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::{solid_earth_tide, TideError};
22use crate::tolerances::{FREQUENCY_DENOMINATOR_EPS_HZ, YAW_SINGULARITY_EPS_RAD};
23use crate::GnssSatelliteId;
24
25const TWO_PI: f64 = 2.0 * PI;
26
27/// Civil date/time fields used by Sidereon PPP correction tables.
28#[derive(Debug, Clone, Copy, PartialEq)]
29pub struct CivilDateTime {
30    pub year: i32,
31    pub month: u8,
32    pub day: u8,
33    pub hour: u8,
34    pub minute: u8,
35    pub second: f64,
36}
37
38/// One satellite observation row needed by the static correction precompute.
39#[derive(Debug, Clone, Copy, PartialEq)]
40pub struct PppCorrectionObservation {
41    pub sat: GnssSatelliteId,
42    pub freq1_hz: f64,
43    pub freq2_hz: f64,
44}
45
46/// One receiver epoch and its visible satellite rows.
47#[derive(Debug, Clone, PartialEq)]
48pub struct PppCorrectionEpoch {
49    pub epoch: CivilDateTime,
50    pub t_rx_j2000_s: f64,
51    pub observations: Vec<PppCorrectionObservation>,
52}
53
54/// Frequency-dependent satellite antenna calibration.
55#[derive(Debug, Clone, PartialEq)]
56pub struct SatelliteAntennaFrequency {
57    pub label: String,
58    pub pco_m: [f64; 3],
59    pub noazi_pcv_m: Vec<(f64, f64)>,
60}
61
62/// Satellite antenna block selected by PRN and validity window.
63#[derive(Debug, Clone, PartialEq)]
64pub struct SatelliteAntenna {
65    pub sat: GnssSatelliteId,
66    pub valid_from: Option<CivilDateTime>,
67    pub valid_until: Option<CivilDateTime>,
68    pub frequencies: Vec<SatelliteAntennaFrequency>,
69}
70
71/// Satellite antenna correction options.
72#[derive(Debug, Clone, PartialEq)]
73pub struct SatelliteAntennaOptions {
74    pub freq1_label: String,
75    pub freq1_hz: f64,
76    pub freq2_label: String,
77    pub freq2_hz: f64,
78    pub antennas: Vec<SatelliteAntenna>,
79}
80
81/// PPP correction precompute switches.
82#[derive(Debug, Clone, PartialEq)]
83pub struct PppCorrectionsOptions {
84    pub solid_earth_tide: bool,
85    pub phase_windup: bool,
86    pub satellite_antenna: Option<SatelliteAntennaOptions>,
87}
88
89/// Indexed vector result. The epoch index refers to the input epoch slice.
90#[derive(Debug, Clone, Copy, PartialEq)]
91pub struct EpochVectorCorrection {
92    pub epoch_index: usize,
93    pub vector_m: [f64; 3],
94}
95
96/// Indexed satellite scalar result. The epoch index refers to the input epoch slice.
97#[derive(Debug, Clone, PartialEq)]
98pub struct SatScalarCorrection {
99    pub sat: GnssSatelliteId,
100    pub epoch_index: usize,
101    pub value_m: f64,
102}
103
104/// Indexed satellite vector result. The epoch index refers to the input epoch slice.
105#[derive(Debug, Clone, PartialEq)]
106pub struct SatVectorCorrection {
107    pub sat: GnssSatelliteId,
108    pub epoch_index: usize,
109    pub vector_m: [f64; 3],
110}
111
112/// Precomputed PPP correction tables.
113#[derive(Debug, Clone, PartialEq, Default)]
114pub struct PppCorrections {
115    pub tide: Vec<EpochVectorCorrection>,
116    pub windup_m: Vec<SatScalarCorrection>,
117    pub sat_pco_ecef: Vec<SatVectorCorrection>,
118    pub sat_pcv_m: Vec<SatScalarCorrection>,
119}
120
121#[derive(Debug, Clone, PartialEq, thiserror::Error)]
122pub enum PppCorrectionsError {
123    #[error("invalid PPP correction input {field}: {reason}")]
124    InvalidInput {
125        field: &'static str,
126        reason: &'static str,
127    },
128    #[error("invalid PPP correction epoch at epoch {epoch_index}: {source}")]
129    Epoch {
130        epoch_index: usize,
131        #[source]
132        source: CoverageError,
133    },
134    #[error("solid Earth tide correction failed at epoch {epoch_index}: {source}")]
135    Tide {
136        epoch_index: usize,
137        #[source]
138        source: TideError,
139    },
140    #[error(
141        "invalid phase wind-up carrier frequencies at epoch {epoch_index} for {sat}: {field} {reason}"
142    )]
143    WindupFrequency {
144        epoch_index: usize,
145        sat: GnssSatelliteId,
146        field: &'static str,
147        reason: &'static str,
148    },
149    #[error("invalid satellite antenna carrier frequencies: {field} {reason}")]
150    SatelliteAntennaFrequency {
151        field: &'static str,
152        reason: &'static str,
153    },
154}
155
156/// Build static PPP correction tables for a precise-orbit arc.
157pub fn build(
158    sp3: &Sp3,
159    epochs: &[PppCorrectionEpoch],
160    receiver_ecef_m: [f64; 3],
161    options: &PppCorrectionsOptions,
162) -> Result<PppCorrections, PppCorrectionsError> {
163    validate_receiver_state(receiver_ecef_m)?;
164
165    let mut corrections = PppCorrections::default();
166    if !options.solid_earth_tide && !options.phase_windup && options.satellite_antenna.is_none() {
167        return Ok(corrections);
168    }
169
170    let satellite_antenna_frequencies = options
171        .satellite_antenna
172        .as_ref()
173        .map(validate_satellite_antenna_options)
174        .transpose()?;
175
176    let mut previous_windup_cycles: BTreeMap<GnssSatelliteId, f64> = BTreeMap::new();
177
178    for (epoch_index, epoch_row) in epochs.iter().enumerate() {
179        let sun_moon =
180            sun_moon_at(epoch_row.epoch).map_err(|source| PppCorrectionsError::Epoch {
181                epoch_index,
182                source,
183            })?;
184
185        if options.solid_earth_tide {
186            let d = tide_at(
187                receiver_ecef_m,
188                epoch_row.epoch,
189                sun_moon.sun,
190                sun_moon.moon,
191            )
192            .map_err(|source| PppCorrectionsError::Tide {
193                epoch_index,
194                source,
195            })?;
196            corrections.tide.push(EpochVectorCorrection {
197                epoch_index,
198                vector_m: d,
199            });
200        }
201
202        for observation in &epoch_row.observations {
203            let obs = match predict(
204                sp3,
205                observation.sat,
206                receiver_ecef_m,
207                epoch_row.t_rx_j2000_s,
208                PredictOptions {
209                    carrier_hz: F_L1_HZ,
210                    light_time: true,
211                    sagnac: true,
212                },
213            ) {
214                Ok(obs) => obs,
215                Err(ObservablesError::InvalidInput { field, kind }) => {
216                    return Err(PppCorrectionsError::InvalidInput {
217                        field,
218                        reason: observables_input_reason(kind),
219                    });
220                }
221                Err(ObservablesError::NoEphemeris | ObservablesError::Ephemeris(_)) => continue,
222            };
223
224            if options.phase_windup {
225                let prev = previous_windup_cycles.get(&observation.sat).copied();
226                if let Some(phw) = windup_cycles(&obs, receiver_ecef_m, sun_moon.sun, prev) {
227                    let (f1, f2) = windup_frequency_pair(options, observation, epoch_index)?;
228                    corrections.windup_m.push(SatScalarCorrection {
229                        sat: observation.sat,
230                        epoch_index,
231                        value_m: windup_metres(phw, f1, f2),
232                    });
233                    previous_windup_cycles.insert(observation.sat, phw);
234                }
235            }
236
237            if let Some(sat_ant) = &options.satellite_antenna {
238                if let Some((pco_ecef, pcv_m)) = satellite_antenna_correction(
239                    &obs,
240                    sun_moon.sun,
241                    observation.sat,
242                    epoch_row.epoch,
243                    sat_ant,
244                    satellite_antenna_frequencies
245                        .expect("satellite antenna frequencies are validated when enabled"),
246                ) {
247                    corrections.sat_pco_ecef.push(SatVectorCorrection {
248                        sat: observation.sat,
249                        epoch_index,
250                        vector_m: pco_ecef,
251                    });
252                    corrections.sat_pcv_m.push(SatScalarCorrection {
253                        sat: observation.sat,
254                        epoch_index,
255                        value_m: pcv_m,
256                    });
257                }
258            }
259        }
260    }
261
262    Ok(corrections)
263}
264
265fn validate_receiver_state(receiver_ecef_m: [f64; 3]) -> Result<(), PppCorrectionsError> {
266    validate::finite_vec3(receiver_ecef_m, "receiver_ecef_m").map_err(ppp_invalid_input)?;
267    validate::finite_positive(norm3(receiver_ecef_m), "receiver radius_m")
268        .map_err(ppp_invalid_input)?;
269    Ok(())
270}
271
272fn ppp_invalid_input(error: validate::FieldError) -> PppCorrectionsError {
273    PppCorrectionsError::InvalidInput {
274        field: error.field(),
275        reason: error.reason(),
276    }
277}
278
279fn observables_input_reason(kind: ObservablesInputErrorKind) -> &'static str {
280    match kind {
281        ObservablesInputErrorKind::NonFinite => "not finite",
282        ObservablesInputErrorKind::NotPositive => "not positive",
283        ObservablesInputErrorKind::Negative => "negative",
284        ObservablesInputErrorKind::OutOfRange => "out of range",
285        ObservablesInputErrorKind::Missing => "missing",
286        ObservablesInputErrorKind::FloatParse => "invalid float",
287        ObservablesInputErrorKind::IntParse => "invalid integer",
288        ObservablesInputErrorKind::InvalidCivilDate => "invalid civil date",
289        ObservablesInputErrorKind::InvalidCivilTime => "invalid civil time",
290    }
291}
292
293fn windup_frequency_pair(
294    options: &PppCorrectionsOptions,
295    observation: &PppCorrectionObservation,
296    epoch_index: usize,
297) -> Result<(f64, f64), PppCorrectionsError> {
298    let (f1_hz, f2_hz) = options
299        .satellite_antenna
300        .as_ref()
301        .map(|a| (a.freq1_hz, a.freq2_hz))
302        .unwrap_or((observation.freq1_hz, observation.freq2_hz));
303    validate_frequency_pair(
304        f1_hz,
305        f2_hz,
306        FrequencyPairFields {
307            freq1: "phase wind-up freq1_hz",
308            freq2: "phase wind-up freq2_hz",
309            pair: "phase wind-up frequency pair",
310        },
311        |field, reason| PppCorrectionsError::WindupFrequency {
312            epoch_index,
313            sat: observation.sat,
314            field,
315            reason,
316        },
317    )
318}
319
320fn validate_satellite_antenna_frequency_pair(
321    options: &SatelliteAntennaOptions,
322) -> Result<(f64, f64), PppCorrectionsError> {
323    validate_frequency_pair(
324        options.freq1_hz,
325        options.freq2_hz,
326        FrequencyPairFields {
327            freq1: "satellite antenna freq1_hz",
328            freq2: "satellite antenna freq2_hz",
329            pair: "satellite antenna frequency pair",
330        },
331        |field, reason| PppCorrectionsError::SatelliteAntennaFrequency { field, reason },
332    )
333}
334
335fn validate_satellite_antenna_options(
336    options: &SatelliteAntennaOptions,
337) -> Result<(f64, f64), PppCorrectionsError> {
338    let frequencies_hz = validate_satellite_antenna_frequency_pair(options)?;
339    validate_satellite_antenna_pcv_samples(options)?;
340    Ok(frequencies_hz)
341}
342
343fn validate_satellite_antenna_pcv_samples(
344    options: &SatelliteAntennaOptions,
345) -> Result<(), PppCorrectionsError> {
346    for antenna in &options.antennas {
347        for frequency in &antenna.frequencies {
348            for &(nadir_deg, pcv_m) in &frequency.noazi_pcv_m {
349                validate::finite(nadir_deg, "satellite antenna noazi_pcv_m")
350                    .map_err(ppp_invalid_input)?;
351                validate::finite(pcv_m, "satellite antenna noazi_pcv_m")
352                    .map_err(ppp_invalid_input)?;
353            }
354        }
355    }
356    Ok(())
357}
358
359#[derive(Debug, Clone, Copy)]
360struct FrequencyPairFields {
361    freq1: &'static str,
362    freq2: &'static str,
363    pair: &'static str,
364}
365
366fn validate_frequency_pair(
367    f1_hz: f64,
368    f2_hz: f64,
369    fields: FrequencyPairFields,
370    invalid: impl Fn(&'static str, &'static str) -> PppCorrectionsError,
371) -> Result<(f64, f64), PppCorrectionsError> {
372    let f1_hz = validate::finite_positive(f1_hz, fields.freq1)
373        .map_err(|e| invalid(e.field(), e.reason()))?;
374    let f2_hz = validate::finite_positive(f2_hz, fields.freq2)
375        .map_err(|e| invalid(e.field(), e.reason()))?;
376    if (f1_hz - f2_hz).abs() < FREQUENCY_DENOMINATOR_EPS_HZ {
377        Err(invalid(fields.pair, "must differ"))
378    } else {
379        Ok((f1_hz, f2_hz))
380    }
381}
382
383fn sun_moon_at(epoch: CivilDateTime) -> Result<SunMoon, CoverageError> {
384    let ts = time_scales_at(epoch)?;
385    Ok(sun_moon_ecef(&ts).expect("validated time scales produce Sun/Moon vectors"))
386}
387
388fn time_scales_at(epoch: CivilDateTime) -> Result<TimeScales, CoverageError> {
389    let civil = validate::civil_datetime_with_second_policy(
390        i64::from(epoch.year),
391        i64::from(epoch.month),
392        i64::from(epoch.day),
393        i64::from(epoch.hour),
394        i64::from(epoch.minute),
395        epoch.second,
396        validate::CivilSecondPolicy::UtcLike,
397    )
398    .map_err(|error| CoverageError::InvalidInput {
399        field: error.field(),
400        kind: TimeScaleInputErrorKind::from(&error),
401    })?;
402
403    TimeScales::from_utc_validated(
404        civil.year as i32,
405        civil.month as i32,
406        civil.day as i32,
407        civil.hour as i32,
408        civil.minute as i32,
409        civil.second,
410        ValidityMode::Strict,
411    )
412    .map(|validated| validated.value)
413}
414
415fn tide_at(
416    receiver_ecef_m: [f64; 3],
417    epoch: CivilDateTime,
418    sun_ecef_m: [f64; 3],
419    moon_ecef_m: [f64; 3],
420) -> Result<[f64; 3], TideError> {
421    let fhr = epoch.hour as f64 + epoch.minute as f64 / 60.0 + epoch.second / 3600.0;
422    solid_earth_tide(
423        &receiver_ecef_m,
424        epoch.year,
425        epoch.month as i32,
426        epoch.day as i32,
427        fhr,
428        &sun_ecef_m,
429        &moon_ecef_m,
430    )
431}
432
433fn windup_metres(phw_cycles: f64, f1_hz: f64, f2_hz: f64) -> f64 {
434    let lam1 = C_M_S / f1_hz;
435    let lam2 = C_M_S / f2_hz;
436    let gamma = ionosphere_free_gamma(f1_hz, f2_hz);
437    (gamma * lam1 - (gamma - 1.0) * lam2) * phw_cycles
438}
439
440fn windup_cycles(
441    pred: &PredictedObservables,
442    receiver_ecef_m: [f64; 3],
443    sun_ecef_m: [f64; 3],
444    prev_phw: Option<f64>,
445) -> Option<f64> {
446    let rs = pred.sat_pos_ecef_m;
447    let vs = pred.sat_velocity_m_s;
448    let (exs, eys) = sat_yaw(rs, vs, sun_ecef_m)?;
449    let ek = unit3(sub3(receiver_ecef_m, rs))?;
450
451    let (n, e, _u) = crate::estimation::substrate::frames::local_neu_basis(
452        crate::estimation::recipe::FrameRecipe::GeodeticNeuCrossProduct,
453        receiver_ecef_m,
454    );
455    let exr = n;
456    let eyr = neg3(e);
457
458    let eks = cross3(ek, eys);
459    let ekr = cross3(ek, eyr);
460    let ds = sub3(exs, add3(scale3(ek, dot3(ek, exs)), eks));
461    let dr = sub3(exr, sub3(scale3(ek, dot3(ek, exr)), ekr));
462
463    let nds = norm3(ds);
464    let ndr = norm3(dr);
465    if nds == 0.0 || ndr == 0.0 {
466        return None;
467    }
468
469    let cosp = clamp(dot3(ds, dr) / nds / ndr);
470    let mut ph = cosp.acos() / TWO_PI;
471    let drs = cross3(ds, dr);
472    if dot3(ek, drs) < 0.0 {
473        ph = -ph;
474    }
475
476    Some(match prev_phw {
477        None => ph,
478        Some(prev) => ph + (prev - ph + 0.5).floor(),
479    })
480}
481
482fn sat_yaw(rs: [f64; 3], vs: [f64; 3], sun_ecef_m: [f64; 3]) -> Option<([f64; 3], [f64; 3])> {
483    let ri_v = [
484        vs[0] - OMEGA_E_DOT_RAD_S * rs[1],
485        vs[1] + OMEGA_E_DOT_RAD_S * rs[0],
486        vs[2],
487    ];
488    let n = cross3(rs, ri_v);
489    let p = cross3(sun_ecef_m, n);
490
491    let es = unit3(rs)?;
492    let esun = unit3(sun_ecef_m)?;
493    let en = unit3(n)?;
494    let ep = unit3(p)?;
495
496    let beta = PI / 2.0 - clamp(dot3(esun, en)).acos();
497    let ee = clamp(dot3(es, ep)).acos();
498    let mut mu = PI / 2.0 + if dot3(es, esun) <= 0.0 { -ee } else { ee };
499
500    if mu < -PI / 2.0 {
501        mu += TWO_PI;
502    } else if mu >= PI / 2.0 {
503        mu -= TWO_PI;
504    }
505
506    let yaw = yaw_nominal(beta, mu);
507    let ex = cross3(en, es);
508    let cosy = yaw.cos();
509    let siny = yaw.sin();
510    let exs = add3(scale3(en, -siny), scale3(ex, cosy));
511    let eys = add3(scale3(en, -cosy), scale3(ex, -siny));
512    Some((exs, eys))
513}
514
515fn yaw_nominal(beta: f64, mu: f64) -> f64 {
516    if beta.abs() < YAW_SINGULARITY_EPS_RAD && mu.abs() < YAW_SINGULARITY_EPS_RAD {
517        PI
518    } else {
519        (-beta.tan()).atan2(mu.sin()) + PI
520    }
521}
522
523fn satellite_antenna_correction(
524    pred: &PredictedObservables,
525    sun_ecef_m: [f64; 3],
526    sat: GnssSatelliteId,
527    epoch: CivilDateTime,
528    options: &SatelliteAntennaOptions,
529    frequencies_hz: (f64, f64),
530) -> Option<([f64; 3], f64)> {
531    let rs = pred.sat_pos_ecef_m;
532    let ant = options.antenna_for(sat, epoch)?;
533
534    let ez = unit3(neg3(rs))?;
535    let es = unit3(sub3(sun_ecef_m, rs))?;
536    let ey = unit3(cross3(ez, es))?;
537    let ex = cross3(ey, ez);
538
539    let off1 = ant.pco(&options.freq1_label)?;
540    let off2 = ant.pco(&options.freq2_label)?;
541    let gamma = ionosphere_free_gamma(frequencies_hz.0, frequencies_hz.1);
542
543    let dant1 = body_to_ecef(off1, ex, ey, ez);
544    let dant2 = body_to_ecef(off2, ex, ey, ez);
545    let dant_ecef = sub3(scale3(dant1, gamma), scale3(dant2, gamma - 1.0));
546    let pcv_m = nadir_pcv_if(ant, pred, options, gamma)?;
547
548    Some((dant_ecef, pcv_m))
549}
550
551fn body_to_ecef(pco_body_m: [f64; 3], ex: [f64; 3], ey: [f64; 3], ez: [f64; 3]) -> [f64; 3] {
552    add3(
553        add3(scale3(ex, pco_body_m[0]), scale3(ey, pco_body_m[1])),
554        scale3(ez, pco_body_m[2]),
555    )
556}
557
558fn ionosphere_free_gamma(f1_hz: f64, f2_hz: f64) -> f64 {
559    let f1_sq = f1_hz * f1_hz;
560    f1_sq / (f1_sq - f2_hz * f2_hz)
561}
562
563fn nadir_pcv_if(
564    ant: &SatelliteAntenna,
565    pred: &PredictedObservables,
566    options: &SatelliteAntennaOptions,
567    gamma: f64,
568) -> Option<f64> {
569    let eu = unit3(neg3(pred.los_unit))?;
570    let ez = unit3(neg3(pred.sat_pos_ecef_m))?;
571    let nadir_deg = clamp(dot3(eu, ez)).acos() * RAD_TO_DEG;
572    let p1 = ant.pcv_noazi(&options.freq1_label, nadir_deg)?;
573    let p2 = ant.pcv_noazi(&options.freq2_label, nadir_deg)?;
574    Some(gamma * p1 - (gamma - 1.0) * p2)
575}
576
577impl SatelliteAntennaOptions {
578    fn antenna_for(&self, sat: GnssSatelliteId, epoch: CivilDateTime) -> Option<&SatelliteAntenna> {
579        self.antennas
580            .iter()
581            .find(|ant| ant.sat == sat && ant.valid_at(epoch))
582    }
583}
584
585impl SatelliteAntenna {
586    fn valid_at(&self, epoch: CivilDateTime) -> bool {
587        let after_from = self
588            .valid_from
589            .map(|from| civil_cmp(epoch, from) != std::cmp::Ordering::Less)
590            .unwrap_or(true);
591        let before_until = self
592            .valid_until
593            .map(|until| civil_cmp(epoch, until) != std::cmp::Ordering::Greater)
594            .unwrap_or(true);
595        after_from && before_until
596    }
597
598    fn frequency(&self, label: &str) -> Option<&SatelliteAntennaFrequency> {
599        self.frequencies
600            .iter()
601            .find(|f| f.label.trim() == label.trim())
602    }
603
604    fn pco(&self, label: &str) -> Option<[f64; 3]> {
605        self.frequency(label).map(|f| f.pco_m)
606    }
607
608    fn pcv_noazi(&self, label: &str, zenith_deg: f64) -> Option<f64> {
609        let frequency = self.frequency(label)?;
610        interpolate_samples(&frequency.noazi_pcv_m, zenith_deg)
611    }
612}
613
614fn civil_cmp(a: CivilDateTime, b: CivilDateTime) -> std::cmp::Ordering {
615    (
616        a.year,
617        a.month,
618        a.day,
619        a.hour,
620        a.minute,
621        ordered_seconds(a.second),
622    )
623        .cmp(&(
624            b.year,
625            b.month,
626            b.day,
627            b.hour,
628            b.minute,
629            ordered_seconds(b.second),
630        ))
631}
632
633fn ordered_seconds(second: f64) -> i64 {
634    (second * 1_000_000.0).round() as i64
635}
636
637fn interpolate_samples(samples: &[(f64, f64)], zenith_deg: f64) -> Option<f64> {
638    let mut sorted = samples.to_vec();
639    sorted.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
640    antenna::interpolate_zenith_sorted(&sorted, zenith_deg)
641}
642
643fn clamp(x: f64) -> f64 {
644    x.clamp(-1.0, 1.0)
645}
646
647#[cfg(all(test, sidereon_repo_tests))]
648mod tests {
649    use super::*;
650    use crate::constants::{F_L2_HZ, SECONDS_PER_DAY};
651    use crate::observables::j2000_seconds_from_split;
652    use crate::GnssSystem;
653
654    fn sp3_fixture() -> Sp3 {
655        let path = concat!(
656            env!("CARGO_MANIFEST_DIR"),
657            "/tests/fixtures/sp3/GRG0MGXFIN_20201760000_01D_15M_ORB.SP3"
658        );
659        let bytes = std::fs::read(path).unwrap_or_else(|e| panic!("read SP3 fixture {path}: {e}"));
660        Sp3::parse(&bytes).expect("parse SP3 fixture")
661    }
662
663    fn civil(year: i32, month: u8, day: u8, hour: u8, minute: u8, second: f64) -> CivilDateTime {
664        CivilDateTime {
665            year,
666            month,
667            day,
668            hour,
669            minute,
670            second,
671        }
672    }
673
674    fn split_jd(epoch: CivilDateTime) -> (f64, f64) {
675        let a = (14 - epoch.month as i32) / 12;
676        let y = epoch.year + 4800 - a;
677        let m = epoch.month as i32 + 12 * a - 3;
678        let jdn =
679            epoch.day as i32 + (153 * m + 2) / 5 + 365 * y + y / 4 - y / 100 + y / 400 - 32_045;
680        let jd_whole = jdn as f64 - 0.5;
681        let day_seconds =
682            epoch.hour as f64 * 3600.0 + epoch.minute as f64 * 60.0 + epoch.second / 1.0;
683        (jd_whole, day_seconds / SECONDS_PER_DAY)
684    }
685
686    fn fake_antenna_options(sat: GnssSatelliteId) -> SatelliteAntennaOptions {
687        SatelliteAntennaOptions {
688            freq1_label: "G01".to_string(),
689            freq1_hz: F_L1_HZ,
690            freq2_label: "G02".to_string(),
691            freq2_hz: F_L2_HZ,
692            antennas: vec![SatelliteAntenna {
693                sat,
694                valid_from: Some(civil(2020, 1, 1, 0, 0, 0.0)),
695                valid_until: Some(civil(2021, 1, 1, 0, 0, 0.0)),
696                frequencies: vec![
697                    SatelliteAntennaFrequency {
698                        label: "G01".to_string(),
699                        pco_m: [0.1, -0.2, 1.0],
700                        noazi_pcv_m: vec![(0.0, 0.001), (5.0, 0.002), (10.0, 0.004)],
701                    },
702                    SatelliteAntennaFrequency {
703                        label: "G02".to_string(),
704                        pco_m: [-0.1, 0.3, 0.5],
705                        noazi_pcv_m: vec![(0.0, -0.001), (5.0, -0.002), (10.0, -0.003)],
706                    },
707                ],
708            }],
709        }
710    }
711
712    fn windup_epoch(sat: GnssSatelliteId, freq1_hz: f64, freq2_hz: f64) -> PppCorrectionEpoch {
713        let epoch = civil(2020, 6, 24, 12, 0, 0.0);
714        let (jd_whole, jd_fraction) = split_jd(epoch);
715        PppCorrectionEpoch {
716            epoch,
717            t_rx_j2000_s: j2000_seconds_from_split(jd_whole, jd_fraction)
718                .expect("valid split Julian date"),
719            observations: vec![PppCorrectionObservation {
720                sat,
721                freq1_hz,
722                freq2_hz,
723            }],
724        }
725    }
726
727    #[test]
728    fn ppp_corrections_match_elixir_reference_fixture() {
729        let sp3 = sp3_fixture();
730        let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
731        let epoch = civil(2020, 6, 24, 12, 0, 0.0);
732        let (jd_whole, jd_fraction) = split_jd(epoch);
733        let receiver = [3_512_900.0, 780_500.0, 5_248_700.0];
734        let epochs = vec![PppCorrectionEpoch {
735            epoch,
736            t_rx_j2000_s: j2000_seconds_from_split(jd_whole, jd_fraction)
737                .expect("valid split Julian date"),
738            observations: vec![PppCorrectionObservation {
739                sat,
740                freq1_hz: F_L1_HZ,
741                freq2_hz: F_L2_HZ,
742            }],
743        }];
744        let options = PppCorrectionsOptions {
745            solid_earth_tide: true,
746            phase_windup: true,
747            satellite_antenna: Some(fake_antenna_options(sat)),
748        };
749
750        let got = build(&sp3, &epochs, receiver, &options).expect("valid PPP corrections");
751
752        assert_eq!(got.tide.len(), 1);
753        assert_eq!(
754            got.tide[0].vector_m.map(f64::to_bits),
755            [0x3FB8BC98E788ED00, 0x3FAA54D8C1097508, 0x3FB03498C46B3B50]
756        );
757        assert_eq!(got.windup_m.len(), 1);
758        assert_eq!(got.windup_m[0].value_m.to_bits(), 0xBF808DE79DBD2C16);
759        assert_eq!(got.sat_pco_ecef.len(), 1);
760        assert_eq!(
761            got.sat_pco_ecef[0].vector_m.map(f64::to_bits),
762            [0xBFE58ED947570048, 0x3FDEDBB280CEB1BE, 0xBFFE3BCA6A354E4A]
763        );
764        assert_eq!(got.sat_pcv_m.len(), 1);
765        assert_eq!(got.sat_pcv_m[0].value_m.to_bits(), 0x3F77617E95BD232C);
766    }
767
768    #[test]
769    fn phase_windup_rejects_invalid_observation_frequency_pairs() {
770        let sp3 = sp3_fixture();
771        let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
772        let receiver = [3_512_900.0, 780_500.0, 5_248_700.0];
773        let options = PppCorrectionsOptions {
774            solid_earth_tide: false,
775            phase_windup: true,
776            satellite_antenna: None,
777        };
778        let cases = [
779            (0.0, F_L2_HZ, "phase wind-up freq1_hz", "not positive"),
780            (-F_L1_HZ, F_L2_HZ, "phase wind-up freq1_hz", "not positive"),
781            (
782                F_L1_HZ,
783                F_L1_HZ,
784                "phase wind-up frequency pair",
785                "must differ",
786            ),
787        ];
788
789        for (freq1_hz, freq2_hz, field, reason) in cases {
790            let epochs = vec![windup_epoch(sat, freq1_hz, freq2_hz)];
791            let err = build(&sp3, &epochs, receiver, &options)
792                .expect_err("invalid phase wind-up frequencies must error");
793
794            assert_eq!(
795                err,
796                PppCorrectionsError::WindupFrequency {
797                    epoch_index: 0,
798                    sat,
799                    field,
800                    reason,
801                }
802            );
803        }
804    }
805
806    #[test]
807    fn phase_windup_observation_frequency_pair_computes_finite_correction() {
808        let sp3 = sp3_fixture();
809        let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
810        let receiver = [3_512_900.0, 780_500.0, 5_248_700.0];
811        let options = PppCorrectionsOptions {
812            solid_earth_tide: false,
813            phase_windup: true,
814            satellite_antenna: None,
815        };
816        let epochs = vec![windup_epoch(sat, F_L1_HZ, F_L2_HZ)];
817
818        let got =
819            build(&sp3, &epochs, receiver, &options).expect("valid phase wind-up frequencies");
820
821        assert_eq!(got.windup_m.len(), 1);
822        assert!(got.windup_m[0].value_m.is_finite());
823    }
824
825    #[test]
826    fn satellite_antenna_rejects_invalid_frequency_pairs_without_windup() {
827        let sp3 = sp3_fixture();
828        let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
829        let receiver = [3_512_900.0, 780_500.0, 5_248_700.0];
830        let cases = [
831            (0.0, F_L2_HZ, "satellite antenna freq1_hz", "not positive"),
832            (
833                F_L1_HZ,
834                f64::INFINITY,
835                "satellite antenna freq2_hz",
836                "not finite",
837            ),
838            (
839                f64::NAN,
840                F_L2_HZ,
841                "satellite antenna freq1_hz",
842                "not finite",
843            ),
844            (
845                F_L1_HZ,
846                F_L1_HZ,
847                "satellite antenna frequency pair",
848                "must differ",
849            ),
850        ];
851
852        for (freq1_hz, freq2_hz, field, reason) in cases {
853            let mut antenna = fake_antenna_options(sat);
854            antenna.freq1_hz = freq1_hz;
855            antenna.freq2_hz = freq2_hz;
856            let options = PppCorrectionsOptions {
857                solid_earth_tide: false,
858                phase_windup: false,
859                satellite_antenna: Some(antenna),
860            };
861            let epochs = vec![windup_epoch(sat, F_L1_HZ, F_L2_HZ)];
862
863            let err = build(&sp3, &epochs, receiver, &options)
864                .expect_err("invalid satellite antenna frequencies must error");
865
866            assert_eq!(
867                err,
868                PppCorrectionsError::SatelliteAntennaFrequency { field, reason }
869            );
870        }
871    }
872
873    #[test]
874    fn satellite_antenna_frequency_pair_computes_finite_corrections_without_windup() {
875        let sp3 = sp3_fixture();
876        let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
877        let receiver = [3_512_900.0, 780_500.0, 5_248_700.0];
878        let options = PppCorrectionsOptions {
879            solid_earth_tide: false,
880            phase_windup: false,
881            satellite_antenna: Some(fake_antenna_options(sat)),
882        };
883        let epochs = vec![windup_epoch(sat, F_L1_HZ, F_L2_HZ)];
884
885        let got =
886            build(&sp3, &epochs, receiver, &options).expect("valid satellite antenna frequencies");
887
888        assert!(got.windup_m.is_empty());
889        assert_eq!(got.sat_pco_ecef.len(), 1);
890        assert!(got.sat_pco_ecef[0]
891            .vector_m
892            .iter()
893            .all(|value| value.is_finite()));
894        assert_eq!(got.sat_pcv_m.len(), 1);
895        assert!(got.sat_pcv_m[0].value_m.is_finite());
896    }
897
898    #[test]
899    fn satellite_antenna_rejects_non_finite_pcv_samples() {
900        let sp3 = sp3_fixture();
901        let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
902        let receiver = [3_512_900.0, 780_500.0, 5_248_700.0];
903        let mut antenna = fake_antenna_options(sat);
904        antenna.antennas[0].frequencies[0].noazi_pcv_m[1] = (5.0, f64::NAN);
905        let options = PppCorrectionsOptions {
906            solid_earth_tide: false,
907            phase_windup: false,
908            satellite_antenna: Some(antenna),
909        };
910        let epochs = vec![windup_epoch(sat, F_L1_HZ, F_L2_HZ)];
911
912        let err = build(&sp3, &epochs, receiver, &options)
913            .expect_err("non-finite satellite PCV samples must error");
914
915        assert_eq!(
916            err,
917            PppCorrectionsError::InvalidInput {
918                field: "satellite antenna noazi_pcv_m",
919                reason: "not finite",
920            }
921        );
922    }
923
924    #[test]
925    fn satellite_antenna_empty_pcv_grid_is_not_materialized_as_zero() {
926        let sp3 = sp3_fixture();
927        let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
928        let epoch = civil(2020, 6, 24, 12, 0, 0.0);
929        let (jd_whole, jd_fraction) = split_jd(epoch);
930        let receiver = [3_512_900.0, 780_500.0, 5_248_700.0];
931        let epochs = vec![PppCorrectionEpoch {
932            epoch,
933            t_rx_j2000_s: j2000_seconds_from_split(jd_whole, jd_fraction)
934                .expect("valid split Julian date"),
935            observations: vec![PppCorrectionObservation {
936                sat,
937                freq1_hz: F_L1_HZ,
938                freq2_hz: F_L2_HZ,
939            }],
940        }];
941        let mut antenna = fake_antenna_options(sat);
942        antenna.antennas[0].frequencies[0].noazi_pcv_m.clear();
943        let options = PppCorrectionsOptions {
944            solid_earth_tide: false,
945            phase_windup: false,
946            satellite_antenna: Some(antenna),
947        };
948
949        let got = build(&sp3, &epochs, receiver, &options).expect("valid PPP corrections");
950
951        assert!(got.sat_pco_ecef.is_empty());
952        assert!(got.sat_pcv_m.is_empty());
953    }
954
955    #[test]
956    fn build_rejects_non_finite_receive_time_for_satellite_corrections() {
957        let sp3 = sp3_fixture();
958        let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
959        let options = PppCorrectionsOptions {
960            solid_earth_tide: false,
961            phase_windup: true,
962            satellite_antenna: None,
963        };
964        let epochs = vec![PppCorrectionEpoch {
965            epoch: civil(2020, 6, 24, 12, 0, 0.0),
966            t_rx_j2000_s: f64::NAN,
967            observations: vec![PppCorrectionObservation {
968                sat,
969                freq1_hz: F_L1_HZ,
970                freq2_hz: F_L2_HZ,
971            }],
972        }];
973
974        let err = build(
975            &sp3,
976            &epochs,
977            [3_512_900.0, 780_500.0, 5_248_700.0],
978            &options,
979        )
980        .expect_err("non-finite receive time must be reported");
981
982        assert_eq!(
983            err,
984            PppCorrectionsError::InvalidInput {
985                field: "t_rx_j2000_s",
986                reason: "not finite",
987            }
988        );
989    }
990
991    #[test]
992    fn noazi_pcv_interpolation_clamps_and_interpolates() {
993        let samples = vec![(10.0, 4.0), (0.0, 1.0), (5.0, 2.0)];
994
995        assert_eq!(interpolate_samples(&samples, -1.0), Some(1.0));
996        assert_eq!(interpolate_samples(&samples, 2.5), Some(1.5));
997        assert_eq!(interpolate_samples(&samples, 99.0), Some(4.0));
998    }
999
1000    #[test]
1001    fn build_rejects_invalid_receiver_state_before_disabled_short_circuit() {
1002        let sp3 = sp3_fixture();
1003        let options = PppCorrectionsOptions {
1004            solid_earth_tide: false,
1005            phase_windup: false,
1006            satellite_antenna: None,
1007        };
1008
1009        for (receiver, field, reason) in [
1010            (
1011                [f64::NAN, 780_500.0, 5_248_700.0],
1012                "receiver_ecef_m",
1013                "not finite",
1014            ),
1015            ([0.0, 0.0, 0.0], "receiver radius_m", "not positive"),
1016        ] {
1017            let err = build(&sp3, &[], receiver, &options)
1018                .expect_err("invalid receiver state must error before empty success");
1019
1020            assert_eq!(err, PppCorrectionsError::InvalidInput { field, reason });
1021        }
1022    }
1023
1024    #[test]
1025    fn build_rejects_invalid_correction_epoch_without_panicking() {
1026        let sp3 = sp3_fixture();
1027        let options = PppCorrectionsOptions {
1028            solid_earth_tide: true,
1029            phase_windup: false,
1030            satellite_antenna: None,
1031        };
1032        let epochs = vec![PppCorrectionEpoch {
1033            epoch: civil(2021, 2, 29, 12, 0, 0.0),
1034            t_rx_j2000_s: 0.0,
1035            observations: Vec::new(),
1036        }];
1037
1038        let err = build(
1039            &sp3,
1040            &epochs,
1041            [3_512_900.0, 780_500.0, 5_248_700.0],
1042            &options,
1043        )
1044        .expect_err("invalid PPP correction epoch must return an error");
1045
1046        assert_eq!(
1047            err,
1048            PppCorrectionsError::Epoch {
1049                epoch_index: 0,
1050                source: CoverageError::InvalidInput {
1051                    field: "civil datetime",
1052                    kind: TimeScaleInputErrorKind::InvalidCivilDate,
1053                },
1054            }
1055        );
1056    }
1057
1058    #[test]
1059    fn build_rejects_non_finite_correction_epoch_without_panicking() {
1060        let sp3 = sp3_fixture();
1061        let options = PppCorrectionsOptions {
1062            solid_earth_tide: true,
1063            phase_windup: false,
1064            satellite_antenna: None,
1065        };
1066        let epochs = vec![PppCorrectionEpoch {
1067            epoch: civil(2020, 6, 24, 12, 0, f64::NAN),
1068            t_rx_j2000_s: 0.0,
1069            observations: Vec::new(),
1070        }];
1071
1072        let err = build(
1073            &sp3,
1074            &epochs,
1075            [3_512_900.0, 780_500.0, 5_248_700.0],
1076            &options,
1077        )
1078        .expect_err("non-finite PPP correction epoch must return an error");
1079
1080        assert_eq!(
1081            err,
1082            PppCorrectionsError::Epoch {
1083                epoch_index: 0,
1084                source: CoverageError::InvalidInput {
1085                    field: "civil datetime",
1086                    kind: TimeScaleInputErrorKind::NonFinite,
1087                },
1088            }
1089        );
1090    }
1091
1092    #[test]
1093    fn build_rejects_correction_epoch_after_eop_coverage() {
1094        let sp3 = sp3_fixture();
1095        let options = PppCorrectionsOptions {
1096            solid_earth_tide: true,
1097            phase_windup: false,
1098            satellite_antenna: None,
1099        };
1100        let epochs = vec![PppCorrectionEpoch {
1101            epoch: civil(2100, 1, 1, 0, 0, 0.0),
1102            t_rx_j2000_s: 0.0,
1103            observations: Vec::new(),
1104        }];
1105
1106        let err = build(
1107            &sp3,
1108            &epochs,
1109            [3_512_900.0, 780_500.0, 5_248_700.0],
1110            &options,
1111        )
1112        .expect_err("post-coverage PPP correction epoch must return an error");
1113
1114        assert_eq!(
1115            err,
1116            PppCorrectionsError::Epoch {
1117                epoch_index: 0,
1118                source: CoverageError::OutsideCoverage(
1119                    crate::astro::time::DegradeReason::AfterCoverage
1120                ),
1121            }
1122        );
1123    }
1124
1125    #[test]
1126    fn build_accepts_valid_correction_epoch() {
1127        let sp3 = sp3_fixture();
1128        let options = PppCorrectionsOptions {
1129            solid_earth_tide: true,
1130            phase_windup: false,
1131            satellite_antenna: None,
1132        };
1133        let epochs = vec![PppCorrectionEpoch {
1134            epoch: civil(2020, 6, 24, 12, 0, 0.0),
1135            t_rx_j2000_s: 0.0,
1136            observations: Vec::new(),
1137        }];
1138
1139        let got = build(
1140            &sp3,
1141            &epochs,
1142            [3_512_900.0, 780_500.0, 5_248_700.0],
1143            &options,
1144        )
1145        .expect("valid PPP correction epoch must build");
1146
1147        assert_eq!(got.tide.len(), 1);
1148    }
1149
1150    #[test]
1151    fn build_rejects_degenerate_receiver_state_before_tide() {
1152        let sp3 = sp3_fixture();
1153        let epoch = civil(2020, 6, 24, 12, 0, 0.0);
1154        let options = PppCorrectionsOptions {
1155            solid_earth_tide: true,
1156            phase_windup: false,
1157            satellite_antenna: None,
1158        };
1159        let epochs = vec![PppCorrectionEpoch {
1160            epoch,
1161            t_rx_j2000_s: 0.0,
1162            observations: Vec::new(),
1163        }];
1164
1165        let err = build(&sp3, &epochs, [0.0, 0.0, 0.0], &options)
1166            .expect_err("degenerate tide geometry must error");
1167
1168        assert_eq!(
1169            err,
1170            PppCorrectionsError::InvalidInput {
1171                field: "receiver radius_m",
1172                reason: "not positive",
1173            }
1174        );
1175    }
1176}