#![allow(clippy::doc_markdown)]
const SPEED_OF_LIGHT: f64 = 299_792_458.0;
const FUNDAMENTAL_HZ: f64 = 10.23e6;
pub const L1_HZ: f64 = 154.0 * FUNDAMENTAL_HZ;
pub const L2_HZ: f64 = 120.0 * FUNDAMENTAL_HZ;
pub const L5_HZ: f64 = 115.0 * FUNDAMENTAL_HZ;
pub const E6_HZ: f64 = 125.0 * FUNDAMENTAL_HZ;
pub const E5B_HZ: f64 = 118.0 * FUNDAMENTAL_HZ;
pub const E5_HZ: f64 = 116.5 * FUNDAMENTAL_HZ;
pub const B1I_HZ: f64 = 152.6 * FUNDAMENTAL_HZ;
pub const B3I_HZ: f64 = 124.0 * FUNDAMENTAL_HZ;
pub const NAVIC_S_HZ: f64 = 243.6 * FUNDAMENTAL_HZ;
pub const GLONASS_G3_HZ: f64 = 117.5 * FUNDAMENTAL_HZ;
pub const GLONASS_G1A_HZ: f64 = 156.5 * FUNDAMENTAL_HZ;
pub const GLONASS_G2A_HZ: f64 = 122.0 * FUNDAMENTAL_HZ;
#[must_use]
pub fn carrier_frequency(system: char, band: char) -> Option<f64> {
Some(match (system, band) {
('G' | 'J' | 'E' | 'C' | 'S', '1') => L1_HZ,
('G' | 'J', '2') => L2_HZ,
('G' | 'J' | 'E' | 'C' | 'I' | 'S', '5') => L5_HZ,
('J' | 'E', '6') => E6_HZ,
('E' | 'C', '7') => E5B_HZ,
('E' | 'C', '8') => E5_HZ,
('C', '2') => B1I_HZ,
('C', '6') => B3I_HZ,
('I', '9') => NAVIC_S_HZ,
('R', '3') => GLONASS_G3_HZ,
('R', '4') => GLONASS_G1A_HZ,
('R', '6') => GLONASS_G2A_HZ,
_ => return None,
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CombinationError {
NonPositiveFrequency,
NotDescending,
}
impl core::fmt::Display for CombinationError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::NonPositiveFrequency => {
write!(f, "a carrier frequency is not finite and positive")
}
Self::NotDescending => write!(f, "the carrier frequencies are not strictly descending"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct FrequencyPair {
f1: f64,
f2: f64,
}
impl FrequencyPair {
pub const GPS_L1_L2: Self = Self {
f1: L1_HZ,
f2: L2_HZ,
};
pub const GPS_L1_L5: Self = Self {
f1: L1_HZ,
f2: L5_HZ,
};
pub const GALILEO_E1_E5A: Self = Self {
f1: L1_HZ,
f2: L5_HZ,
};
pub fn new(f1: f64, f2: f64) -> Result<Self, CombinationError> {
if !(f1.is_finite() && f2.is_finite() && f1 > 0.0 && f2 > 0.0) {
return Err(CombinationError::NonPositiveFrequency);
}
if f1 <= f2 {
return Err(CombinationError::NotDescending);
}
Ok(Self { f1, f2 })
}
#[must_use]
pub const fn f1(self) -> f64 {
self.f1
}
#[must_use]
pub const fn f2(self) -> f64 {
self.f2
}
#[must_use]
pub fn wavelength1(self) -> f64 {
SPEED_OF_LIGHT / self.f1
}
#[must_use]
pub fn wavelength2(self) -> f64 {
SPEED_OF_LIGHT / self.f2
}
#[must_use]
pub fn wide_lane_wavelength(self) -> f64 {
SPEED_OF_LIGHT / (self.f1 - self.f2)
}
#[must_use]
pub fn narrow_lane_wavelength(self) -> f64 {
SPEED_OF_LIGHT / (self.f1 + self.f2)
}
#[must_use]
pub fn ionosphere_free(self, x1: f64, x2: f64) -> f64 {
let (a, b) = (self.f1 * self.f1, self.f2 * self.f2);
(a * x1 - b * x2) / (a - b)
}
#[must_use]
pub fn wide_lane(self, x1: f64, x2: f64) -> f64 {
(self.f1 * x1 - self.f2 * x2) / (self.f1 - self.f2)
}
#[must_use]
pub fn narrow_lane(self, x1: f64, x2: f64) -> f64 {
(self.f1 * x1 + self.f2 * x2) / (self.f1 + self.f2)
}
#[must_use]
pub fn melbourne_wubbena(self, p1: f64, p2: f64, l1: f64, l2: f64) -> f64 {
self.wide_lane(l1, l2) - self.narrow_lane(p1, p2)
}
#[must_use]
pub fn melbourne_wubbena_cycles(self, p1: f64, p2: f64, l1: f64, l2: f64) -> f64 {
self.melbourne_wubbena(p1, p2, l1, l2) / self.wide_lane_wavelength()
}
}
#[must_use]
pub fn geometry_free(x1: f64, x2: f64) -> f64 {
x1 - x2
}
#[must_use]
pub fn graphic(p: f64, l: f64) -> f64 {
f64::midpoint(p, l)
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
#[test]
fn the_carriers_are_their_published_frequencies() {
for (got, want, name) in [
(L1_HZ, 1_575.42e6, "L1"),
(L2_HZ, 1_227.60e6, "L2"),
(L5_HZ, 1_176.45e6, "L5"),
(E6_HZ, 1_278.75e6, "E6"),
(E5B_HZ, 1_207.14e6, "E5b"),
(E5_HZ, 1_191.795e6, "E5"),
(B1I_HZ, 1_561.098e6, "B1I"),
(B3I_HZ, 1_268.52e6, "B3I"),
(NAVIC_S_HZ, 2_492.028e6, "NavIC S"),
(GLONASS_G3_HZ, 1_202.025e6, "G3"),
(GLONASS_G1A_HZ, 1_600.995e6, "G1a"),
(GLONASS_G2A_HZ, 1_248.06e6, "G2a"),
] {
assert!((got - want).abs() < 1.0, "{name}: {got} vs {want}");
}
}
#[test]
fn the_band_lookup_resolves_what_it_can_and_refuses_the_rest() {
for (system, band, mhz, name) in [
('G', '1', 1_575.42, "GPS L1"),
('J', '1', 1_575.42, "QZSS L1"),
('E', '1', 1_575.42, "Galileo E1"),
('C', '1', 1_575.42, "BeiDou B1C"),
('S', '1', 1_575.42, "SBAS L1"),
('G', '2', 1_227.60, "GPS L2"),
('J', '2', 1_227.60, "QZSS L2"),
('G', '5', 1_176.45, "GPS L5"),
('J', '5', 1_176.45, "QZSS L5"),
('E', '5', 1_176.45, "Galileo E5a"),
('C', '5', 1_176.45, "BeiDou B2a"),
('I', '5', 1_176.45, "NavIC L5"),
('S', '5', 1_176.45, "SBAS L5"),
('J', '6', 1_278.75, "QZSS L6"),
('E', '6', 1_278.75, "Galileo E6"),
('E', '7', 1_207.14, "Galileo E5b"),
('C', '7', 1_207.14, "BeiDou B2b"),
('E', '8', 1_191.795, "Galileo E5"),
('C', '8', 1_191.795, "BeiDou B2"),
('C', '2', 1_561.098, "BeiDou B1I"),
('C', '6', 1_268.52, "BeiDou B3I"),
('I', '9', 2_492.028, "NavIC S"),
('R', '3', 1_202.025, "GLONASS L3OC"),
('R', '4', 1_600.995, "GLONASS L1OC"),
('R', '6', 1_248.06, "GLONASS L2OC"),
] {
let got = carrier_frequency(system, band)
.unwrap_or_else(|| panic!("{name}: {system}{band} has no frequency"));
assert!(
(got - mhz * 1e6).abs() < 1.0,
"{name}: {got} vs {}",
mhz * 1e6
);
}
for (a, b) in [
(('G', '1'), ('E', '1')),
(('G', '1'), ('C', '1')),
(('G', '5'), ('E', '5')),
(('E', '7'), ('C', '7')),
] {
assert_eq!(
carrier_frequency(a.0, a.1),
carrier_frequency(b.0, b.1),
"{}{} and {}{} share a carrier",
a.0,
a.1,
b.0,
b.1
);
}
let (b1c, b1i) = (
carrier_frequency('C', '1').unwrap(),
carrier_frequency('C', '2').unwrap(),
);
assert!((b1c - b1i - 14.322e6).abs() < 1.0, "{}", b1c - b1i);
for (system, band, why) in [
('R', '1', "GLONASS G1 is FDMA"),
('R', '2', "GLONASS G2 is FDMA"),
('G', '6', "no GPS band 6"),
('G', '7', "no GPS band 7"),
('G', '8', "no GPS band 8"),
('J', '7', "no QZSS band 7"),
('E', '2', "no Galileo band 2"),
('E', '9', "no Galileo band 9"),
('C', '9', "no BeiDou band 9"),
('I', '1', "no NavIC band 1"),
('S', '2', "no SBAS band 2"),
('R', '5', "no GLONASS band 5"),
('R', '9', "no GLONASS band 9"),
('Z', '1', "unknown system"),
('g', '1', "the letters are upper case"),
('G', 'X', "not a band digit"),
('G', ' ', "blank band"),
] {
assert_eq!(
carrier_frequency(system, band),
None,
"{system}{band}: {why}"
);
}
}
#[test]
fn a_pair_must_be_positive_and_descending() {
assert_eq!(
FrequencyPair::new(L2_HZ, L1_HZ),
Err(CombinationError::NotDescending)
);
assert_eq!(
FrequencyPair::new(L1_HZ, L1_HZ),
Err(CombinationError::NotDescending)
);
assert_eq!(
FrequencyPair::new(-1.0, L2_HZ),
Err(CombinationError::NonPositiveFrequency)
);
assert_eq!(
FrequencyPair::new(f64::NAN, L2_HZ),
Err(CombinationError::NonPositiveFrequency)
);
assert_eq!(
FrequencyPair::new(L1_HZ, L2_HZ),
Ok(FrequencyPair::GPS_L1_L2)
);
assert_ne!(
CombinationError::NotDescending.to_string(),
CombinationError::NonPositiveFrequency.to_string()
);
}
#[test]
fn the_gps_wavelengths_are_the_published_ones() {
let p = FrequencyPair::GPS_L1_L2;
for (got, want, name) in [
(p.wavelength1(), 0.190_293_672_798, "λ1"),
(p.wavelength2(), 0.244_210_213_425, "λ2"),
(p.wide_lane_wavelength(), 0.861_918_400_322, "λ_WL"),
(p.narrow_lane_wavelength(), 0.106_953_378_142, "λ_NL"),
] {
assert!(
(got - want).abs() < 1e-12,
"{name}: {got:.12} vs {want:.12}"
);
}
}
#[test]
fn each_combination_cancels_what_it_is_built_to_cancel() {
let p = FrequencyPair::GPS_L1_L2;
let (f1, f2) = (p.f1(), p.f2());
let rho = 22_345_678.901_f64; let iono = 4.0e16_f64; let (n1, n2) = (-1_234_567.0_f64, -962_120.0_f64);
let (i1, i2) = (iono / (f1 * f1), iono / (f2 * f2));
let (p1, p2) = (rho + i1, rho + i2);
let (l1, l2) = (
rho - i1 + n1 * p.wavelength1(),
rho - i2 + n2 * p.wavelength2(),
);
assert!(
(p.ionosphere_free(p1, p2) - rho).abs() < 1e-6,
"IF code: {}",
p.ionosphere_free(p1, p2) - rho
);
let gf_code = geometry_free(p1, p2);
assert!((gf_code - (i1 - i2)).abs() < 1e-8, "GF code: {gf_code}");
let gf_phase = geometry_free(l1, l2);
let want = (i2 - i1) + n1 * p.wavelength1() - n2 * p.wavelength2();
assert!(
(gf_phase - want).abs() < 1e-6,
"GF phase: {gf_phase} vs {want}"
);
let wl = p.wide_lane(l1, l2);
let n_wl = n1 - n2;
let want = rho - (f1 * i1 - f2 * i2) / (f1 - f2) + n_wl * p.wide_lane_wavelength();
assert!((wl - want).abs() < 1e-6, "WL: {wl} vs {want}");
let nl = p.narrow_lane(p1, p2);
let want = rho + (f1 * i1 + f2 * i2) / (f1 + f2);
assert!((nl - want).abs() < 1e-6, "NL: {nl} vs {want}");
let wl_iono = -(f1 * i1 - f2 * i2) / (f1 - f2);
let nl_iono = (f1 * i1 + f2 * i2) / (f1 + f2);
let closed_form = iono / (f1 * f2);
assert!(
(wl_iono - nl_iono).abs() < 1e-12 && (wl_iono - closed_form).abs() < 1e-12,
"the MW ionosphere does not cancel: {wl_iono} vs {nl_iono} vs {closed_form}"
);
let mw = p.melbourne_wubbena(p1, p2, l1, l2);
let want = n_wl * p.wide_lane_wavelength();
assert!((mw - want).abs() < 1e-6, "MW: {mw} vs {want}");
let cycles = p.melbourne_wubbena_cycles(p1, p2, l1, l2);
assert!(
(cycles - n_wl).abs() < 1e-6,
"MW cycles: {cycles} vs {n_wl}"
);
let g = graphic(p1, l1);
let want = rho + 0.5 * n1 * p.wavelength1();
assert!((g - want).abs() < 1e-6, "GRAPHIC: {g} vs {want}");
}
#[test]
fn the_combinations_match_values_computed_by_hand() {
let p = FrequencyPair::GPS_L1_L2;
let (p1, p2) = (20_000_000.0_f64, 20_000_002.0_f64);
let (l1, l2) = (19_999_999.0_f64, 19_999_997.0_f64);
for (got, want, name) in [
(p.ionosphere_free(p1, p2), 19_999_996.908_544_44, "IF"),
(p.wide_lane(l1, l2), 20_000_006.058_823_53, "WL"),
(p.narrow_lane(p1, p2), 20_000_000.875_912_41, "NL"),
(p.melbourne_wubbena(p1, p2, l1, l2), 5.182_911_121, "MW"),
(geometry_free(p1, p2), -2.0, "GF"),
(graphic(p1, l1), 19_999_999.5, "GRAPHIC"),
] {
assert!((got - want).abs() < 1e-6, "{name}: {got:.9} vs {want:.9}");
}
let (f1, f2) = (p.f1() / FUNDAMENTAL_HZ, p.f2() / FUNDAMENTAL_HZ);
assert!(((f1 * f1 - f2 * f2) - 9316.0).abs() < 1e-9);
assert!(((f1 - f2) - 34.0).abs() < 1e-9);
assert!(((f1 + f2) - 274.0).abs() < 1e-9);
}
}