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)
}
fn lobe_width_hz(m: &Modulation) -> f64 {
m.chip_rate_hz()
}
pub fn power_in_band(m: &Modulation, band_hz: f64) -> f64 {
let half = band_hz / 2.0;
2.0 * simpson(0.0, half, panels_for(half, lobe_width_hz(m)), |f| m.psd(f))
}
pub fn psd_maximum(m: &Modulation, band_hz: f64) -> (f64, f64) {
let f = crate::spectrum::psd_peak_hz(m, band_hz / 2.0);
let g = m.psd(f);
if m.psd(0.0) >= g {
(0.0, m.psd(0.0))
} else {
(f, g)
}
}
pub fn fractional_power_bandwidth_hz(m: &Modulation, fraction: f64, norm_band_hz: f64) -> f64 {
let total = power_in_band(m, norm_band_hz);
let target = fraction.clamp(0.0, 1.0) * total;
let (mut lo, mut hi) = (0.0, norm_band_hz);
for _ in 0..60 {
let mid = 0.5 * (lo + hi);
if power_in_band(m, mid) < target {
lo = mid;
} else {
hi = mid;
}
}
0.5 * (lo + hi)
}
pub fn equivalent_rectangular_bandwidth_hz(m: &Modulation, band_hz: f64) -> f64 {
power_in_band(m, band_hz) / psd_maximum(m, band_hz).1
}
pub fn spectral_separation_coeff_band_limited(
sig: &Modulation,
intf: &Modulation,
rx_band_hz: f64,
tx_band_hz: f64,
) -> f64 {
let half = rx_band_hz / 2.0;
let lobe = lobe_width_hz(sig).min(lobe_width_hz(intf));
let overlap = 2.0
* simpson(0.0, half, panels_for(half, lobe), |f| {
sig.psd(f) * intf.psd(f)
});
if tx_band_hz.is_finite() {
overlap / power_in_band(intf, tx_band_hz)
} else {
overlap
}
}
#[derive(Clone, Debug)]
pub struct BandLimitedAcf {
pub tau_step_s: f64,
pub values: Vec<f64>,
}
impl BandLimitedAcf {
pub fn tau_max_s(&self) -> f64 {
(self.values.len() - 1) as f64 * self.tau_step_s
}
pub fn at(&self, tau_s: f64) -> f64 {
let x = tau_s.abs() / self.tau_step_s;
let k = x.floor() as usize;
if k + 1 >= self.values.len() {
return if k < self.values.len() {
self.values[k]
} else {
0.0
};
}
let w = x - k as f64;
self.values[k] * (1.0 - w) + self.values[k + 1] * w
}
}
pub fn band_limited_acf(
psd: impl Fn(f64) -> f64,
band_hz: f64,
lobe_hz: f64,
tau_max_s: f64,
tau_step_s: f64,
) -> BandLimitedAcf {
let half = band_hz / 2.0;
let n_tau = (tau_max_s / tau_step_s).ceil() as usize + 1;
let by_lobe = panels_for(half, lobe_hz);
let by_osc = (half * tau_max_s * 40.0).ceil() as usize;
let mut n = by_lobe.max(by_osc).max(2_000);
if n % 2 == 1 {
n += 1;
}
let h = half / n as f64;
let mut acc = vec![0.0f64; n_tau];
for j in 0..=n {
let f = j as f64 * h;
let w = if j == 0 || j == n {
1.0
} else if j % 2 == 1 {
4.0
} else {
2.0
};
let g = w * psd(f);
if g == 0.0 {
continue;
}
let theta = 2.0 * PI * f * tau_step_s;
let two_c = 2.0 * theta.cos();
let (mut c_prev, mut c) = (theta.cos(), 1.0);
for a in acc.iter_mut() {
*a += g * c;
let next = two_c * c - c_prev;
c_prev = c;
c = next;
}
}
let r0 = acc[0];
BandLimitedAcf {
tau_step_s,
values: acc.iter().map(|v| v / r0).collect(),
}
}
pub fn modulation_acf(
m: &Modulation,
band_hz: f64,
tau_max_s: f64,
tau_step_s: f64,
) -> BandLimitedAcf {
band_limited_acf(
|f| m.psd(f),
band_hz,
lobe_width_hz(m),
tau_max_s,
tau_step_s,
)
}
fn parabolic_peak(acf: &BandLimitedAcf, k: usize, g: impl Fn(f64) -> f64) -> (f64, f64) {
let (y0, y1, y2) = (g(acf.values[k - 1]), g(acf.values[k]), g(acf.values[k + 1]));
let den = y0 - 2.0 * y1 + y2;
let dx = if den.abs() > 0.0 {
(0.5 * (y0 - y2) / den).clamp(-1.0, 1.0)
} else {
0.0
};
let tau = (k as f64 + dx) * acf.tau_step_s;
(tau, y1 - 0.25 * (y0 - y2) * dx)
}
pub fn first_acf_side_lobe(acf: &BandLimitedAcf, chip_s: f64) -> Option<(f64, f64)> {
let k_end = ((chip_s / acf.tau_step_s).floor() as usize).min(acf.values.len() - 2);
let v = &acf.values;
let crossing = (1..=k_end).find(|&k| v[k] * v[k - 1] <= 0.0 && v[k - 1] != 0.0)?;
for k in crossing.max(1)..k_end {
let (a, b, c) = (v[k - 1].powi(2), v[k].powi(2), v[k + 1].powi(2));
if b >= a && b > c {
return Some(parabolic_peak(acf, k, |x| x * x));
}
}
None
}
pub fn acf_extrema(acf: &BandLimitedAcf, tau_end_s: f64, min_abs: f64) -> Vec<(f64, f64)> {
let k_end = ((tau_end_s / acf.tau_step_s).floor() as usize).min(acf.values.len() - 2);
let v = &acf.values;
let mut out = Vec::new();
for k in 1..k_end {
let is_max = v[k] >= v[k - 1] && v[k] > v[k + 1];
let is_min = v[k] <= v[k - 1] && v[k] < v[k + 1];
if (is_max || is_min) && v[k].abs() >= min_abs {
out.push(parabolic_peak(acf, k, |x| x));
}
}
out
}
pub fn acf_first_zero_s(acf: &BandLimitedAcf) -> Option<f64> {
let v = &acf.values;
(1..v.len()).find(|&k| v[k] <= 0.0).map(|k| {
let (a, b) = (v[k - 1], v[k]);
let w = if a != b { a / (a - b) } else { 0.0 };
((k - 1) as f64 + w) * acf.tau_step_s
})
}
pub fn nelp_multipath_bias_s(
acf: &BandLimitedAcf,
spacing_s: f64,
gamma: f64,
delay_s: f64,
cos_phase: f64,
) -> Option<f64> {
let half = spacing_s / 2.0;
let s = |eps: f64| -> f64 {
let c2 = |x: f64| {
let a = acf.at(x);
let b = acf.at(x - delay_s);
a * a + gamma * gamma * b * b + 2.0 * gamma * cos_phase * a * b
};
c2(eps - half) - c2(eps + half)
};
let w = half + delay_s + acf.tau_step_s;
let step = 0.25 * acf.tau_step_s;
let k_max = (w / step).ceil() as usize;
let refine = |mut lo: f64, mut hi: f64| -> f64 {
for _ in 0..60 {
let mid = 0.5 * (lo + hi);
if s(mid) < 0.0 {
lo = mid;
} else {
hi = mid;
}
}
0.5 * (lo + hi)
};
let s0 = s(0.0);
let (mut right_prev, mut left_prev) = (s0, s0);
for k in 0..k_max {
let xr = (k + 1) as f64 * step;
let sr = s(xr);
let sl = s(-xr);
let right = (right_prev < 0.0 && sr >= 0.0).then(|| refine(xr - step, xr));
let left = (sl < 0.0 && left_prev >= 0.0).then(|| refine(-xr, -xr + step));
match (left, right) {
(Some(l), Some(r)) => return Some(if l.abs() < r.abs() { l } else { r }),
(Some(l), None) => return Some(l),
(None, Some(r)) => return Some(r),
(None, None) => {}
}
right_prev = sr;
left_prev = sl;
}
None
}
pub fn nelp_worst_case_multipath_bias_s(
acf: &BandLimitedAcf,
spacing_s: f64,
gamma: f64,
delays_s: &[f64],
n_phase: usize,
) -> f64 {
let n_phase = n_phase.max(2);
let mut worst = 0.0f64;
for &d in delays_s {
for i in 0..n_phase {
let cp = -1.0 + 2.0 * i as f64 / (n_phase - 1) as f64;
if let Some(b) = nelp_multipath_bias_s(acf, spacing_s, gamma, d, cp) {
worst = worst.max(b.abs());
}
}
}
worst
}
#[cfg(test)]
mod band_limited_measure_tests {
use super::*;
#[test]
fn power_in_band_matches_the_bpsk_closed_form() {
for (n, band) in [(1.0, 2.046e6), (2.0, 7.0e6), (10.0, 24.0e6)] {
let m = Modulation::BpskR { n };
let num = power_in_band(&m, band);
let closed = bpsk_power_in_band_closed_form(m.chip_rate_hz(), band);
assert!((num - closed).abs() < 1e-6, "{n} {band}: {num} vs {closed}");
}
}
#[test]
fn fractional_bandwidth_inverts_power_in_band() {
let m = Modulation::BocSin { m: 1.0, n: 1.0 };
let norm = 40.0e6;
let b = 5.3e6;
let frac = power_in_band(&m, b) / power_in_band(&m, norm);
let got = fractional_power_bandwidth_hz(&m, frac, norm);
assert!((got - b).abs() < 1.0, "{got} vs {b}");
}
#[test]
fn bpsk_maximum_is_at_the_carrier_and_boc11_at_its_lobe() {
let (f, g) = psd_maximum(&Modulation::BpskR { n: 1.0 }, 24e6);
assert_eq!(f, 0.0);
assert!((g - 1.0 / F0_HZ).abs() < 1e-15);
let (f, _) = psd_maximum(&Modulation::BocSin { m: 1.0, n: 1.0 }, 24e6);
assert!((f - 0.7590e6).abs() < 1e3, "{f}");
}
#[test]
fn band_limited_ssc_reduces_to_the_unlimited_one() {
let a = Modulation::BpskR { n: 1.0 };
let b = Modulation::BocSin { m: 1.0, n: 1.0 };
let k1 = spectral_separation_coeff_band_limited(&a, &b, 24e6, f64::INFINITY);
let k0 = spectral_separation_coeff(&a, &b, 24e6);
assert!((k1 / k0 - 1.0).abs() < 1e-4, "{k1} vs {k0}");
}
#[test]
fn wide_band_bpsk_correlation_is_the_triangle() {
let m = Modulation::BpskR { n: 1.0 };
let tc = 1.0 / m.chip_rate_hz();
let acf = modulation_acf(&m, 400e6, 1.5 * tc, 1e-9);
for x in [0.1, 0.25, 0.5, 0.75] {
let r = acf.at(x * tc);
assert!((r - (1.0 - x)).abs() < 5e-3, "R({x} Tc) = {r}");
}
assert!(first_acf_side_lobe(&acf, tc).is_none());
let z = acf_first_zero_s(&acf).unwrap();
assert!((z / tc - 1.0).abs() < 0.02, "first zero {z}");
}
#[test]
fn wide_band_boc11_has_its_minus_half_side_lobe() {
let m = Modulation::BocSin { m: 1.0, n: 1.0 };
let tc = 1.0 / m.chip_rate_hz();
let acf = modulation_acf(&m, 400e6, 1.2 * tc, 0.5e-9);
let (tau, r2) = first_acf_side_lobe(&acf, tc).expect("side lobe");
assert!((tau / tc - 0.5).abs() < 0.01, "{tau}");
assert!((r2 - 0.25).abs() < 0.01, "{r2}");
}
#[test]
fn nelp_in_phase_bias_equals_the_coherent_envelope_on_a_wide_band() {
let m = Modulation::BpskR { n: 1.0 };
let tc = 1.0 / m.chip_rate_hz();
let acf = modulation_acf(&m, 400e6, 2.0 * tc, 0.5e-9);
let gamma = 10f64.powf(-6.0 / 20.0);
for d_chips in [0.1, 0.3, 0.6] {
let b = nelp_multipath_bias_s(&acf, 0.5 * tc, gamma, d_chips * tc, 1.0).unwrap();
let (coh, _) = multipath_error_envelope_chips(0.5, 6.0, d_chips);
assert!(
(b / tc - coh).abs() < 3e-3,
"d {d_chips}: {} vs {coh}",
b / tc
);
}
let none = nelp_multipath_bias_s(&acf, 0.5 * tc, 0.0, 0.3 * tc, 1.0).unwrap();
assert!(none.abs() < 1e-3 * tc, "no reflection, no bias: {none}");
}
}
#[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());
}
}