Skip to main content

sidereon_core/
frequencies.rs

1//! Canonical GNSS carrier-frequency table.
2//!
3//! All GNSS carrier lookup paths route through this module so signal frequency
4//! policy cannot drift between combinations, RINEX observation decoding, SPP,
5//! and ionosphere models.
6
7use core::fmt;
8
9use crate::constants::{C_M_S, F_B1I_HZ, F_B3I_HZ, F_E1_HZ, F_E5A_HZ, F_L1_HZ, F_L2_HZ};
10use crate::validate;
11use crate::GnssSystem;
12
13const F_E6_HZ: f64 = 1_278_750_000.0;
14const F_E5B_HZ: f64 = 1_207_140_000.0;
15const F_E5_HZ: f64 = 1_191_795_000.0;
16const F_GLONASS_G1_BASE_HZ: f64 = 1_602_000_000.0;
17const F_GLONASS_G1_STEP_HZ: f64 = 562_500.0;
18const F_GLONASS_G2_BASE_HZ: f64 = 1_246_000_000.0;
19const F_GLONASS_G2_STEP_HZ: f64 = 437_500.0;
20
21/// GNSS carrier band.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
23pub enum CarrierBand {
24    /// GPS/QZSS L1.
25    L1,
26    /// GPS/QZSS L2.
27    L2,
28    /// GPS/QZSS L5.
29    L5,
30    /// Galileo E1.
31    E1,
32    /// Galileo E5a.
33    E5a,
34    /// Galileo E5b.
35    E5b,
36    /// Galileo E5 AltBOC.
37    E5,
38    /// Galileo E6.
39    E6,
40    /// BeiDou B1C.
41    B1c,
42    /// BeiDou B1I.
43    B1i,
44    /// BeiDou B2a.
45    B2a,
46    /// BeiDou B2b.
47    B2b,
48    /// BeiDou B2.
49    B2,
50    /// BeiDou B3I.
51    B3i,
52    /// GLONASS G1 FDMA.
53    G1,
54    /// GLONASS G2 FDMA.
55    G2,
56}
57
58impl CarrierBand {
59    /// Parse a lower-case carrier-band token.
60    pub fn from_name(name: &str) -> Option<Self> {
61        match name {
62            "l1" => Some(Self::L1),
63            "l2" => Some(Self::L2),
64            "l5" => Some(Self::L5),
65            "e1" => Some(Self::E1),
66            "e5a" => Some(Self::E5a),
67            "e5b" => Some(Self::E5b),
68            "e5" => Some(Self::E5),
69            "e6" => Some(Self::E6),
70            "b1c" => Some(Self::B1c),
71            "b1i" => Some(Self::B1i),
72            "b2a" => Some(Self::B2a),
73            "b2b" => Some(Self::B2b),
74            "b2" => Some(Self::B2),
75            "b3i" => Some(Self::B3i),
76            "g1" => Some(Self::G1),
77            "g2" => Some(Self::G2),
78            _ => None,
79        }
80    }
81
82    /// Parse only the carrier-band tokens supported by the ionosphere-free API.
83    pub fn from_iono_free_name(name: &str) -> Option<Self> {
84        match name {
85            "l1" => Some(Self::L1),
86            "l2" => Some(Self::L2),
87            "e1" => Some(Self::E1),
88            "e5a" => Some(Self::E5a),
89            "b1i" => Some(Self::B1i),
90            "b3i" => Some(Self::B3i),
91            _ => None,
92        }
93    }
94
95    /// The canonical lower-case band token.
96    pub const fn as_str(&self) -> &'static str {
97        match *self {
98            Self::L1 => "l1",
99            Self::L2 => "l2",
100            Self::L5 => "l5",
101            Self::E1 => "e1",
102            Self::E5a => "e5a",
103            Self::E5b => "e5b",
104            Self::E5 => "e5",
105            Self::E6 => "e6",
106            Self::B1c => "b1c",
107            Self::B1i => "b1i",
108            Self::B2a => "b2a",
109            Self::B2b => "b2b",
110            Self::B2 => "b2",
111            Self::B3i => "b3i",
112            Self::G1 => "g1",
113            Self::G2 => "g2",
114        }
115    }
116
117    /// The canonical lower-case band token.
118    pub const fn name(self) -> &'static str {
119        self.as_str()
120    }
121}
122
123impl fmt::Display for CarrierBand {
124    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
125        f.write_str(self.as_str())
126    }
127}
128
129/// A standard two-carrier ionosphere-free pair.
130#[derive(Debug, Clone, Copy, PartialEq, Eq)]
131pub struct CarrierPair {
132    /// First carrier band in the affine combination.
133    pub band1: CarrierBand,
134    /// Second carrier band in the affine combination.
135    pub band2: CarrierBand,
136}
137
138impl CarrierPair {
139    /// Construct a pair from two carrier bands.
140    pub const fn new(band1: CarrierBand, band2: CarrierBand) -> Self {
141        Self { band1, band2 }
142    }
143}
144
145/// One fixed-frequency carrier-table entry.
146#[derive(Debug, Clone, Copy, PartialEq)]
147pub struct CarrierFrequency {
148    /// GNSS constellation.
149    pub system: GnssSystem,
150    /// Carrier band.
151    pub band: CarrierBand,
152    /// Carrier frequency in hertz.
153    pub frequency_hz: f64,
154}
155
156/// Fixed-frequency carrier entries. GLONASS FDMA carriers are channel-derived
157/// through [`rinex_band_frequency_hz`] and therefore do not appear here.
158pub const fn fixed_carrier_frequencies() -> [CarrierFrequency; 17] {
159    [
160        CarrierFrequency {
161            system: GnssSystem::Gps,
162            band: CarrierBand::L1,
163            frequency_hz: F_L1_HZ,
164        },
165        CarrierFrequency {
166            system: GnssSystem::Gps,
167            band: CarrierBand::L2,
168            frequency_hz: F_L2_HZ,
169        },
170        CarrierFrequency {
171            system: GnssSystem::Gps,
172            band: CarrierBand::L5,
173            frequency_hz: F_E5A_HZ,
174        },
175        CarrierFrequency {
176            system: GnssSystem::Qzss,
177            band: CarrierBand::L1,
178            frequency_hz: F_L1_HZ,
179        },
180        CarrierFrequency {
181            system: GnssSystem::Qzss,
182            band: CarrierBand::L2,
183            frequency_hz: F_L2_HZ,
184        },
185        CarrierFrequency {
186            system: GnssSystem::Qzss,
187            band: CarrierBand::L5,
188            frequency_hz: F_E5A_HZ,
189        },
190        CarrierFrequency {
191            system: GnssSystem::Galileo,
192            band: CarrierBand::E1,
193            frequency_hz: F_E1_HZ,
194        },
195        CarrierFrequency {
196            system: GnssSystem::Galileo,
197            band: CarrierBand::E5a,
198            frequency_hz: F_E5A_HZ,
199        },
200        CarrierFrequency {
201            system: GnssSystem::Galileo,
202            band: CarrierBand::E6,
203            frequency_hz: F_E6_HZ,
204        },
205        CarrierFrequency {
206            system: GnssSystem::Galileo,
207            band: CarrierBand::E5b,
208            frequency_hz: F_E5B_HZ,
209        },
210        CarrierFrequency {
211            system: GnssSystem::Galileo,
212            band: CarrierBand::E5,
213            frequency_hz: F_E5_HZ,
214        },
215        CarrierFrequency {
216            system: GnssSystem::BeiDou,
217            band: CarrierBand::B1c,
218            frequency_hz: F_L1_HZ,
219        },
220        CarrierFrequency {
221            system: GnssSystem::BeiDou,
222            band: CarrierBand::B1i,
223            frequency_hz: F_B1I_HZ,
224        },
225        CarrierFrequency {
226            system: GnssSystem::BeiDou,
227            band: CarrierBand::B2a,
228            frequency_hz: F_E5A_HZ,
229        },
230        CarrierFrequency {
231            system: GnssSystem::BeiDou,
232            band: CarrierBand::B3i,
233            frequency_hz: F_B3I_HZ,
234        },
235        CarrierFrequency {
236            system: GnssSystem::BeiDou,
237            band: CarrierBand::B2b,
238            frequency_hz: F_E5B_HZ,
239        },
240        CarrierFrequency {
241            system: GnssSystem::BeiDou,
242            band: CarrierBand::B2,
243            frequency_hz: F_E5_HZ,
244        },
245    ]
246}
247
248/// Carrier entries used by the current ionosphere-free public API.
249pub const fn iono_free_carrier_frequencies() -> [CarrierFrequency; 6] {
250    [
251        CarrierFrequency {
252            system: GnssSystem::Gps,
253            band: CarrierBand::L1,
254            frequency_hz: F_L1_HZ,
255        },
256        CarrierFrequency {
257            system: GnssSystem::Gps,
258            band: CarrierBand::L2,
259            frequency_hz: F_L2_HZ,
260        },
261        CarrierFrequency {
262            system: GnssSystem::Galileo,
263            band: CarrierBand::E1,
264            frequency_hz: F_E1_HZ,
265        },
266        CarrierFrequency {
267            system: GnssSystem::Galileo,
268            band: CarrierBand::E5a,
269            frequency_hz: F_E5A_HZ,
270        },
271        CarrierFrequency {
272            system: GnssSystem::BeiDou,
273            band: CarrierBand::B1i,
274            frequency_hz: F_B1I_HZ,
275        },
276        CarrierFrequency {
277            system: GnssSystem::BeiDou,
278            band: CarrierBand::B3i,
279            frequency_hz: F_B3I_HZ,
280        },
281    ]
282}
283
284/// Carrier frequency in hertz for a constellation and canonical carrier band.
285pub const fn frequency_hz(system: GnssSystem, band: CarrierBand) -> Option<f64> {
286    match (system, band) {
287        (GnssSystem::Gps, CarrierBand::L1) => Some(F_L1_HZ),
288        (GnssSystem::Gps, CarrierBand::L2) => Some(F_L2_HZ),
289        (GnssSystem::Gps, CarrierBand::L5) => Some(F_E5A_HZ),
290        (GnssSystem::Qzss, CarrierBand::L1) => Some(F_L1_HZ),
291        (GnssSystem::Qzss, CarrierBand::L2) => Some(F_L2_HZ),
292        (GnssSystem::Qzss, CarrierBand::L5) => Some(F_E5A_HZ),
293        (GnssSystem::Galileo, CarrierBand::E1) => Some(F_E1_HZ),
294        (GnssSystem::Galileo, CarrierBand::E5a) => Some(F_E5A_HZ),
295        (GnssSystem::Galileo, CarrierBand::E6) => Some(F_E6_HZ),
296        (GnssSystem::Galileo, CarrierBand::E5b) => Some(F_E5B_HZ),
297        (GnssSystem::Galileo, CarrierBand::E5) => Some(F_E5_HZ),
298        (GnssSystem::BeiDou, CarrierBand::B1c) => Some(F_L1_HZ),
299        (GnssSystem::BeiDou, CarrierBand::B1i) => Some(F_B1I_HZ),
300        (GnssSystem::BeiDou, CarrierBand::B2a) => Some(F_E5A_HZ),
301        (GnssSystem::BeiDou, CarrierBand::B3i) => Some(F_B3I_HZ),
302        (GnssSystem::BeiDou, CarrierBand::B2b) => Some(F_E5B_HZ),
303        (GnssSystem::BeiDou, CarrierBand::B2) => Some(F_E5_HZ),
304        _ => None,
305    }
306}
307
308/// Carrier wavelength in meters for a constellation and canonical carrier band.
309pub fn wavelength_m(system: GnssSystem, band: CarrierBand) -> Option<f64> {
310    frequency_hz(system, band).and_then(wavelength_for_frequency)
311}
312
313/// RINEX observation band frequency in hertz for a system and band digit.
314///
315/// GLONASS G1/G2 carriers require the FDMA channel number from the observation
316/// file's `GLONASS SLOT / FRQ #` records.
317pub fn rinex_band_frequency_hz(
318    system: GnssSystem,
319    band: char,
320    glonass_channel: Option<i8>,
321) -> Option<f64> {
322    rinex_signal_frequency_hz(system, band, None, None, glonass_channel)
323}
324
325/// Carrier frequency in hertz for a system and RINEX band digit.
326///
327/// Unlike [`rinex_band_frequency_hz`], this classified lookup distinguishes a
328/// missing GLONASS FDMA channel.
329pub fn rinex_band_frequency_hz_classified(
330    system: GnssSystem,
331    band: char,
332    glonass_channel: Option<i8>,
333) -> crate::Result<Option<f64>> {
334    if matches!(
335        (system, band, glonass_channel),
336        (GnssSystem::Glonass, '1' | '2', None)
337    ) {
338        return Err(crate::Error::MissingGlonassChannel);
339    }
340    Ok(rinex_band_frequency_hz(system, band, glonass_channel))
341}
342
343/// RINEX observation-code frequency in hertz for a system and full code.
344///
345/// BeiDou's band labels changed across RINEX 3 minor versions: in RINEX 3.02
346/// `C1I`/`L1I` are B1I (1561.098 MHz), while in RINEX 3.03 and later band 1 is
347/// B1C (1575.42 MHz). Use this helper when the observation code and file
348/// version are available instead of reducing the code to a band digit first.
349pub fn rinex_observation_frequency_hz(
350    system: GnssSystem,
351    code: &str,
352    rinex_version: f64,
353    glonass_channel: Option<i8>,
354) -> Option<f64> {
355    let mut chars = code.chars();
356    let _kind = chars.next()?;
357    let band = chars.next()?;
358    let tracking = chars.next();
359    rinex_signal_frequency_hz(system, band, tracking, Some(rinex_version), glonass_channel)
360}
361
362fn rinex_signal_frequency_hz(
363    system: GnssSystem,
364    band: char,
365    tracking: Option<char>,
366    rinex_version: Option<f64>,
367    glonass_channel: Option<i8>,
368) -> Option<f64> {
369    let frequency_hz = match (system, band, glonass_channel) {
370        (GnssSystem::Gps, '1', _) => frequency_hz(system, CarrierBand::L1),
371        (GnssSystem::Gps, '2', _) => frequency_hz(system, CarrierBand::L2),
372        (GnssSystem::Gps, '5', _) => frequency_hz(system, CarrierBand::L5),
373        (GnssSystem::Qzss, '1', _) => frequency_hz(system, CarrierBand::L1),
374        (GnssSystem::Qzss, '2', _) => frequency_hz(system, CarrierBand::L2),
375        (GnssSystem::Qzss, '5', _) => frequency_hz(system, CarrierBand::L5),
376        (GnssSystem::Galileo, '1', _) => frequency_hz(system, CarrierBand::E1),
377        (GnssSystem::Galileo, '5', _) => frequency_hz(system, CarrierBand::E5a),
378        (GnssSystem::Galileo, '6', _) => frequency_hz(system, CarrierBand::E6),
379        (GnssSystem::Galileo, '7', _) => frequency_hz(system, CarrierBand::E5b),
380        (GnssSystem::Galileo, '8', _) => frequency_hz(system, CarrierBand::E5),
381        (GnssSystem::BeiDou, _, _) => {
382            frequency_hz(system, rinex_beidou_band(band, tracking, rinex_version)?)
383        }
384        (GnssSystem::Glonass, '1', Some(channel)) => {
385            Some(F_GLONASS_G1_BASE_HZ + f64::from(channel) * F_GLONASS_G1_STEP_HZ)
386        }
387        (GnssSystem::Glonass, '2', Some(channel)) => {
388            Some(F_GLONASS_G2_BASE_HZ + f64::from(channel) * F_GLONASS_G2_STEP_HZ)
389        }
390        _ => None,
391    }?;
392    valid_frequency_hz(frequency_hz)
393}
394
395fn rinex_beidou_band(
396    band: char,
397    tracking: Option<char>,
398    rinex_version: Option<f64>,
399) -> Option<CarrierBand> {
400    match band {
401        '1' if tracking == Some('I') && rinex_version.is_some_and(is_rinex_302) => {
402            Some(CarrierBand::B1i)
403        }
404        '1' => Some(CarrierBand::B1c),
405        '2' => Some(CarrierBand::B1i),
406        '5' => Some(CarrierBand::B2a),
407        '6' => Some(CarrierBand::B3i),
408        '7' => Some(CarrierBand::B2b),
409        '8' => Some(CarrierBand::B2),
410        _ => None,
411    }
412}
413
414fn is_rinex_302(version: f64) -> bool {
415    (3.015..3.025).contains(&version)
416}
417
418/// RINEX observation band wavelength in meters for a system and band digit.
419pub fn rinex_band_wavelength_m(
420    system: GnssSystem,
421    band: char,
422    glonass_channel: Option<i8>,
423) -> Option<f64> {
424    rinex_band_frequency_hz(system, band, glonass_channel).and_then(wavelength_for_frequency)
425}
426
427/// RINEX observation-code wavelength in meters for a system and full code.
428pub fn rinex_observation_wavelength_m(
429    system: GnssSystem,
430    code: &str,
431    rinex_version: f64,
432    glonass_channel: Option<i8>,
433) -> Option<f64> {
434    rinex_observation_frequency_hz(system, code, rinex_version, glonass_channel)
435        .and_then(wavelength_for_frequency)
436}
437
438fn valid_frequency_hz(frequency_hz: f64) -> Option<f64> {
439    validate::finite_positive(frequency_hz, "frequency_hz").ok()
440}
441
442pub(crate) fn wavelength_for_frequency(frequency_hz: f64) -> Option<f64> {
443    valid_frequency_hz(frequency_hz).map(|frequency_hz| C_M_S / frequency_hz)
444}
445
446/// Standard dual-frequency ionosphere-free carrier pair for a constellation.
447pub const fn default_iono_free_pair(system: GnssSystem) -> Option<CarrierPair> {
448    match system {
449        GnssSystem::Gps => Some(CarrierPair::new(CarrierBand::L1, CarrierBand::L2)),
450        GnssSystem::Galileo => Some(CarrierPair::new(CarrierBand::E1, CarrierBand::E5a)),
451        GnssSystem::BeiDou => Some(CarrierPair::new(CarrierBand::B1i, CarrierBand::B3i)),
452        _ => None,
453    }
454}
455
456/// GLONASS G1 FDMA carrier frequency in hertz for an FDMA channel number `k`.
457///
458/// GLONASS is frequency-division multiplexed, so it has no single fixed
459/// single-frequency carrier and is therefore absent from
460/// [`default_spp_frequency_hz`]. The SPP ionosphere-scaling policy resolves the
461/// GLONASS carrier per satellite from its broadcast / observation FDMA channel
462/// instead: `1602.0 MHz + k * 562.5 kHz`, the same table as
463/// [`rinex_band_frequency_hz`] uses for G1. This is the carrier the broadcast
464/// Klobuchar L1 delay is scaled to by `(f_L1 / f_k)^2` for a GLONASS satellite.
465pub const fn glonass_g1_frequency_hz(channel: i8) -> f64 {
466    F_GLONASS_G1_BASE_HZ + (channel as f64) * F_GLONASS_G1_STEP_HZ
467}
468
469/// Single-frequency carrier used by the SPP ionosphere-scaling policy.
470pub const fn default_spp_carrier(system: GnssSystem) -> Option<CarrierBand> {
471    match system {
472        GnssSystem::Gps => Some(CarrierBand::L1),
473        GnssSystem::Galileo => Some(CarrierBand::E1),
474        GnssSystem::BeiDou => Some(CarrierBand::B1i),
475        _ => None,
476    }
477}
478
479/// Single-frequency carrier frequency used by the SPP ionosphere-scaling policy.
480pub const fn default_spp_frequency_hz(system: GnssSystem) -> Option<f64> {
481    match default_spp_carrier(system) {
482        Some(band) => frequency_hz(system, band),
483        None => None,
484    }
485}
486
487#[cfg(test)]
488mod tests {
489    use super::*;
490
491    #[test]
492    fn iono_free_table_matches_supported_pairs() {
493        assert_eq!(
494            default_iono_free_pair(GnssSystem::Gps),
495            Some(CarrierPair::new(CarrierBand::L1, CarrierBand::L2))
496        );
497        assert_eq!(
498            default_iono_free_pair(GnssSystem::Galileo),
499            Some(CarrierPair::new(CarrierBand::E1, CarrierBand::E5a))
500        );
501        assert_eq!(
502            default_iono_free_pair(GnssSystem::BeiDou),
503            Some(CarrierPair::new(CarrierBand::B1i, CarrierBand::B3i))
504        );
505        assert_eq!(default_iono_free_pair(GnssSystem::Glonass), None);
506        assert_eq!(iono_free_carrier_frequencies().len(), 6);
507    }
508
509    #[test]
510    fn carrier_band_labels_are_canonical_lowercase() {
511        let cases = [
512            (CarrierBand::L1, "l1"),
513            (CarrierBand::L2, "l2"),
514            (CarrierBand::L5, "l5"),
515            (CarrierBand::E1, "e1"),
516            (CarrierBand::E5a, "e5a"),
517            (CarrierBand::E5b, "e5b"),
518            (CarrierBand::E5, "e5"),
519            (CarrierBand::E6, "e6"),
520            (CarrierBand::B1c, "b1c"),
521            (CarrierBand::B1i, "b1i"),
522            (CarrierBand::B2a, "b2a"),
523            (CarrierBand::B2b, "b2b"),
524            (CarrierBand::B2, "b2"),
525            (CarrierBand::B3i, "b3i"),
526            (CarrierBand::G1, "g1"),
527            (CarrierBand::G2, "g2"),
528        ];
529        for (band, label) in cases {
530            assert_eq!(band.as_str(), label);
531            assert_eq!(band.name(), label);
532            assert_eq!(band.to_string(), label);
533            assert_eq!(CarrierBand::from_name(label), Some(band));
534        }
535    }
536
537    #[test]
538    fn rinex_band_table_matches_existing_frequency_bits() {
539        let cases: [(GnssSystem, char, Option<i8>, f64); 16] = [
540            (GnssSystem::Gps, '1', None, 1_575_420_000.0),
541            (GnssSystem::Gps, '2', None, 1_227_600_000.0),
542            (GnssSystem::Gps, '5', None, 1_176_450_000.0),
543            (GnssSystem::Qzss, '1', None, 1_575_420_000.0),
544            (GnssSystem::Qzss, '2', None, 1_227_600_000.0),
545            (GnssSystem::Qzss, '5', None, 1_176_450_000.0),
546            (GnssSystem::Galileo, '6', None, 1_278_750_000.0),
547            (GnssSystem::Galileo, '7', None, 1_207_140_000.0),
548            (GnssSystem::Galileo, '8', None, 1_191_795_000.0),
549            (GnssSystem::BeiDou, '1', None, 1_575_420_000.0),
550            (GnssSystem::BeiDou, '2', None, 1_561_098_000.0),
551            (GnssSystem::BeiDou, '6', None, 1_268_520_000.0),
552            (GnssSystem::BeiDou, '7', None, 1_207_140_000.0),
553            (GnssSystem::BeiDou, '8', None, 1_191_795_000.0),
554            (GnssSystem::Glonass, '1', Some(1), 1_602_562_500.0),
555            (GnssSystem::Glonass, '2', Some(1), 1_246_437_500.0),
556        ];
557        for (system, band, channel, expected) in cases {
558            assert_eq!(
559                rinex_band_frequency_hz(system, band, channel).map(f64::to_bits),
560                Some(expected.to_bits())
561            );
562        }
563        assert_eq!(
564            rinex_band_frequency_hz(GnssSystem::Glonass, '1', None),
565            None
566        );
567    }
568
569    #[test]
570    fn classified_rinex_band_lookup_distinguishes_missing_channels() {
571        for band in ['1', '2'] {
572            assert_eq!(
573                rinex_band_frequency_hz_classified(GnssSystem::Glonass, band, None),
574                Err(crate::Error::MissingGlonassChannel)
575            );
576            assert_eq!(
577                rinex_band_frequency_hz(GnssSystem::Glonass, band, None),
578                None
579            );
580        }
581        assert_eq!(
582            rinex_band_frequency_hz_classified(GnssSystem::Glonass, '3', None),
583            Ok(None)
584        );
585    }
586
587    #[test]
588    fn classified_rinex_band_lookup_preserves_frequency_bits() {
589        let cases: [(GnssSystem, char, Option<i8>, f64); 3] = [
590            (GnssSystem::Gps, '1', None, 1_575_420_000.0),
591            (GnssSystem::Glonass, '1', Some(-7), 1_598_062_500.0),
592            (GnssSystem::Glonass, '2', Some(6), 1_248_625_000.0),
593        ];
594        for (system, band, channel, expected) in cases {
595            assert_eq!(
596                rinex_band_frequency_hz_classified(system, band, channel)
597                    .map(|frequency| frequency.map(f64::to_bits)),
598                Ok(Some(expected.to_bits()))
599            );
600        }
601    }
602
603    #[test]
604    fn rinex_observation_code_resolves_beidou_302_b1i() {
605        for code in ["C1I", "L1I"] {
606            assert_eq!(
607                rinex_observation_frequency_hz(GnssSystem::BeiDou, code, 3.02, None)
608                    .map(f64::to_bits),
609                Some(F_B1I_HZ.to_bits())
610            );
611        }
612        assert_eq!(
613            rinex_observation_frequency_hz(GnssSystem::BeiDou, "L1X", 3.03, None).map(f64::to_bits),
614            Some(F_L1_HZ.to_bits())
615        );
616    }
617
618    #[test]
619    fn wavelength_helpers_use_c_over_frequency() {
620        let wavelength = wavelength_m(GnssSystem::Gps, CarrierBand::L1).unwrap();
621        assert_eq!(wavelength.to_bits(), (C_M_S / F_L1_HZ).to_bits());
622
623        let glonass_wavelength = rinex_band_wavelength_m(GnssSystem::Glonass, '1', Some(-7))
624            .expect("GLONASS G1 channel");
625        let frequency = 1_602_000_000.0 + f64::from(-7) * 562_500.0;
626        assert_eq!(glonass_wavelength.to_bits(), (C_M_S / frequency).to_bits());
627    }
628
629    #[test]
630    fn glonass_g1_spp_carrier_matches_rinex_band_table() {
631        for channel in [-7_i8, -1, 0, 1, 6] {
632            assert_eq!(
633                glonass_g1_frequency_hz(channel).to_bits(),
634                rinex_band_frequency_hz(GnssSystem::Glonass, '1', Some(channel))
635                    .expect("GLONASS G1 channel frequency")
636                    .to_bits()
637            );
638        }
639    }
640
641    #[test]
642    fn frequency_validation_rejects_invalid_runtime_values() {
643        assert_eq!(valid_frequency_hz(f64::NAN), None);
644        assert_eq!(valid_frequency_hz(f64::INFINITY), None);
645        assert_eq!(valid_frequency_hz(0.0), None);
646        assert_eq!(valid_frequency_hz(-1.0), None);
647        assert_eq!(wavelength_for_frequency(f64::NAN), None);
648    }
649}