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
6use crate::constants::C_M_S;
7pub use crate::dop::{dop, Dop, DopError, LineOfSight};
8use crate::ephemeris::{BroadcastEphemeris, Sp3};
9use crate::id::GnssSystem;
10pub use crate::quality::{
11    spp_robust_fde_driver, FdeError, FdeOptions, FdeResult, FdeSppError, FdeSppOptions,
12};
13use crate::rinex::observations::{pseudoranges, ObsEpochTime, ObservationFile, SignalPolicy};
14use crate::rtcm::{self, MsmKind};
15pub use crate::spp::{
16    residual_rms, solve, solve_broadcast, solve_doppler_velocity, solve_spp_batch_parallel,
17    solve_spp_batch_serial, solve_with_doppler_velocity, solve_with_fallback, solve_with_policy,
18    solve_with_solver, BroadcastReason, Corrections, DopplerObservation, DopplerVelocityInputs,
19    EphemerisSource, FallbackError, FixSource, GalileoNequickCoeffs, KlobucharCoeffs, Observation,
20    ReceiverSolution, RejectedSat, RejectionReason, RobustConfig, SolutionMetadata, SolveInputs,
21    SolvePolicy, SolvePolicyError, SourcedSolution, SppDopplerSolution, SppError, SurfaceMet,
22    DEFAULT_HUBER_K, DEFAULT_ROBUST_MAX_OUTER, DEFAULT_ROBUST_OUTER_TOL_M,
23    DEFAULT_ROBUST_SCALE_FLOOR_M, ELEVATION_MASK_RAD, SIGMA0_M, TRANSMIT_TIME_ITERATIONS,
24};
25pub use crate::static_positioning::{
26    solve_static, StaticClockBias, StaticCovariance, StaticEpoch, StaticEpochInfluence,
27    StaticInfluenceStatus, StaticResidual, StaticSatelliteBatchInfluence, StaticSatelliteInfluence,
28    StaticSolution, StaticSolutionMetadata, StaticSolveError, StaticSolveOptions,
29};
30pub use crate::static_reference_station::{
31    solve_static_reference_station_rinex, StaticReferenceCarrierRinexOptions,
32    StaticReferenceCarrierSolution, StaticReferenceCodeSolution, StaticReferenceEpochDiagnostic,
33    StaticReferenceFixStatus, StaticReferenceModeError, StaticReferenceModeReport,
34    StaticReferenceModeStatus, StaticReferenceStationCovariance, StaticReferenceStationError,
35    StaticReferenceStationMode, StaticReferenceStationRinexOptions, StaticReferenceStationSolution,
36};
37use crate::{astro::time, Error as CoreError, GnssSatelliteId};
38
39/// Role-oriented alias for a solved receiver state.
40pub type Solution = ReceiverSolution;
41
42/// Error type returned by [`solve`].
43pub type Error = SppError;
44
45/// Assembly-time error from building SPP inputs out of a parsed RINEX
46/// observation file.
47#[derive(Debug, Clone, PartialEq, Eq)]
48#[non_exhaustive]
49pub enum RinexSppError {
50    /// A RINEX observation helper rejected malformed or non-finite input.
51    Observation(CoreError),
52    /// No initial receiver position was supplied and the observation header did
53    /// not carry `APPROX POSITION XYZ`.
54    MissingApproxPosition,
55}
56
57impl fmt::Display for RinexSppError {
58    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
59        match self {
60            Self::Observation(error) => write!(f, "RINEX SPP observation assembly failed: {error}"),
61            Self::MissingApproxPosition => {
62                f.write_str("RINEX SPP assembly needs APPROX POSITION XYZ or an initial guess")
63            }
64        }
65    }
66}
67
68impl std::error::Error for RinexSppError {}
69
70impl From<CoreError> for RinexSppError {
71    fn from(error: CoreError) -> Self {
72        Self::Observation(error)
73    }
74}
75
76/// Broadcast correction metadata used while converting RINEX observations into
77/// SPP [`SolveInputs`].
78///
79/// A broadcast navigation product supplies ionosphere coefficients and GLONASS
80/// FDMA channels. A precise SP3 product does not, so its default assembly
81/// context is zero Klobuchar coefficients and no GLONASS channels. When solving
82/// precise SP3 positions with broadcast atmosphere metadata, wrap the SP3 with
83/// [`RinexSppSource::with_broadcast_context`].
84#[derive(Debug, Clone, PartialEq)]
85pub struct RinexSppBroadcastCorrections {
86    /// GPS Klobuchar coefficients, also used as the fallback for systems
87    /// without a dedicated correction set.
88    pub klobuchar: KlobucharCoeffs,
89    /// BeiDou-specific Klobuchar coefficients, when a NAV product provides
90    /// `BDSA`/`BDSB`.
91    pub beidou_klobuchar: Option<KlobucharCoeffs>,
92    /// Galileo NeQuick-G coefficients, when a NAV product provides `GAL`.
93    pub galileo_nequick: Option<GalileoNequickCoeffs>,
94    /// GLONASS FDMA channel numbers keyed by GLONASS slot.
95    pub glonass_channels: BTreeMap<u8, i8>,
96}
97
98impl Default for RinexSppBroadcastCorrections {
99    fn default() -> Self {
100        Self {
101            klobuchar: zero_klobuchar(),
102            beidou_klobuchar: None,
103            galileo_nequick: None,
104            glonass_channels: BTreeMap::new(),
105        }
106    }
107}
108
109/// Source of non-observation metadata needed during RINEX SPP assembly.
110///
111/// [`BroadcastEphemeris`] implements this trait with its parsed NAV ionosphere
112/// coefficients and GLONASS FDMA channels. [`Sp3`] implements it with empty
113/// broadcast metadata so precise-only callers can still assemble
114/// troposphere-only or no-correction inputs.
115pub trait RinexSppAssemblySource {
116    /// Broadcast correction metadata available to RINEX SPP assembly.
117    fn rinex_spp_broadcast_corrections(&self) -> RinexSppBroadcastCorrections;
118}
119
120impl RinexSppAssemblySource for BroadcastEphemeris {
121    fn rinex_spp_broadcast_corrections(&self) -> RinexSppBroadcastCorrections {
122        let iono = self.iono_corrections();
123        let gps = iono.gps.map(klobuchar_from_alpha_beta);
124        RinexSppBroadcastCorrections {
125            klobuchar: gps.unwrap_or_else(zero_klobuchar),
126            beidou_klobuchar: iono.beidou.map(klobuchar_from_alpha_beta),
127            galileo_nequick: iono.galileo,
128            glonass_channels: self.glonass_frequency_channels(),
129        }
130    }
131}
132
133impl RinexSppAssemblySource for Sp3 {
134    fn rinex_spp_broadcast_corrections(&self) -> RinexSppBroadcastCorrections {
135        RinexSppBroadcastCorrections::default()
136    }
137}
138
139/// Delegating ephemeris source that lets a precise product solve with broadcast
140/// NAV metadata during RINEX SPP assembly.
141///
142/// Use [`Self::with_broadcast_context`] for the common precise-SP3-plus-RINEX-NAV
143/// path: satellite position and clocks come from `ephemeris`, while
144/// ionosphere coefficients and GLONASS FDMA channels come from `broadcast`.
145pub struct RinexSppSource<'a, E: EphemerisSource + ?Sized> {
146    ephemeris: &'a E,
147    broadcast: Option<&'a BroadcastEphemeris>,
148}
149
150impl<'a, E: EphemerisSource + ?Sized> RinexSppSource<'a, E> {
151    /// Build a delegating source with no broadcast assembly context.
152    #[must_use]
153    pub const fn new(ephemeris: &'a E) -> Self {
154        Self {
155            ephemeris,
156            broadcast: None,
157        }
158    }
159
160    /// Build a delegating source whose ephemeris is used for the solve and whose
161    /// broadcast product is used for RINEX assembly metadata.
162    #[must_use]
163    pub const fn with_broadcast_context(
164        ephemeris: &'a E,
165        broadcast: &'a BroadcastEphemeris,
166    ) -> Self {
167        Self {
168            ephemeris,
169            broadcast: Some(broadcast),
170        }
171    }
172
173    /// The ephemeris source delegated to during the SPP solve.
174    #[must_use]
175    pub const fn ephemeris(&self) -> &'a E {
176        self.ephemeris
177    }
178
179    /// Broadcast metadata source, when one was supplied.
180    #[must_use]
181    pub const fn broadcast_context(&self) -> Option<&'a BroadcastEphemeris> {
182        self.broadcast
183    }
184}
185
186impl<E: EphemerisSource + ?Sized> EphemerisSource for RinexSppSource<'_, E> {
187    fn position_clock_at_j2000_s(
188        &self,
189        sat: GnssSatelliteId,
190        t_j2000_s: f64,
191    ) -> Option<([f64; 3], f64)> {
192        self.ephemeris.position_clock_at_j2000_s(sat, t_j2000_s)
193    }
194}
195
196impl<E: EphemerisSource + ?Sized> RinexSppAssemblySource for RinexSppSource<'_, E> {
197    fn rinex_spp_broadcast_corrections(&self) -> RinexSppBroadcastCorrections {
198        self.broadcast
199            .map(RinexSppAssemblySource::rinex_spp_broadcast_corrections)
200            .unwrap_or_default()
201    }
202}
203
204/// Options for assembling RINEX observation epochs into SPP [`SolveInputs`].
205#[derive(Debug, Clone, PartialEq)]
206pub struct RinexSppOptions {
207    /// Per-constellation pseudorange-code selection policy.
208    pub signal_policy: SignalPolicy,
209    /// Correction terms to request in each assembled solve.
210    pub corrections: Corrections,
211    /// Optional initial guess `[x_m, y_m, z_m, b_m]`. When absent, the RINEX
212    /// header's `APPROX POSITION XYZ` provides the position and the clock seed
213    /// is zero.
214    pub initial_guess: Option<[f64; 4]>,
215    /// Optional satellite allow-list. `None` keeps every satellite with a
216    /// selected pseudorange.
217    pub satellites: Option<BTreeSet<GnssSatelliteId>>,
218    /// Surface meteorology for troposphere correction.
219    pub met: SurfaceMet,
220    /// Optional robust reweighting for every assembled epoch.
221    pub robust: Option<RobustConfig>,
222}
223
224impl RinexSppOptions {
225    /// Build options from an explicit signal policy.
226    #[must_use]
227    pub fn new(signal_policy: SignalPolicy) -> Self {
228        Self {
229            signal_policy,
230            corrections: Corrections::IONO_TROPO,
231            initial_guess: None,
232            satellites: None,
233            met: SurfaceMet::default(),
234            robust: None,
235        }
236    }
237
238    /// Build options using the default single-frequency signal policy for the
239    /// observation file's RINEX version.
240    pub fn default_for(obs: &ObservationFile) -> Result<Self, RinexSppError> {
241        Ok(Self::new(SignalPolicy::default_for(obs.header().version)?))
242    }
243
244    /// Replace the correction request.
245    #[must_use]
246    pub const fn with_corrections(mut self, corrections: Corrections) -> Self {
247        self.corrections = corrections;
248        self
249    }
250
251    /// Replace the initial solve guess.
252    #[must_use]
253    pub const fn with_initial_guess(mut self, initial_guess: [f64; 4]) -> Self {
254        self.initial_guess = Some(initial_guess);
255        self
256    }
257
258    /// Restrict assembly to the supplied satellites.
259    #[must_use]
260    pub fn with_satellites<I>(mut self, satellites: I) -> Self
261    where
262        I: IntoIterator<Item = GnssSatelliteId>,
263    {
264        self.satellites = Some(satellites.into_iter().collect());
265        self
266    }
267
268    /// Replace surface meteorology.
269    #[must_use]
270    pub const fn with_surface_met(mut self, met: SurfaceMet) -> Self {
271        self.met = met;
272        self
273    }
274
275    /// Replace robust-reweighting config.
276    #[must_use]
277    pub const fn with_robust(mut self, robust: Option<RobustConfig>) -> Self {
278        self.robust = robust;
279        self
280    }
281}
282
283/// One assembled RINEX observation epoch and its SPP inputs.
284#[derive(Debug, Clone)]
285pub struct RinexSppEpochInputs {
286    /// Index in [`ObservationFile::epochs`].
287    pub epoch_index: usize,
288    /// Civil epoch exactly as it appears in the RINEX observation file.
289    pub epoch: ObsEpochTime,
290    /// Fully assembled SPP inputs for this epoch.
291    pub inputs: SolveInputs,
292}
293
294/// One RINEX observation epoch paired with its serial SPP solve result.
295#[derive(Debug, Clone)]
296pub struct RinexSppEpochSolution {
297    /// Index in [`ObservationFile::epochs`].
298    pub epoch_index: usize,
299    /// Civil epoch exactly as it appears in the RINEX observation file.
300    pub epoch: ObsEpochTime,
301    /// Result from solving the assembled epoch.
302    pub solution: Result<ReceiverSolution, SolvePolicyError>,
303}
304
305/// One set of assembled RTCM MSM observations and its SPP inputs.
306#[derive(Debug, Clone)]
307pub struct RtcmSppEpochInputs {
308    /// Index in the ordered set of assembled RTCM observation epochs.
309    pub epoch_index: usize,
310    /// Civil epoch reconstructed from the stream conversion logic.
311    pub epoch: ObsEpochTime,
312    /// Fully assembled SPP inputs for this epoch.
313    pub inputs: SolveInputs,
314}
315
316/// Convert RTCM MSM observation messages into SPP-ready epoch inputs.
317///
318/// Messages are grouped by `(system, epoch_time)` in stream order, so each
319/// RTCM epoch produces one `RtcmSppEpochInputs` entry.
320pub fn spp_inputs_from_rtcm_msm<S, F>(
321    messages: &[rtcm::MsmMessage],
322    source: &S,
323    options: &RinexSppOptions,
324    mut map_epoch: F,
325) -> Result<Vec<RtcmSppEpochInputs>, RinexSppError>
326where
327    S: RinexSppAssemblySource + ?Sized,
328    F: FnMut(GnssSystem, u32) -> Option<(f64, ObsEpochTime)>,
329{
330    if messages.is_empty() {
331        return Ok(Vec::new());
332    }
333
334    let initial_guess = options.initial_guess.unwrap_or([0.0; 4]);
335    let base_corrections = merged_broadcast_corrections_from_source(source);
336    let mut groups = Vec::<(GnssSystem, u32, Vec<usize>)>::new();
337    let mut group_index = BTreeMap::<(GnssSystem, u32), usize>::new();
338
339    for (index, message) in messages.iter().enumerate() {
340        let key = (message.system, message.header.epoch_time);
341        let slot = if let Some(index) = group_index.get(&key) {
342            *index
343        } else {
344            let slot = groups.len();
345            groups.push((key.0, key.1, Vec::new()));
346            group_index.insert(key, slot);
347            slot
348        };
349        groups[slot].2.push(index);
350    }
351
352    let mut out = Vec::new();
353    for (epoch_index, (system, epoch_time, group_indexes)) in groups.into_iter().enumerate() {
354        let Some((t_rx_j2000_s, epoch)) = map_epoch(system, epoch_time) else {
355            continue;
356        };
357        let Some(preferred_codes) = options.signal_policy.codes.get(&system) else {
358            continue;
359        };
360        let preferred_codes = preferred_codes
361            .iter()
362            .map(String::as_str)
363            .collect::<Vec<_>>();
364
365        let Some(message_kind) = group_indexes
366            .first()
367            .and_then(|index| messages.get(*index))
368            .map(|message| message.kind)
369        else {
370            continue;
371        };
372
373        let mut by_satellite = BTreeMap::<u8, Vec<&rtcm::MsmSignal>>::new();
374        let mut satellite_cache = BTreeMap::<u8, rtcm::MsmSatellite>::new();
375        for message in group_indexes
376            .iter()
377            .copied()
378            .filter_map(|index| messages.get(index))
379        {
380            for satellite in &message.satellites {
381                satellite_cache.insert(satellite.id, *satellite);
382            }
383            for signal in &message.signals {
384                by_satellite
385                    .entry(signal.satellite_id)
386                    .or_default()
387                    .push(signal);
388            }
389        }
390
391        let mut observations = Vec::new();
392        for (satellite_id, signals) in by_satellite {
393            let Some(satellite) = satellite_cache.get(&satellite_id) else {
394                continue;
395            };
396            let Some(pseudorange_m) = rtcm_msm_pseudorange_m(
397                system,
398                message_kind,
399                *satellite,
400                &signals,
401                &preferred_codes,
402            ) else {
403                continue;
404            };
405            if let Ok(satellite_id) = GnssSatelliteId::new(system, satellite_id) {
406                observations.push(Observation {
407                    satellite_id,
408                    pseudorange_m,
409                });
410            }
411        }
412
413        if observations.is_empty() {
414            continue;
415        }
416
417        let t_rx_second_of_day_s =
418            time::second_of_day(epoch.hour.into(), epoch.minute.into(), epoch.second);
419        let day_of_year = time::day_of_year(
420            epoch.year,
421            i32::from(epoch.month),
422            i32::from(epoch.day),
423            epoch.hour.into(),
424            epoch.minute.into(),
425            epoch.second,
426        );
427
428        out.push(RtcmSppEpochInputs {
429            epoch_index,
430            epoch,
431            inputs: SolveInputs {
432                observations,
433                t_rx_j2000_s,
434                t_rx_second_of_day_s,
435                day_of_year,
436                initial_guess,
437                corrections: options.corrections,
438                klobuchar: base_corrections.klobuchar,
439                beidou_klobuchar: base_corrections.beidou_klobuchar,
440                galileo_nequick: base_corrections.galileo_nequick,
441                sbas_iono: None,
442                glonass_channels: base_corrections.glonass_channels.clone(),
443                met: options.met,
444                robust: options.robust,
445            },
446        });
447    }
448
449    Ok(out)
450}
451
452fn rtcm_msm_pseudorange_m(
453    system: GnssSystem,
454    kind: MsmKind,
455    satellite: rtcm::MsmSatellite,
456    signals: &[&rtcm::MsmSignal],
457    preferred_codes: &[&str],
458) -> Option<f64> {
459    if satellite.rough_range_ms == 255 {
460        return None;
461    }
462    let rough_ms =
463        f64::from(satellite.rough_range_ms) + f64::from(satellite.rough_range_mod1) / 1024.0;
464
465    if let Some(signal) = select_rtcm_signal(system, signals, preferred_codes) {
466        let fine_ms = match kind {
467            MsmKind::Msm4 => {
468                if signal.fine_pseudorange == -16_384 {
469                    f64::NAN
470                } else {
471                    f64::from(signal.fine_pseudorange) / 2_f64.powi(24)
472                }
473            }
474            MsmKind::Msm7 => f64::from(signal.fine_pseudorange) / 2_f64.powi(29),
475        };
476        if fine_ms.is_finite() {
477            return Some((rough_ms + fine_ms) * 1.0e-3 * C_M_S);
478        }
479    }
480
481    None
482}
483
484fn select_rtcm_signal<'a>(
485    system: GnssSystem,
486    signals: &'a [&'a rtcm::MsmSignal],
487    preferred_codes: &[&'a str],
488) -> Option<&'a rtcm::MsmSignal> {
489    if signals.is_empty() {
490        return None;
491    }
492
493    if preferred_codes.is_empty() {
494        return signals.iter().copied().next();
495    }
496
497    preferred_codes
498        .iter()
499        .find_map(|requested| {
500            let normalized = requested
501                .strip_prefix('C')
502                .or_else(|| requested.strip_prefix('L'))
503                .unwrap_or(requested);
504            signals.iter().copied().find(|signal| {
505                let Some(code) = rtcm::msm_signal_rinex_code(system, signal.signal_id) else {
506                    return false;
507                };
508                code == *requested || code == normalized
509            })
510        })
511        .or_else(|| signals.iter().copied().next())
512}
513
514/// Assemble every non-event RINEX observation epoch with at least one selected
515/// pseudorange into SPP [`SolveInputs`].
516///
517/// The function preserves observation-file epoch order, skips RINEX event
518/// epochs (`flag > 1`), selects one single-frequency pseudorange per satellite
519/// under [`RinexSppOptions::signal_policy`], derives receive time from the RINEX
520/// civil epoch, seeds the receiver from `APPROX POSITION XYZ` unless
521/// `initial_guess` is supplied, and combines GLONASS channels from the assembly
522/// source with any observation-header `GLONASS SLOT / FRQ #` entries. Observation
523/// header channels take precedence.
524pub fn spp_inputs_from_rinex_obs<S>(
525    obs: &ObservationFile,
526    source: &S,
527    options: &RinexSppOptions,
528) -> Result<Vec<RinexSppEpochInputs>, RinexSppError>
529where
530    S: RinexSppAssemblySource + ?Sized,
531{
532    let initial_guess = initial_guess(obs, options)?;
533    let base_corrections = merged_broadcast_corrections(obs, source);
534    let mut out = Vec::new();
535
536    for (epoch_index, epoch) in obs.epochs().iter().enumerate() {
537        if epoch.flag > 1 {
538            continue;
539        }
540        let mut selected = pseudoranges(obs, epoch, &options.signal_policy)?;
541        if let Some(allowed) = &options.satellites {
542            selected.retain(|(sat, _)| allowed.contains(sat));
543        }
544        if selected.is_empty() {
545            continue;
546        }
547
548        let epoch_context = epoch_time_context(epoch.epoch);
549        let observations = selected
550            .into_iter()
551            .map(|(satellite_id, pseudorange_m)| Observation {
552                satellite_id,
553                pseudorange_m,
554            })
555            .collect();
556
557        out.push(RinexSppEpochInputs {
558            epoch_index,
559            epoch: epoch.epoch,
560            inputs: SolveInputs {
561                observations,
562                t_rx_j2000_s: epoch_context.t_rx_j2000_s,
563                t_rx_second_of_day_s: epoch_context.t_rx_second_of_day_s,
564                day_of_year: epoch_context.day_of_year,
565                initial_guess,
566                corrections: options.corrections,
567                klobuchar: base_corrections.klobuchar,
568                beidou_klobuchar: base_corrections.beidou_klobuchar,
569                galileo_nequick: base_corrections.galileo_nequick,
570                sbas_iono: None,
571                glonass_channels: base_corrections.glonass_channels.clone(),
572                met: options.met,
573                robust: options.robust,
574            },
575        });
576    }
577
578    Ok(out)
579}
580
581/// Assemble RINEX SPP epochs and solve them serially against the same source.
582///
583/// The returned vector has one entry per assembled epoch, not one entry per raw
584/// RINEX epoch. Per-epoch solve failures are retained in
585/// [`RinexSppEpochSolution::solution`], matching [`solve_spp_batch_serial`].
586pub fn solve_spp_from_rinex_obs<S>(
587    source: &S,
588    obs: &ObservationFile,
589    options: &RinexSppOptions,
590    with_geodetic: bool,
591    policy: SolvePolicy,
592) -> Result<Vec<RinexSppEpochSolution>, RinexSppError>
593where
594    S: EphemerisSource + RinexSppAssemblySource,
595{
596    let epochs = spp_inputs_from_rinex_obs(obs, source, options)?;
597    let inputs = epochs
598        .iter()
599        .map(|epoch| epoch.inputs.clone())
600        .collect::<Vec<_>>();
601    let results = solve_spp_batch_serial(source, &inputs, with_geodetic, policy);
602    Ok(epochs
603        .into_iter()
604        .zip(results)
605        .map(|(epoch, solution)| RinexSppEpochSolution {
606            epoch_index: epoch.epoch_index,
607            epoch: epoch.epoch,
608            solution,
609        })
610        .collect())
611}
612
613fn klobuchar_from_alpha_beta(value: crate::ephemeris::KlobucharAlphaBeta) -> KlobucharCoeffs {
614    KlobucharCoeffs {
615        alpha: value.alpha,
616        beta: value.beta,
617    }
618}
619
620const fn zero_klobuchar() -> KlobucharCoeffs {
621    KlobucharCoeffs {
622        alpha: [0.0; 4],
623        beta: [0.0; 4],
624    }
625}
626
627fn initial_guess(
628    obs: &ObservationFile,
629    options: &RinexSppOptions,
630) -> Result<[f64; 4], RinexSppError> {
631    if let Some(initial_guess) = options.initial_guess {
632        return Ok(initial_guess);
633    }
634    let approx = obs
635        .header()
636        .approx_position_m
637        .ok_or(RinexSppError::MissingApproxPosition)?;
638    Ok([approx[0], approx[1], approx[2], 0.0])
639}
640
641fn merged_broadcast_corrections<S>(
642    obs: &ObservationFile,
643    source: &S,
644) -> RinexSppBroadcastCorrections
645where
646    S: RinexSppAssemblySource + ?Sized,
647{
648    let mut corrections = source.rinex_spp_broadcast_corrections();
649    corrections.glonass_channels.extend(
650        obs.header()
651            .glonass_slots
652            .iter()
653            .map(|(&slot, &channel)| (slot, channel)),
654    );
655    corrections
656}
657
658fn merged_broadcast_corrections_from_source<S>(source: &S) -> RinexSppBroadcastCorrections
659where
660    S: RinexSppAssemblySource + ?Sized,
661{
662    source.rinex_spp_broadcast_corrections()
663}
664
665struct EpochTimeContext {
666    t_rx_j2000_s: f64,
667    t_rx_second_of_day_s: f64,
668    day_of_year: f64,
669}
670
671fn epoch_time_context(epoch: ObsEpochTime) -> EpochTimeContext {
672    let year = epoch.year;
673    let month = i32::from(epoch.month);
674    let day = i32::from(epoch.day);
675    let hour = i32::from(epoch.hour);
676    let minute = i32::from(epoch.minute);
677    EpochTimeContext {
678        t_rx_j2000_s: time::j2000_seconds(year, month, day, hour, minute, epoch.second),
679        t_rx_second_of_day_s: time::second_of_day(hour, minute, epoch.second),
680        day_of_year: time::day_of_year(year, month, day, hour, minute, epoch.second),
681    }
682}
683
684#[cfg(test)]
685mod tests {
686    use super::*;
687    use crate::rinex_obs::SignalPolicy;
688    use crate::rtcm::{MsmHeader, MsmKind, MsmMessage, MsmSatellite, MsmSignal};
689
690    #[derive(Default)]
691    struct NoCorrections;
692
693    impl RinexSppAssemblySource for NoCorrections {
694        fn rinex_spp_broadcast_corrections(&self) -> RinexSppBroadcastCorrections {
695            RinexSppBroadcastCorrections::default()
696        }
697    }
698
699    fn synthetic_rtcm_messages() -> Vec<MsmMessage> {
700        vec![MsmMessage {
701            message_number: 1074,
702            system: GnssSystem::Gps,
703            kind: MsmKind::Msm4,
704            header: MsmHeader {
705                reference_station_id: 0,
706                epoch_time: 12_345,
707                multiple_message: false,
708                iods: 1,
709                reserved: 0,
710                clock_steering: 0,
711                external_clock: 0,
712                divergence_free_smoothing: false,
713                smoothing_interval: 0,
714            },
715            satellites: vec![MsmSatellite {
716                id: 1,
717                rough_range_ms: 100,
718                rough_range_mod1: 512,
719                extended_info: None,
720                rough_phase_range_rate_m_s: None,
721            }],
722            signals: vec![
723                MsmSignal {
724                    satellite_id: 1,
725                    signal_id: 1,
726                    fine_pseudorange: 1 << 24,
727                    lock_time_indicator: 0,
728                    half_cycle_ambiguity: false,
729                    cnr: 0,
730                    fine_phase_range: 0,
731                    fine_phase_range_rate: None,
732                },
733                MsmSignal {
734                    satellite_id: 1,
735                    signal_id: 2,
736                    fine_pseudorange: 0,
737                    lock_time_indicator: 0,
738                    half_cycle_ambiguity: false,
739                    cnr: 0,
740                    fine_phase_range: 0,
741                    fine_phase_range_rate: None,
742                },
743            ],
744        }]
745    }
746
747    #[test]
748    fn rtcm_msm_helper_assembles_single_epoch_with_signal_selection() {
749        let messages = synthetic_rtcm_messages();
750        let options = RinexSppOptions::new(SignalPolicy::default_for(3.03).expect("policy"));
751        let inputs =
752            spp_inputs_from_rtcm_msm(&messages, &NoCorrections, &options, |_system, _raw| {
753                Some((
754                    1_234_567.0,
755                    ObsEpochTime {
756                        year: 2026,
757                        month: 7,
758                        day: 7,
759                        hour: 0,
760                        minute: 0,
761                        second: 0.0,
762                    },
763                ))
764            })
765            .expect("convert");
766
767        assert_eq!(inputs.len(), 1);
768        let epoch = &inputs[0];
769        assert_eq!(epoch.inputs.observations.len(), 1);
770        assert_eq!(epoch.inputs.observations[0].satellite_id.to_string(), "G01");
771        assert_eq!(
772            epoch.inputs.observations[0].pseudorange_m as i64,
773            30_129_142
774        );
775    }
776}