use std::f64::consts::PI;
pub const F0_HZ: f64 = 1_023_000.0;
fn sinc(x: f64) -> f64 {
if x.abs() < 1e-12 {
1.0
} else {
x.sin() / x
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Modulation {
BpskR { n: f64 },
BocSin { m: f64, n: f64 },
Mboc {
p: f64,
},
}
impl Modulation {
pub fn chip_rate_hz(&self) -> f64 {
match *self {
Modulation::BpskR { n } => n * F0_HZ,
Modulation::BocSin { n, .. } => n * F0_HZ,
Modulation::Mboc { .. } => F0_HZ,
}
}
pub fn psd(&self, f_hz: f64) -> f64 {
match *self {
Modulation::BpskR { n } => {
let tc = 1.0 / (n * F0_HZ);
tc * sinc(PI * f_hz * tc).powi(2)
}
Modulation::BocSin { m, n } => {
let fc = n * F0_HZ; let fs = m * F0_HZ; if f_hz.abs() < 1e-9 {
return 0.0; }
let arg_sub = PI * f_hz / (2.0 * fs);
let cos_sub = arg_sub.cos();
if cos_sub.abs() < 1e-9 {
let eps = 1e-6 * fs;
return self.psd(f_hz + eps);
}
let n_half = 2.0 * fs / fc; let n_even = (n_half.round() as i64) % 2 == 0;
let num_code = if n_even {
(PI * f_hz / fc).sin()
} else {
(PI * f_hz / fc).cos()
};
let factor = num_code * arg_sub.sin() / (PI * f_hz * cos_sub);
fc * factor.powi(2)
}
Modulation::Mboc { p } => {
let p = p.clamp(0.0, 1.0);
(1.0 - p) * Modulation::BocSin { m: 1.0, n: 1.0 }.psd(f_hz)
+ p * Modulation::BocSin { m: 6.0, n: 1.0 }.psd(f_hz)
}
}
}
pub fn label(&self) -> String {
let num = |v: f64| {
if (v - v.round()).abs() < 1e-9 {
format!("{}", v.round() as i64)
} else if ((3.0 * v) - (3.0 * v).round()).abs() < 1e-9 {
format!("{}/3", (3.0 * v).round() as i64)
} else {
format!("{v}")
}
};
match *self {
Modulation::BpskR { n } => format!("BPSK({})", num(n)),
Modulation::BocSin { m, n } => format!("BOC({},{})", num(m), num(n)),
Modulation::Mboc { p } if (p - 1.0 / 11.0).abs() < 1e-12 => {
"MBOC(6,1,1/11)".to_string()
}
Modulation::Mboc { p } => format!("MBOC(6,1,{p})"),
}
}
}
fn parse_ratio(s: &str) -> Result<f64, String> {
let s = s.trim();
let v = match s.split_once('/') {
Some((a, b)) => {
let a: f64 = a.trim().parse().map_err(|_| format!("bad number {s:?}"))?;
let b: f64 = b.trim().parse().map_err(|_| format!("bad number {s:?}"))?;
a / b
}
None => s.parse().map_err(|_| format!("bad number {s:?}"))?,
};
if v.is_finite() && v > 0.0 {
Ok(v)
} else {
Err(format!("{s:?} must be a positive number"))
}
}
pub fn parse_modulation(label: &str) -> Result<Modulation, String> {
let t = label.trim().to_ascii_uppercase().replace(' ', "");
let (head, rest) = t
.split_once('(')
.ok_or_else(|| format!("modulation {label:?}: expected NAME(args), e.g. BPSK(10)"))?;
let args = rest
.strip_suffix(')')
.ok_or_else(|| format!("modulation {label:?}: missing closing parenthesis"))?;
let parts: Vec<&str> = args.split(',').collect();
match (head, parts.len()) {
("BPSK" | "BPSK-R", 1) => Ok(Modulation::BpskR {
n: parse_ratio(parts[0])?,
}),
("BOC" | "BOCSIN", 2) => Ok(Modulation::BocSin {
m: parse_ratio(parts[0])?,
n: parse_ratio(parts[1])?,
}),
("MBOC", 3) if parse_ratio(parts[0])? == 6.0 && parse_ratio(parts[1])? == 1.0 => {
Ok(Modulation::Mboc {
p: parse_ratio(parts[2])?,
})
}
_ => Err(format!(
"modulation {label:?} is not one of BPSK(n), BOC(m,n) or MBOC(6,1,p)"
)),
}
}
fn integrate(half: f64, n: usize, g: impl Fn(f64) -> f64) -> f64 {
let n = if n % 2 == 0 { n.max(2) } else { n + 1 };
let h = 2.0 * half / n as f64;
let mut s = g(-half) + g(half);
for i in 1..n {
let f = -half + i as f64 * h;
s += if i % 2 == 1 { 4.0 } else { 2.0 } * g(f);
}
s * h / 3.0
}
pub fn rms_bandwidth_hz(m: &Modulation, band_hz: f64) -> f64 {
let half = band_hz / 2.0;
let n = 20_000;
let num = integrate(half, n, |f| f * f * m.psd(f));
let den = integrate(half, n, |f| m.psd(f));
(num / den.max(1e-300)).sqrt()
}
pub fn spectral_separation_coeff(sig: &Modulation, intf: &Modulation, band_hz: f64) -> f64 {
let half = band_hz / 2.0;
integrate(half, 40_000, |f| sig.psd(f) * intf.psd(f))
}
pub fn spectral_separation_coeff_offset(
sig: &Modulation,
intf: &Modulation,
offset_hz: f64,
band_hz: f64,
) -> f64 {
let half = band_hz / 2.0;
let lobe = sig.chip_rate_hz().min(intf.chip_rate_hz());
let n = ((band_hz / lobe) * 80.0).ceil().clamp(2_000.0, 400_000.0) as usize;
integrate(half, n, |f| sig.psd(f) * intf.psd(f - offset_hz))
}
pub fn ssc_vs_white(sig: &Modulation, band_hz: f64) -> f64 {
let half = band_hz / 2.0;
integrate(half, 40_000, |f| sig.psd(f)) / band_hz
}
pub fn q_from_ssc(ssc_per_hz: f64, chip_rate_hz: f64) -> f64 {
1.0 / (chip_rate_hz * ssc_per_hz.max(1e-30))
}
pub fn dll_code_jitter_chips(
cn0_dbhz: f64,
loop_bw_hz: f64,
corr_spacing_chips: f64,
integ_time_s: f64,
) -> f64 {
let c = 10f64.powf(cn0_dbhz / 10.0);
let d = corr_spacing_chips.clamp(1e-3, 1.999);
let lead = loop_bw_hz * d / (2.0 * c);
let squaring = 1.0 + 2.0 / ((2.0 - d) * integ_time_s * c);
(lead * squaring).sqrt()
}
fn bpsk_acf(x: f64) -> f64 {
(1.0 - x.abs()).max(0.0)
}
pub fn multipath_error_envelope_chips(
spacing_chips: f64,
smr_db: f64,
delay_chips: f64,
) -> (f64, f64) {
let a = 10f64.powf(-smr_db.abs() / 20.0); let d = spacing_chips.max(1e-3);
let discrim = |eps: f64, cos_theta: f64| -> f64 {
let e = bpsk_acf(eps - d / 2.0) + cos_theta * a * bpsk_acf(eps - d / 2.0 - delay_chips);
let l = bpsk_acf(eps + d / 2.0) + cos_theta * a * bpsk_acf(eps + d / 2.0 - delay_chips);
e * e - l * l
};
let solve = |cos_theta: f64| -> f64 {
let w = ((1.0 - d / 2.0).max(0.05)) * 0.98;
let steps = 2000;
let mut prev_x = -w;
let mut prev_f = discrim(prev_x, cos_theta);
let mut best = 0.0;
let mut best_dist = f64::INFINITY;
for i in 1..=steps {
let x = -w + 2.0 * w * i as f64 / steps as f64;
let f = discrim(x, cos_theta);
if prev_f * f < 0.0 {
let (mut a_lo, mut a_hi) = (prev_x, x);
for _ in 0..60 {
let mid = 0.5 * (a_lo + a_hi);
if discrim(a_lo, cos_theta) * discrim(mid, cos_theta) <= 0.0 {
a_hi = mid;
} else {
a_lo = mid;
}
}
let root = 0.5 * (a_lo + a_hi);
if root.abs() < best_dist {
best_dist = root.abs();
best = root;
}
}
prev_x = x;
prev_f = f;
}
best
};
(solve(1.0), solve(-1.0))
}
pub const C_LIGHT_M_PER_S: f64 = 299_792_458.0;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CodeFamily {
MaximalLength { n: u32 },
Gold { n: u32 },
}
fn t_param(n: u32) -> u64 {
1 + (1u64 << ((n + 2) / 2))
}
fn euler_phi(mut m: u64) -> u64 {
let mut result = m;
let mut p = 2u64;
while p * p <= m {
if m % p == 0 {
while m % p == 0 {
m /= p;
}
result -= result / p;
}
p += 1;
}
if m > 1 {
result -= result / m;
}
result
}
impl CodeFamily {
fn n_in_range(n: u32) -> bool {
(2..=31).contains(&n)
}
pub fn register_length(&self) -> u32 {
match *self {
CodeFamily::MaximalLength { n } | CodeFamily::Gold { n } => n,
}
}
pub fn code_length(&self) -> u64 {
let n = self.register_length();
if n == 0 || n >= 63 {
return 0;
}
(1u64 << n) - 1
}
pub fn max_autocorr_sidelobe(&self) -> Option<f64> {
let n = self.register_length();
if !Self::n_in_range(n) {
return None;
}
let l = self.code_length() as f64;
match *self {
CodeFamily::MaximalLength { .. } => Some(1.0 / l),
CodeFamily::Gold { n } if n % 4 != 0 => Some(t_param(n) as f64 / l),
CodeFamily::Gold { .. } => None,
}
}
pub fn max_crosscorr(&self) -> Option<f64> {
match *self {
CodeFamily::MaximalLength { .. } => None,
CodeFamily::Gold { n } if Self::n_in_range(n) && n % 4 != 0 => {
Some(t_param(n) as f64 / self.code_length() as f64)
}
CodeFamily::Gold { .. } => None,
}
}
pub fn peak_to_sidelobe_db(&self) -> Option<f64> {
self.max_autocorr_sidelobe()
.map(|s| 20.0 * (1.0 / s).log10())
}
pub fn gold_codes_exist(&self) -> bool {
match *self {
CodeFamily::MaximalLength { n } => Self::n_in_range(n),
CodeFamily::Gold { n } => Self::n_in_range(n) && n % 4 != 0,
}
}
pub fn family_size(&self) -> Option<u64> {
let n = self.register_length();
if !Self::n_in_range(n) {
return None;
}
match *self {
CodeFamily::MaximalLength { n } => Some(euler_phi(self.code_length()) / n as u64),
CodeFamily::Gold { n } if n % 4 != 0 => Some(self.code_length() + 2),
CodeFamily::Gold { .. } => None,
}
}
}
pub fn range_ambiguity_m(chip_rate_hz: f64, code_length_chips: u64) -> f64 {
if !chip_rate_hz.is_finite() || chip_rate_hz <= 0.0 {
return f64::NAN;
}
C_LIGHT_M_PER_S * code_length_chips as f64 / (2.0 * chip_rate_hz)
}
pub fn code_length_for_ambiguity(chip_rate_hz: f64, required_range_m: f64) -> u64 {
if !chip_rate_hz.is_finite()
|| chip_rate_hz <= 0.0
|| !required_range_m.is_finite()
|| required_range_m < 0.0
{
return 0;
}
let l = 2.0 * chip_rate_hz * required_range_m / C_LIGHT_M_PER_S;
l.ceil().max(1.0) as u64
}
pub fn sine_integral(x: f64) -> f64 {
if x < 0.0 {
return -sine_integral(-x);
}
if x <= 20.0 {
let mut term = x; let mut sum = x;
let mut k = 0usize;
loop {
k += 1;
let a = (2 * k) as f64;
let b = (2 * k + 1) as f64;
term *= -x * x / (a * b);
let add = term / b;
sum += add;
if add.abs() < 1e-17 * sum.abs().max(1.0) || k > 200 {
break;
}
}
return sum;
}
let inv2 = 1.0 / (x * x);
let (mut f, mut g) = (0.0, 0.0);
let (mut tf, mut tg) = (1.0_f64, 1.0_f64);
let mut prev_f = f64::INFINITY;
for k in 0..60usize {
if tf.abs() > prev_f {
break;
}
prev_f = tf.abs();
f += tf;
g += tg;
let kk = k as f64;
tf *= -(2.0 * kk + 1.0) * (2.0 * kk + 2.0) * inv2;
tg *= -(2.0 * kk + 2.0) * (2.0 * kk + 3.0) * inv2;
}
let f = f / x;
let g = g * inv2;
PI / 2.0 - f * x.cos() - g * x.sin()
}
pub fn bpsk_power_in_band_closed_form(chip_rate_hz: f64, band_hz: f64) -> f64 {
if !(chip_rate_hz > 0.0 && band_hz > 0.0) {
return 0.0;
}
let a = PI * band_hz / chip_rate_hz;
let h = a / 2.0;
(2.0 / PI) * (sine_integral(a) - h.sin().powi(2) / h)
}
pub fn bpsk_gabor_bandwidth_closed_form_hz(chip_rate_hz: f64, band_hz: f64) -> f64 {
let tc = 1.0 / chip_rate_hz;
let eta = bpsk_power_in_band_closed_form(chip_rate_hz, band_hz);
let num = band_hz / 2.0 - (PI * band_hz * tc).sin() / (2.0 * PI * tc);
(num / (PI * PI * tc * eta.max(1e-300))).sqrt()
}
pub fn bpsk_offset_ssc_closed_form(chip_rate_hz: f64, offset_hz: f64) -> f64 {
let tc = 1.0 / chip_rate_hz;
let x = 2.0 * PI * offset_hz * tc;
if x.abs() < 1e-4 {
return 2.0 * tc / 3.0 * (1.0 - x * x / 20.0);
}
4.0 / (x * x / tc) * (1.0 - x.sin() / x)
}
pub fn simpson(a: f64, b: f64, n: usize, g: impl Fn(f64) -> f64) -> f64 {
if b <= a {
return 0.0;
}
let n = if n % 2 == 0 { n.max(2) } else { n + 1 };
let h = (b - a) / n as f64;
let mut s = g(a) + g(b);
for i in 1..n {
s += if i % 2 == 1 { 4.0 } else { 2.0 } * g(a + i as f64 * h);
}
s * h / 3.0
}
pub fn panels_for(span_hz: f64, lobe_hz: f64) -> usize {
((span_hz / lobe_hz.max(1.0)) * 40.0)
.ceil()
.clamp(2_000.0, 400_000.0) as usize
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum EarlyLate {
Coherent,
NonCoherent,
}
#[allow(clippy::too_many_arguments)]
pub fn dll_jitter_bandlimited_s(
psd: impl Fn(f64) -> f64,
band_hz: f64,
lobe_hz: f64,
c_n0_dbhz: f64,
loop_bw_hz: f64,
integ_time_s: f64,
spacing_s: f64,
mode: EarlyLate,
) -> f64 {
let cn0 = 10f64.powf(c_n0_dbhz / 10.0);
let half = band_hz / 2.0;
let n = panels_for(band_hz, lobe_hz.min(1.0 / spacing_s.max(1e-15)));
let num = simpson(-half, half, n, |f| {
psd(f) * (PI * f * spacing_s).sin().powi(2)
});
let slope = simpson(-half, half, n, |f| f * psd(f) * (PI * f * spacing_s).sin());
let bl = loop_bw_hz * (1.0 - 0.5 * loop_bw_hz * integ_time_s).max(0.0);
let mut var = bl * num / ((2.0 * PI).powi(2) * cn0 * slope * slope).max(1e-300);
if mode == EarlyLate::NonCoherent {
let c2 = simpson(-half, half, n, |f| {
psd(f) * (PI * f * spacing_s).cos().powi(2)
});
let c1 = simpson(-half, half, n, |f| psd(f) * (PI * f * spacing_s).cos());
var *= 1.0 + c2 / (integ_time_s * cn0 * c1 * c1).max(1e-300);
}
var.sqrt()
}
pub fn dll_jitter_small_spacing_limit_s(
psd: impl Fn(f64) -> f64,
band_hz: f64,
lobe_hz: f64,
c_n0_dbhz: f64,
loop_bw_hz: f64,
integ_time_s: f64,
) -> f64 {
let cn0 = 10f64.powf(c_n0_dbhz / 10.0);
let half = band_hz / 2.0;
let f2 = simpson(-half, half, panels_for(band_hz, lobe_hz), |f| {
f * f * psd(f)
});
let bl = loop_bw_hz * (1.0 - 0.5 * loop_bw_hz * integ_time_s).max(0.0);
(bl / ((2.0 * PI).powi(2) * cn0 * f2).max(1e-300)).sqrt()
}
pub fn altboc_15_10_psd(f_hz: f64) -> f64 {
let fc = 10.0 * F0_HZ;
let fs = 15.0 * F0_HZ;
let eval = |f: f64| -> f64 {
let u = PI * f / (2.0 * fs);
let cu = u.cos();
let bracket = cu * cu - cu - 2.0 * cu * (u / 2.0).cos() + 2.0;
0.5 * fc / (PI * PI * f * f) * (PI * f / fc).cos().powi(2) / (cu * cu) * bracket
};
if f_hz.abs() < 1.0 {
return 0.75 * fc / (fs * fs) / 8.0;
}
let u = PI * f_hz / (2.0 * fs);
if u.cos().abs() < 1e-6 {
let e = 1e-3 * fs;
return 0.5 * (eval(f_hz - e) + eval(f_hz + e));
}
eval(f_hz)
}
#[cfg(test)]
mod code_tests {
use super::*;
#[test]
fn msequence_period_is_two_pow_n_minus_one() {
assert_eq!(CodeFamily::MaximalLength { n: 10 }.code_length(), 1023);
assert_eq!(CodeFamily::Gold { n: 10 }.code_length(), 1023);
assert_eq!(CodeFamily::MaximalLength { n: 13 }.code_length(), 8191);
}
#[test]
fn msequence_sidelobe_is_inverse_length() {
let f = CodeFamily::MaximalLength { n: 10 };
assert!((f.max_autocorr_sidelobe().unwrap() - 1.0 / 1023.0).abs() < 1e-15);
assert!((f.peak_to_sidelobe_db().unwrap() - 60.197).abs() < 0.01);
}
#[test]
fn gps_ca_gold_crosscorr_matches_textbook() {
let f = CodeFamily::Gold { n: 10 };
let xc = f.max_crosscorr().unwrap();
assert!((xc - 65.0 / 1023.0).abs() < 1e-15, "xc {xc}");
let db = 20.0 * xc.log10();
assert!(
(db - (-23.94)).abs() < 0.1,
"GPS C/A Gold cross-corr {db:.2} dB, want ≈ −23.9 dB"
);
}
#[test]
fn gold_family_size_is_length_plus_two() {
assert_eq!(CodeFamily::Gold { n: 10 }.family_size(), Some(1025));
}
#[test]
fn msequence_count_degree_10() {
assert_eq!(CodeFamily::MaximalLength { n: 10 }.family_size(), Some(60));
assert_eq!(euler_phi(1023), 600);
}
#[test]
fn gold_existence_condition() {
assert!(CodeFamily::Gold { n: 10 }.gold_codes_exist()); assert!(CodeFamily::Gold { n: 11 }.gold_codes_exist()); assert!(!CodeFamily::Gold { n: 8 }.gold_codes_exist()); assert!(!CodeFamily::Gold { n: 12 }.gold_codes_exist());
}
#[test]
fn invalid_gold_reports_no_guarantee() {
for n in [8u32, 12, 16] {
let g = CodeFamily::Gold { n };
assert!(!g.gold_codes_exist(), "n={n} should have no Gold set");
assert_eq!(g.max_crosscorr(), None, "n={n} cross-corr must be None");
assert_eq!(
g.max_autocorr_sidelobe(),
None,
"n={n} sidelobe must be None"
);
assert_eq!(g.family_size(), None, "n={n} family size must be None");
assert_eq!(g.peak_to_sidelobe_db(), None);
}
}
#[test]
fn degenerate_inputs_do_not_panic() {
assert_eq!(CodeFamily::MaximalLength { n: 0 }.family_size(), None);
assert_eq!(CodeFamily::MaximalLength { n: 1 }.max_crosscorr(), None);
assert_eq!(CodeFamily::MaximalLength { n: 40 }.family_size(), None); assert!(range_ambiguity_m(0.0, 1023).is_nan());
assert!(range_ambiguity_m(-1.0, 1023).is_nan());
assert_eq!(code_length_for_ambiguity(1.023e6, f64::NAN), 0);
assert_eq!(code_length_for_ambiguity(0.0, 1.0e5), 0);
assert_eq!(code_length_for_ambiguity(1.023e6, -5.0), 0);
}
#[test]
fn longer_code_has_cleaner_peak() {
let short = CodeFamily::MaximalLength { n: 7 }
.peak_to_sidelobe_db()
.unwrap();
let long = CodeFamily::MaximalLength { n: 13 }
.peak_to_sidelobe_db()
.unwrap();
assert!(
long > short,
"longer code {long:.1} should beat {short:.1} dB"
);
let gold = CodeFamily::Gold { n: 10 }.max_autocorr_sidelobe().unwrap();
let mseq = CodeFamily::MaximalLength { n: 10 }
.max_autocorr_sidelobe()
.unwrap();
assert!(gold > mseq, "Gold sidelobe {gold:.4} > m-seq {mseq:.4}");
}
#[test]
fn ambiguity_forward_and_independent_inverse_anchors() {
let rc = 1.023e6; let du = range_ambiguity_m(rc, 1023);
assert!((du - 149_896.229).abs() < 1.0, "C/A ambiguity {du:.1} m");
let l = code_length_for_ambiguity(5.115e6, 3.0e5);
assert_eq!(l, 10_238, "hand-computed inverse anchor");
assert_eq!(code_length_for_ambiguity(rc, du), 1023);
assert!(
code_length_for_ambiguity(rc, 4.0e8) > 1023,
"deep-space needs longer code"
);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bpsk_psd_is_unit_area() {
let m = Modulation::BpskR { n: 1.0 };
let area = integrate(20.0 * m.chip_rate_hz(), 40_000, |f| m.psd(f));
assert!((area - 1.0).abs() < 0.02, "BPSK ∫G df = {area}, want ≈1");
}
#[test]
fn boc11_psd_is_unit_area() {
let m = Modulation::BocSin { m: 1.0, n: 1.0 };
let area = integrate(24.0 * m.chip_rate_hz(), 60_000, |f| m.psd(f));
assert!(
(area - 1.0).abs() < 0.03,
"BOC(1,1) ∫G df = {area}, want ≈1"
);
}
#[test]
fn bpsk_peaks_at_carrier_boc_splits() {
let bpsk = Modulation::BpskR { n: 1.0 };
let boc = Modulation::BocSin { m: 1.0, n: 1.0 };
assert!(bpsk.psd(0.0) > bpsk.psd(F0_HZ), "BPSK should peak at f=0");
assert!(
boc.psd(0.0) < boc.psd(F0_HZ),
"BOC should null at f=0, peak near ±f_s"
);
}
#[test]
fn bpsk_self_ssc_matches_closed_form() {
let m = Modulation::BpskR { n: 1.0 };
let rc = m.chip_rate_hz();
let kappa = spectral_separation_coeff(&m, &m, 24.0 * rc);
let closed = 2.0 / (3.0 * rc);
let rel = (kappa - closed).abs() / closed;
assert!(
rel < 0.03,
"BPSK self-SSC {kappa:.3e} vs 2/(3Rc) {closed:.3e}, rel {rel:.3}"
);
}
#[test]
fn boc_separates_from_bpsk() {
let bpsk = Modulation::BpskR { n: 1.0 };
let boc = Modulation::BocSin { m: 1.0, n: 1.0 };
let band = 24.0 * F0_HZ;
let self_ssc = spectral_separation_coeff(&bpsk, &bpsk, band);
let cross = spectral_separation_coeff(&bpsk, &boc, band);
assert!(
cross < self_ssc,
"BOC↔BPSK SSC {cross:.3e} should be < BPSK self {self_ssc:.3e}"
);
}
#[test]
fn boc_has_larger_gabor_bandwidth() {
let bpsk = Modulation::BpskR { n: 1.0 };
let boc = Modulation::BocSin { m: 1.0, n: 1.0 };
let band = 24.0 * F0_HZ;
assert!(rms_bandwidth_hz(&boc, band) > rms_bandwidth_hz(&bpsk, band));
}
#[test]
fn dll_jitter_ca_is_submetre_at_45dbhz() {
let sigma_chips = dll_code_jitter_chips(45.0, 1.0, 0.5, 0.02);
let metres = sigma_chips * 299_792_458.0 / F0_HZ;
assert!(
metres > 0.1 && metres < 2.0,
"C/A DLL jitter {metres:.2} m, want 0.1–2 m"
);
}
#[test]
fn dll_jitter_decreases_with_cn0() {
let lo = dll_code_jitter_chips(35.0, 1.0, 0.5, 0.02);
let hi = dll_code_jitter_chips(50.0, 1.0, 0.5, 0.02);
assert!(hi < lo, "higher C/N0 must track tighter ({hi} !< {lo})");
}
#[test]
fn q_from_white_noise_ssc_is_order_unity() {
let bpsk = Modulation::BpskR { n: 1.0 };
let rc = bpsk.chip_rate_hz();
let kappa = ssc_vs_white(&bpsk, 2.0 * rc);
let q = q_from_ssc(kappa, rc);
assert!(
q > 0.3 && q < 3.0,
"PSD-derived Q {q:.3} should be order unity"
);
}
#[test]
fn narrow_correlator_suppresses_multipath() {
let (max_wide, min_wide) = multipath_error_envelope_chips(1.0, 6.0, 0.3);
let (max_narrow, min_narrow) = multipath_error_envelope_chips(0.1, 6.0, 0.3);
let wide = max_wide.abs().max(min_wide.abs());
let narrow = max_narrow.abs().max(min_narrow.abs());
assert!(
narrow < wide,
"narrow correlator {narrow:.4} should beat wide {wide:.4}"
);
}
#[test]
fn multipath_vanishes_without_reflection() {
let (mx, mn) = multipath_error_envelope_chips(0.5, 60.0, 0.3);
assert!(
mx.abs() < 1e-3 && mn.abs() < 1e-3,
"no-multipath error should vanish"
);
}
#[test]
fn multipath_envelope_straddles_zero() {
let (mx, mn) = multipath_error_envelope_chips(1.0, 6.0, 0.4);
assert!(
mx.abs() > 1e-3,
"expected a non-trivial multipath bias, got {mx}"
);
assert!(
mx * mn < 0.0,
"in/anti-phase should straddle zero ({mx},{mn})"
);
}
}
#[cfg(test)]
mod band_limited_tests {
use super::*;
#[test]
fn sine_integral_matches_tables_and_its_limit() {
assert!((sine_integral(1.0) - 0.946_083_070_367_183).abs() < 1e-12);
assert!((sine_integral(PI) - 1.851_937_051_982_466).abs() < 1e-12);
assert!((sine_integral(2.0 * PI) - 1.418_151_576_132_628).abs() < 1e-12);
assert!((sine_integral(-1.0) + 0.946_083_070_367_183).abs() < 1e-12);
for x in [19.9, 20.1, 35.0, 150.0] {
let q = simpson(
0.0,
x,
200_000,
|t| if t == 0.0 { 1.0 } else { t.sin() / t },
);
assert!((sine_integral(x) - q).abs() < 1e-8, "Si({x})");
}
assert!((sine_integral(1e6) - PI / 2.0).abs() < 1e-5);
}
#[test]
fn bpsk_power_in_band_closed_form_matches_textbook_and_numeric() {
for n in [1.0 / 3.0, 1.0, 5.0, 10.0] {
let rc = n * F0_HZ;
let eta = bpsk_power_in_band_closed_form(rc, 2.0 * rc);
assert!((eta - 0.902_8).abs() < 1e-4, "BPSK({n}) main lobe {eta}");
}
let m = Modulation::BpskR { n: 10.0 };
for b in [10.0e6, 15.0e6, 20.0e6, 51.15e6] {
let num = simpson(-b / 2.0, b / 2.0, 20_000, |f| m.psd(f));
let cf = bpsk_power_in_band_closed_form(m.chip_rate_hz(), b);
assert!((num - cf).abs() < 1e-7, "B {b}: {num} vs {cf}");
}
}
#[test]
fn bpsk_gabor_closed_form_matches_numeric_and_asymptote() {
for (n, b) in [
(10.0, 20.0e6),
(5.0, 10.0e6),
(1.0, 24.0e6),
(1.0 / 3.0, 15.0e6),
] {
let m = Modulation::BpskR { n };
let num = rms_bandwidth_hz(&m, b);
let cf = bpsk_gabor_bandwidth_closed_form_hz(m.chip_rate_hz(), b);
assert!(
(num / cf - 1.0).abs() < 1e-4,
"BPSK({n}) in {b}: {num} vs {cf}"
);
}
let rc = F0_HZ;
let b = 2000.0 * rc;
let asym = (b * rc / (2.0 * PI * PI)).sqrt();
let cf = bpsk_gabor_bandwidth_closed_form_hz(rc, b);
assert!((cf / asym - 1.0).abs() < 2e-3, "{cf} vs {asym}");
}
#[test]
fn bandlimited_dll_reduces_to_the_textbook_forms() {
let m = Modulation::BpskR { n: 1.0 };
let rc = F0_HZ;
let psd = |f: f64| m.psd(f);
let s = dll_jitter_bandlimited_s(
psd,
400.0 * rc,
rc,
45.0,
1.0,
0.02,
1.0 / rc,
EarlyLate::Coherent,
);
let chips = s * rc;
assert!(
(chips - 0.003_956_4).abs() < 0.0025 * 0.003_956_4,
"coherent {chips}"
);
assert!((chips * C_LIGHT_M_PER_S / rc - 1.1594).abs() < 0.012);
let s_nc = dll_jitter_bandlimited_s(
psd,
400.0 * rc,
rc,
45.0,
1.0,
0.02,
0.5 / rc,
EarlyLate::NonCoherent,
);
let kh = dll_code_jitter_chips(45.0, 1.0, 0.5, 0.02) * 0.99f64.sqrt();
assert!((s_nc * rc / kh - 1.0).abs() < 0.01, "{} vs {kh}", s_nc * rc);
let b = 2.0 * rc;
let tiny =
dll_jitter_bandlimited_s(psd, b, rc, 45.0, 1.0, 0.02, 0.005 / rc, EarlyLate::Coherent);
let lim = dll_jitter_small_spacing_limit_s(psd, b, rc, 45.0, 1.0, 0.02);
assert!((tiny / lim - 1.0).abs() < 1e-3, "{tiny} vs {lim}");
let eta = bpsk_power_in_band_closed_form(rc, b);
let beta = bpsk_gabor_bandwidth_closed_form_hz(rc, b);
let cf = (0.99 / ((2.0 * PI).powi(2) * 10f64.powf(4.5) * eta * beta * beta)).sqrt();
assert!((lim / cf - 1.0).abs() < 1e-5, "{lim} vs {cf}");
let wide = dll_jitter_bandlimited_s(
psd,
400.0 * rc,
rc,
45.0,
1.0,
0.02,
0.1 / rc,
EarlyLate::Coherent,
);
let narrow =
dll_jitter_bandlimited_s(psd, b, rc, 45.0, 1.0, 0.02, 0.1 / rc, EarlyLate::Coherent);
assert!(narrow > wide);
}
#[test]
fn offset_bpsk_ssc_matches_parseval_closed_form() {
let m = Modulation::BpskR { n: 10.0 };
let rc = m.chip_rate_hz();
for off in [0.0, 0.5 * rc, 1.5 * rc, 15.345e6, 3.2 * rc] {
let num = spectral_separation_coeff_offset(&m, &m, off, 200.0 * rc);
let cf = bpsk_offset_ssc_closed_form(rc, off);
assert!(
(10.0 * (num / cf).log10()).abs() < 0.01,
"offset {off}: {num:e} vs {cf:e}"
);
}
let z = 10.0 * bpsk_offset_ssc_closed_form(rc, 0.0).log10();
assert!((z - (-71.86)).abs() < 0.01, "{z}");
}
#[test]
fn altboc_psd_is_unit_area_with_lobes_at_e5a_and_e5b() {
let half = 400e6;
let body = simpson(-half, half, 400_000, altboc_15_10_psd);
let mean_f2g = simpson(half - 61.38e6, half, 20_000, |f| {
f * f * altboc_15_10_psd(f)
}) / 61.38e6;
let tail = 2.0 * mean_f2g / half;
let area = body + tail;
assert!((area - 1.0).abs() < 5e-3, "AltBOC area {area}");
let pk = (0..30_000)
.map(|k| k as f64 * 1e3)
.max_by(|a, b| {
altboc_15_10_psd(*a)
.partial_cmp(&altboc_15_10_psd(*b))
.unwrap()
})
.unwrap();
assert!((pk - 15.345e6).abs() < 0.3e6, "AltBOC peak at {pk}");
assert!(altboc_15_10_psd(0.0) < 0.2 * altboc_15_10_psd(pk));
}
#[test]
fn modulation_labels_round_trip() {
for m in [
Modulation::BpskR { n: 1.0 / 3.0 },
Modulation::BpskR { n: 10.0 },
Modulation::BocSin { m: 1.0, n: 1.0 },
Modulation::Mboc { p: 1.0 / 11.0 },
] {
let back = parse_modulation(&m.label()).unwrap();
assert!(
(back.chip_rate_hz() - m.chip_rate_hz()).abs() < 1e-6,
"{}",
m.label()
);
}
assert_eq!(Modulation::BpskR { n: 1.0 / 3.0 }.label(), "BPSK(1/3)");
assert!(parse_modulation("QPSK(1)").is_err());
assert!(parse_modulation("BPSK(-1)").is_err());
}
}