Skip to main content

sidereon_core/
positioning.rs

1//! Single-point positioning and GNSS geometry diagnostics.
2
3use std::collections::{BTreeMap, BTreeSet};
4use std::fmt;
5
6pub use crate::dop::{dop, Dop, DopError, LineOfSight};
7use crate::ephemeris::{BroadcastEphemeris, Sp3};
8pub use crate::quality::{
9    spp_robust_fde_driver, FdeError, FdeOptions, FdeResult, FdeSppError, FdeSppOptions,
10};
11use crate::rinex::observations::{pseudoranges, ObsEpochTime, ObservationFile, SignalPolicy};
12pub use crate::spp::{
13    residual_rms, solve, solve_broadcast, solve_doppler_velocity, solve_spp_batch_parallel,
14    solve_spp_batch_serial, solve_with_doppler_velocity, solve_with_fallback, solve_with_policy,
15    solve_with_solver, BroadcastReason, Corrections, DopplerObservation, DopplerVelocityInputs,
16    EphemerisSource, FallbackError, FixSource, GalileoNequickCoeffs, KlobucharCoeffs, Observation,
17    ReceiverSolution, RejectedSat, RejectionReason, RobustConfig, SolutionMetadata, SolveInputs,
18    SolvePolicy, SolvePolicyError, SourcedSolution, SppDopplerSolution, SppError, SurfaceMet,
19    DEFAULT_HUBER_K, DEFAULT_ROBUST_MAX_OUTER, DEFAULT_ROBUST_OUTER_TOL_M,
20    DEFAULT_ROBUST_SCALE_FLOOR_M, ELEVATION_MASK_RAD, SIGMA0_M, TRANSMIT_TIME_ITERATIONS,
21};
22pub use crate::static_positioning::{
23    solve_static, StaticClockBias, StaticCovariance, StaticEpoch, StaticEpochInfluence,
24    StaticInfluenceStatus, StaticResidual, StaticSatelliteBatchInfluence, StaticSatelliteInfluence,
25    StaticSolution, StaticSolutionMetadata, StaticSolveError, StaticSolveOptions,
26};
27pub use crate::static_reference_station::{
28    solve_static_reference_station_rinex, StaticReferenceCarrierRinexOptions,
29    StaticReferenceCarrierSolution, StaticReferenceCodeSolution, StaticReferenceEpochDiagnostic,
30    StaticReferenceFixStatus, StaticReferenceModeError, StaticReferenceModeReport,
31    StaticReferenceModeStatus, StaticReferenceStationCovariance, StaticReferenceStationError,
32    StaticReferenceStationMode, StaticReferenceStationRinexOptions, StaticReferenceStationSolution,
33};
34use crate::{astro::time, Error as CoreError, GnssSatelliteId};
35
36/// Role-oriented alias for a solved receiver state.
37pub type Solution = ReceiverSolution;
38
39/// Error type returned by [`solve`].
40pub type Error = SppError;
41
42/// Assembly-time error from building SPP inputs out of a parsed RINEX
43/// observation file.
44#[derive(Debug, Clone, PartialEq, Eq)]
45#[non_exhaustive]
46pub enum RinexSppError {
47    /// A RINEX observation helper rejected malformed or non-finite input.
48    Observation(CoreError),
49    /// No initial receiver position was supplied and the observation header did
50    /// not carry `APPROX POSITION XYZ`.
51    MissingApproxPosition,
52}
53
54impl fmt::Display for RinexSppError {
55    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
56        match self {
57            Self::Observation(error) => write!(f, "RINEX SPP observation assembly failed: {error}"),
58            Self::MissingApproxPosition => {
59                f.write_str("RINEX SPP assembly needs APPROX POSITION XYZ or an initial guess")
60            }
61        }
62    }
63}
64
65impl std::error::Error for RinexSppError {}
66
67impl From<CoreError> for RinexSppError {
68    fn from(error: CoreError) -> Self {
69        Self::Observation(error)
70    }
71}
72
73/// Broadcast correction metadata used while converting RINEX observations into
74/// SPP [`SolveInputs`].
75///
76/// A broadcast navigation product supplies ionosphere coefficients and GLONASS
77/// FDMA channels. A precise SP3 product does not, so its default assembly
78/// context is zero Klobuchar coefficients and no GLONASS channels. When solving
79/// precise SP3 positions with broadcast atmosphere metadata, wrap the SP3 with
80/// [`RinexSppSource::with_broadcast_context`].
81#[derive(Debug, Clone, PartialEq)]
82pub struct RinexSppBroadcastCorrections {
83    /// GPS Klobuchar coefficients, also used as the fallback for systems
84    /// without a dedicated correction set.
85    pub klobuchar: KlobucharCoeffs,
86    /// BeiDou-specific Klobuchar coefficients, when a NAV product provides
87    /// `BDSA`/`BDSB`.
88    pub beidou_klobuchar: Option<KlobucharCoeffs>,
89    /// Galileo NeQuick-G coefficients, when a NAV product provides `GAL`.
90    pub galileo_nequick: Option<GalileoNequickCoeffs>,
91    /// GLONASS FDMA channel numbers keyed by GLONASS slot.
92    pub glonass_channels: BTreeMap<u8, i8>,
93}
94
95impl Default for RinexSppBroadcastCorrections {
96    fn default() -> Self {
97        Self {
98            klobuchar: zero_klobuchar(),
99            beidou_klobuchar: None,
100            galileo_nequick: None,
101            glonass_channels: BTreeMap::new(),
102        }
103    }
104}
105
106/// Source of non-observation metadata needed during RINEX SPP assembly.
107///
108/// [`BroadcastEphemeris`] implements this trait with its parsed NAV ionosphere
109/// coefficients and GLONASS FDMA channels. [`Sp3`] implements it with empty
110/// broadcast metadata so precise-only callers can still assemble
111/// troposphere-only or no-correction inputs.
112pub trait RinexSppAssemblySource {
113    /// Broadcast correction metadata available to RINEX SPP assembly.
114    fn rinex_spp_broadcast_corrections(&self) -> RinexSppBroadcastCorrections;
115}
116
117impl RinexSppAssemblySource for BroadcastEphemeris {
118    fn rinex_spp_broadcast_corrections(&self) -> RinexSppBroadcastCorrections {
119        let iono = self.iono_corrections();
120        let gps = iono.gps.map(klobuchar_from_alpha_beta);
121        RinexSppBroadcastCorrections {
122            klobuchar: gps.unwrap_or_else(zero_klobuchar),
123            beidou_klobuchar: iono.beidou.map(klobuchar_from_alpha_beta),
124            galileo_nequick: iono.galileo,
125            glonass_channels: self.glonass_frequency_channels(),
126        }
127    }
128}
129
130impl RinexSppAssemblySource for Sp3 {
131    fn rinex_spp_broadcast_corrections(&self) -> RinexSppBroadcastCorrections {
132        RinexSppBroadcastCorrections::default()
133    }
134}
135
136/// Delegating ephemeris source that lets a precise product solve with broadcast
137/// NAV metadata during RINEX SPP assembly.
138///
139/// Use [`Self::with_broadcast_context`] for the common precise-SP3-plus-RINEX-NAV
140/// path: satellite position and clocks come from `ephemeris`, while
141/// ionosphere coefficients and GLONASS FDMA channels come from `broadcast`.
142pub struct RinexSppSource<'a, E: EphemerisSource + ?Sized> {
143    ephemeris: &'a E,
144    broadcast: Option<&'a BroadcastEphemeris>,
145}
146
147impl<'a, E: EphemerisSource + ?Sized> RinexSppSource<'a, E> {
148    /// Build a delegating source with no broadcast assembly context.
149    #[must_use]
150    pub const fn new(ephemeris: &'a E) -> Self {
151        Self {
152            ephemeris,
153            broadcast: None,
154        }
155    }
156
157    /// Build a delegating source whose ephemeris is used for the solve and whose
158    /// broadcast product is used for RINEX assembly metadata.
159    #[must_use]
160    pub const fn with_broadcast_context(
161        ephemeris: &'a E,
162        broadcast: &'a BroadcastEphemeris,
163    ) -> Self {
164        Self {
165            ephemeris,
166            broadcast: Some(broadcast),
167        }
168    }
169
170    /// The ephemeris source delegated to during the SPP solve.
171    #[must_use]
172    pub const fn ephemeris(&self) -> &'a E {
173        self.ephemeris
174    }
175
176    /// Broadcast metadata source, when one was supplied.
177    #[must_use]
178    pub const fn broadcast_context(&self) -> Option<&'a BroadcastEphemeris> {
179        self.broadcast
180    }
181}
182
183impl<E: EphemerisSource + ?Sized> EphemerisSource for RinexSppSource<'_, E> {
184    fn position_clock_at_j2000_s(
185        &self,
186        sat: GnssSatelliteId,
187        t_j2000_s: f64,
188    ) -> Option<([f64; 3], f64)> {
189        self.ephemeris.position_clock_at_j2000_s(sat, t_j2000_s)
190    }
191}
192
193impl<E: EphemerisSource + ?Sized> RinexSppAssemblySource for RinexSppSource<'_, E> {
194    fn rinex_spp_broadcast_corrections(&self) -> RinexSppBroadcastCorrections {
195        self.broadcast
196            .map(RinexSppAssemblySource::rinex_spp_broadcast_corrections)
197            .unwrap_or_default()
198    }
199}
200
201/// Options for assembling RINEX observation epochs into SPP [`SolveInputs`].
202#[derive(Debug, Clone, PartialEq)]
203pub struct RinexSppOptions {
204    /// Per-constellation pseudorange-code selection policy.
205    pub signal_policy: SignalPolicy,
206    /// Correction terms to request in each assembled solve.
207    pub corrections: Corrections,
208    /// Optional initial guess `[x_m, y_m, z_m, b_m]`. When absent, the RINEX
209    /// header's `APPROX POSITION XYZ` provides the position and the clock seed
210    /// is zero.
211    pub initial_guess: Option<[f64; 4]>,
212    /// Optional satellite allow-list. `None` keeps every satellite with a
213    /// selected pseudorange.
214    pub satellites: Option<BTreeSet<GnssSatelliteId>>,
215    /// Surface meteorology for troposphere correction.
216    pub met: SurfaceMet,
217    /// Optional robust reweighting for every assembled epoch.
218    pub robust: Option<RobustConfig>,
219}
220
221impl RinexSppOptions {
222    /// Build options from an explicit signal policy.
223    #[must_use]
224    pub fn new(signal_policy: SignalPolicy) -> Self {
225        Self {
226            signal_policy,
227            corrections: Corrections::IONO_TROPO,
228            initial_guess: None,
229            satellites: None,
230            met: SurfaceMet::default(),
231            robust: None,
232        }
233    }
234
235    /// Build options using the default single-frequency signal policy for the
236    /// observation file's RINEX version.
237    pub fn default_for(obs: &ObservationFile) -> Result<Self, RinexSppError> {
238        Ok(Self::new(SignalPolicy::default_for(obs.header().version)?))
239    }
240
241    /// Replace the correction request.
242    #[must_use]
243    pub const fn with_corrections(mut self, corrections: Corrections) -> Self {
244        self.corrections = corrections;
245        self
246    }
247
248    /// Replace the initial solve guess.
249    #[must_use]
250    pub const fn with_initial_guess(mut self, initial_guess: [f64; 4]) -> Self {
251        self.initial_guess = Some(initial_guess);
252        self
253    }
254
255    /// Restrict assembly to the supplied satellites.
256    #[must_use]
257    pub fn with_satellites<I>(mut self, satellites: I) -> Self
258    where
259        I: IntoIterator<Item = GnssSatelliteId>,
260    {
261        self.satellites = Some(satellites.into_iter().collect());
262        self
263    }
264
265    /// Replace surface meteorology.
266    #[must_use]
267    pub const fn with_surface_met(mut self, met: SurfaceMet) -> Self {
268        self.met = met;
269        self
270    }
271
272    /// Replace robust-reweighting config.
273    #[must_use]
274    pub const fn with_robust(mut self, robust: Option<RobustConfig>) -> Self {
275        self.robust = robust;
276        self
277    }
278}
279
280/// One assembled RINEX observation epoch and its SPP inputs.
281#[derive(Debug, Clone)]
282pub struct RinexSppEpochInputs {
283    /// Index in [`ObservationFile::epochs`].
284    pub epoch_index: usize,
285    /// Civil epoch exactly as it appears in the RINEX observation file.
286    pub epoch: ObsEpochTime,
287    /// Fully assembled SPP inputs for this epoch.
288    pub inputs: SolveInputs,
289}
290
291/// One RINEX observation epoch paired with its serial SPP solve result.
292#[derive(Debug, Clone)]
293pub struct RinexSppEpochSolution {
294    /// Index in [`ObservationFile::epochs`].
295    pub epoch_index: usize,
296    /// Civil epoch exactly as it appears in the RINEX observation file.
297    pub epoch: ObsEpochTime,
298    /// Result from solving the assembled epoch.
299    pub solution: Result<ReceiverSolution, SolvePolicyError>,
300}
301
302/// Assemble every non-event RINEX observation epoch with at least one selected
303/// pseudorange into SPP [`SolveInputs`].
304///
305/// The function preserves observation-file epoch order, skips RINEX event
306/// epochs (`flag > 1`), selects one single-frequency pseudorange per satellite
307/// under [`RinexSppOptions::signal_policy`], derives receive time from the RINEX
308/// civil epoch, seeds the receiver from `APPROX POSITION XYZ` unless
309/// `initial_guess` is supplied, and combines GLONASS channels from the assembly
310/// source with any observation-header `GLONASS SLOT / FRQ #` entries. Observation
311/// header channels take precedence.
312pub fn spp_inputs_from_rinex_obs<S>(
313    obs: &ObservationFile,
314    source: &S,
315    options: &RinexSppOptions,
316) -> Result<Vec<RinexSppEpochInputs>, RinexSppError>
317where
318    S: RinexSppAssemblySource + ?Sized,
319{
320    let initial_guess = initial_guess(obs, options)?;
321    let base_corrections = merged_broadcast_corrections(obs, source);
322    let mut out = Vec::new();
323
324    for (epoch_index, epoch) in obs.epochs().iter().enumerate() {
325        if epoch.flag > 1 {
326            continue;
327        }
328        let mut selected = pseudoranges(obs, epoch, &options.signal_policy)?;
329        if let Some(allowed) = &options.satellites {
330            selected.retain(|(sat, _)| allowed.contains(sat));
331        }
332        if selected.is_empty() {
333            continue;
334        }
335
336        let epoch_context = epoch_time_context(epoch.epoch);
337        let observations = selected
338            .into_iter()
339            .map(|(satellite_id, pseudorange_m)| Observation {
340                satellite_id,
341                pseudorange_m,
342            })
343            .collect();
344
345        out.push(RinexSppEpochInputs {
346            epoch_index,
347            epoch: epoch.epoch,
348            inputs: SolveInputs {
349                observations,
350                t_rx_j2000_s: epoch_context.t_rx_j2000_s,
351                t_rx_second_of_day_s: epoch_context.t_rx_second_of_day_s,
352                day_of_year: epoch_context.day_of_year,
353                initial_guess,
354                corrections: options.corrections,
355                klobuchar: base_corrections.klobuchar,
356                beidou_klobuchar: base_corrections.beidou_klobuchar,
357                galileo_nequick: base_corrections.galileo_nequick,
358                sbas_iono: None,
359                glonass_channels: base_corrections.glonass_channels.clone(),
360                met: options.met,
361                robust: options.robust,
362            },
363        });
364    }
365
366    Ok(out)
367}
368
369/// Assemble RINEX SPP epochs and solve them serially against the same source.
370///
371/// The returned vector has one entry per assembled epoch, not one entry per raw
372/// RINEX epoch. Per-epoch solve failures are retained in
373/// [`RinexSppEpochSolution::solution`], matching [`solve_spp_batch_serial`].
374pub fn solve_spp_from_rinex_obs<S>(
375    source: &S,
376    obs: &ObservationFile,
377    options: &RinexSppOptions,
378    with_geodetic: bool,
379    policy: SolvePolicy,
380) -> Result<Vec<RinexSppEpochSolution>, RinexSppError>
381where
382    S: EphemerisSource + RinexSppAssemblySource,
383{
384    let epochs = spp_inputs_from_rinex_obs(obs, source, options)?;
385    let inputs = epochs
386        .iter()
387        .map(|epoch| epoch.inputs.clone())
388        .collect::<Vec<_>>();
389    let results = solve_spp_batch_serial(source, &inputs, with_geodetic, policy);
390    Ok(epochs
391        .into_iter()
392        .zip(results)
393        .map(|(epoch, solution)| RinexSppEpochSolution {
394            epoch_index: epoch.epoch_index,
395            epoch: epoch.epoch,
396            solution,
397        })
398        .collect())
399}
400
401fn klobuchar_from_alpha_beta(value: crate::ephemeris::KlobucharAlphaBeta) -> KlobucharCoeffs {
402    KlobucharCoeffs {
403        alpha: value.alpha,
404        beta: value.beta,
405    }
406}
407
408const fn zero_klobuchar() -> KlobucharCoeffs {
409    KlobucharCoeffs {
410        alpha: [0.0; 4],
411        beta: [0.0; 4],
412    }
413}
414
415fn initial_guess(
416    obs: &ObservationFile,
417    options: &RinexSppOptions,
418) -> Result<[f64; 4], RinexSppError> {
419    if let Some(initial_guess) = options.initial_guess {
420        return Ok(initial_guess);
421    }
422    let approx = obs
423        .header()
424        .approx_position_m
425        .ok_or(RinexSppError::MissingApproxPosition)?;
426    Ok([approx[0], approx[1], approx[2], 0.0])
427}
428
429fn merged_broadcast_corrections<S>(
430    obs: &ObservationFile,
431    source: &S,
432) -> RinexSppBroadcastCorrections
433where
434    S: RinexSppAssemblySource + ?Sized,
435{
436    let mut corrections = source.rinex_spp_broadcast_corrections();
437    corrections.glonass_channels.extend(
438        obs.header()
439            .glonass_slots
440            .iter()
441            .map(|(&slot, &channel)| (slot, channel)),
442    );
443    corrections
444}
445
446struct EpochTimeContext {
447    t_rx_j2000_s: f64,
448    t_rx_second_of_day_s: f64,
449    day_of_year: f64,
450}
451
452fn epoch_time_context(epoch: ObsEpochTime) -> EpochTimeContext {
453    let year = epoch.year;
454    let month = i32::from(epoch.month);
455    let day = i32::from(epoch.day);
456    let hour = i32::from(epoch.hour);
457    let minute = i32::from(epoch.minute);
458    EpochTimeContext {
459        t_rx_j2000_s: time::j2000_seconds(year, month, day, hour, minute, epoch.second),
460        t_rx_second_of_day_s: time::second_of_day(hour, minute, epoch.second),
461        day_of_year: time::day_of_year(year, month, day, hour, minute, epoch.second),
462    }
463}