use super::{Cf64, IqError, IqSource, SampleSpec, SpreadingCode};
use crate::acquisition::{pd_square_law, threshold_for_pfa};
use crate::portable_math::{FftPlan, PortableFloat};
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct AcqConfig {
pub coherent_periods: usize,
pub noncoherent: usize,
pub doppler_max_hz: f64,
pub doppler_step_hz: f64,
pub pfa: f64,
}
pub const AUTO_COHERENT_S: f64 = 4.0e-3;
pub fn auto_coherent_periods(code_period_s: f64) -> usize {
if !(code_period_s.is_finite() && code_period_s > 0.0) {
return 1;
}
((AUTO_COHERENT_S / code_period_s - 1e-9).ceil() as usize).max(1)
}
pub const PULL_IN_STEP_MARGIN: f64 = 0.2;
pub fn default_step_hz(code_period_s: f64, n_coh: usize, t_track_s: f64) -> f64 {
let textbook = 2.0 / (3.0 * n_coh.max(1) as f64 * code_period_s);
if t_track_s.is_finite() && t_track_s > 0.0 {
textbook.min(PULL_IN_STEP_MARGIN / t_track_s)
} else {
textbook
}
}
impl AcqConfig {
pub fn doppler_bins(&self) -> Vec<f64> {
let n = (self.doppler_max_hz / self.doppler_step_hz + 1e-9).floor() as i64;
(-n..=n).map(|j| j as f64 * self.doppler_step_hz).collect()
}
}
pub fn samples_per_period(spec: &SampleSpec, code: &dyn SpreadingCode) -> Result<usize, String> {
let spc = spec.fs_hz * code.period_s();
if !(spc.is_finite() && spc >= 2.0) || (spc - spc.round()).abs() > 1e-6 {
return Err(format!(
"{} Hz does not hold a whole number of samples per {} period ({spc} samples)",
spec.fs_hz,
code.name()
));
}
Ok(spc.round() as usize)
}
pub fn samples_needed(
spec: &SampleSpec,
code: &dyn SpreadingCode,
cfg: &AcqConfig,
) -> Result<usize, String> {
Ok(samples_per_period(spec, code)? * cfg.coherent_periods * cfg.noncoherent)
}
#[derive(Clone, Debug, PartialEq)]
pub struct AcqResult {
pub code_name: String,
pub doppler_hz: f64,
pub doppler_index: usize,
pub delay_samples: usize,
pub code_phase_chips: f64,
pub statistic: f64,
pub second_peak: f64,
pub peak_ratio: f64,
pub pfa_cell: f64,
pub threshold: f64,
pub acquired: bool,
pub samples_per_period: usize,
pub n_doppler_bins: usize,
pub sample_power: f64,
}
#[derive(Clone, Debug, PartialEq)]
pub struct AcqGrid {
pub result: AcqResult,
pub grid: Vec<Vec<f64>>,
}
struct RowEngine<'a> {
samples: &'a [Cf64],
fs: f64,
base_hz: f64,
spc: usize,
block: usize,
noncoherent: usize,
plan: FftPlan,
code_fft: Vec<(f64, f64)>,
norm: f64,
sigma2: f64,
}
impl<'a> RowEngine<'a> {
fn new(
samples: &'a [Cf64],
spec: &SampleSpec,
code: &dyn SpreadingCode,
cfg: &AcqConfig,
) -> Result<Self, String> {
let spc = samples_per_period(spec, code)?;
let block = spc * cfg.coherent_periods;
let needed = block * cfg.noncoherent;
if samples.len() < needed {
return Err(format!(
"{} samples supplied, {needed} needed",
samples.len()
));
}
let fs = spec.fs_hz;
let base_hz = spec.baseband_hz(code.carrier_hz());
let chips_per_sample = code.chip_rate_hz() / fs;
let plan = FftPlan::new(spc);
let code_fft: Vec<(f64, f64)> = {
let rep: Vec<(f64, f64)> = (0..spc)
.map(|i| (code.value_at(i as f64 * chips_per_sample), 0.0))
.collect();
plan.forward(&rep)
.into_iter()
.map(|(r, i)| (r, -i))
.collect()
};
let power: f64 = samples[..needed]
.iter()
.map(|x| x.re * x.re + x.im * x.im)
.sum();
let sigma2 = power / needed as f64;
if sigma2.is_nan() || sigma2 <= 0.0 {
return Err("the samples carry no power".into());
}
Ok(Self {
samples,
fs,
base_hz,
spc,
block,
noncoherent: cfg.noncoherent,
plan,
code_fft,
norm: 2.0 / (block as f64 * sigma2),
sigma2,
})
}
fn row(&self, d: f64) -> Vec<f64> {
let (spc, block, fs) = (self.spc, self.block, self.fs);
let f = self.base_hz + d;
let mut row = vec![0.0_f64; spc];
for m in 0..self.noncoherent {
let start = m * block;
let mut fold = vec![(0.0_f64, 0.0_f64); spc];
for i in 0..block {
let n = start + i;
let cyc = (f * n as f64 / fs).fract();
let (sn, cs) = (-core::f64::consts::TAU * cyc).psin_cos();
let s = self.samples[n];
let r = &mut fold[i % spc];
r.0 += s.re * cs - s.im * sn;
r.1 += s.re * sn + s.im * cs;
}
let mut y = self.plan.forward(&fold);
drop(fold);
for (v, &(c, e)) in y.iter_mut().zip(&self.code_fft) {
let (a, b) = *v;
*v = (a * c - b * e, a * e + b * c);
}
for (cell, (re, im)) in row.iter_mut().zip(self.plan.inverse_owned(y)) {
*cell += re * re + im * im;
}
}
for cell in &mut row {
*cell *= self.norm;
}
row
}
}
pub(crate) fn power_rows(
samples: &[Cf64],
spec: &SampleSpec,
code: &dyn SpreadingCode,
cfg: &AcqConfig,
dopplers: &[f64],
) -> Result<(Vec<Vec<f64>>, f64), String> {
let eng = RowEngine::new(samples, spec, code, cfg)?;
let grid = dopplers.iter().map(|&d| eng.row(d)).collect();
Ok((grid, eng.sigma2))
}
fn check_cfg(cfg: &AcqConfig) -> Result<(), String> {
if cfg.coherent_periods == 0 || cfg.noncoherent == 0 {
return Err("coherent_periods and noncoherent must be positive".into());
}
if !(cfg.doppler_step_hz > 0.0 && cfg.doppler_max_hz >= 0.0) {
return Err("the Doppler grid needs a positive step and a non-negative maximum".into());
}
if !(cfg.pfa > 0.0 && cfg.pfa < 1.0) {
return Err("pfa must lie in (0, 1)".into());
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn result_from_peak(
code: &dyn SpreadingCode,
cfg: &AcqConfig,
bins: &[f64],
spc: usize,
sigma2: f64,
chips_per_sample: f64,
best: (f64, usize, usize),
peak_row: &[f64],
) -> AcqResult {
let (peak, jb, tb) = best;
let guard = (1.0 / chips_per_sample).ceil() as usize + 1;
let second_peak = peak_row
.iter()
.enumerate()
.filter(|&(t, _)| {
let d = t.abs_diff(tb);
d.min(spc - d) > guard
})
.map(|(_, &v)| v)
.fold(0.0_f64, f64::max);
let n_cells = (spc * bins.len()) as f64;
let pfa_cell = -((-cfg.pfa).pln_1p() / n_cells).pexp_m1();
let threshold = threshold_for_pfa(pfa_cell, cfg.noncoherent as f64);
let len = code.len_chips() as f64;
let code_phase_chips = (-(tb as f64) * chips_per_sample).rem_euclid(len);
AcqResult {
code_name: code.name(),
doppler_hz: bins[jb],
doppler_index: jb,
delay_samples: tb,
code_phase_chips,
statistic: peak,
second_peak,
peak_ratio: if second_peak > 0.0 {
peak / second_peak
} else {
f64::INFINITY
},
pfa_cell,
threshold,
acquired: peak > threshold,
samples_per_period: spc,
n_doppler_bins: bins.len(),
sample_power: sigma2,
}
}
pub fn acquire(
samples: &[Cf64],
spec: &SampleSpec,
code: &dyn SpreadingCode,
cfg: &AcqConfig,
) -> Result<AcqGrid, String> {
check_cfg(cfg)?;
let bins = cfg.doppler_bins();
let chips_per_sample = code.chip_rate_hz() / spec.fs_hz;
let (grid, sigma2) = power_rows(samples, spec, code, cfg, &bins)?;
let spc = samples_per_period(spec, code)?;
let mut best = (f64::NEG_INFINITY, 0usize, 0usize);
for (j, row) in grid.iter().enumerate() {
for (t, &cell) in row.iter().enumerate() {
if cell > best.0 {
best = (cell, j, t);
}
}
}
let result = result_from_peak(
code,
cfg,
&bins,
spc,
sigma2,
chips_per_sample,
best,
&grid[best.1],
);
Ok(AcqGrid { result, grid })
}
pub fn acquire_peak(
samples: &[Cf64],
spec: &SampleSpec,
code: &dyn SpreadingCode,
cfg: &AcqConfig,
) -> Result<AcqResult, String> {
check_cfg(cfg)?;
let bins = cfg.doppler_bins();
let chips_per_sample = code.chip_rate_hz() / spec.fs_hz;
let eng = RowEngine::new(samples, spec, code, cfg)?;
let mut best = (f64::NEG_INFINITY, 0usize, 0usize);
for (j, &d) in bins.iter().enumerate() {
let row = eng.row(d);
for (t, &cell) in row.iter().enumerate() {
if cell > best.0 {
best = (cell, j, t);
}
}
}
let best_row = eng.row(bins[best.1]);
Ok(result_from_peak(
code,
cfg,
&bins,
eng.spc,
eng.sigma2,
chips_per_sample,
best,
&best_row,
))
}
pub fn acquire_source(
src: &mut dyn IqSource,
code: &dyn SpreadingCode,
cfg: &AcqConfig,
) -> Result<AcqGrid, IqError> {
let spec = src.spec();
let needed = samples_needed(&spec, code, cfg).map_err(IqError::Format)?;
let mut buf = vec![Cf64::default(); needed];
let mut got = 0;
while got < needed {
let n = src.read(&mut buf[got..])?;
if n == 0 {
break;
}
got += n;
}
if got < needed {
return Err(IqError::Format(format!(
"stream ended after {got} of {needed} samples"
)));
}
acquire(&buf, &spec, code, cfg).map_err(IqError::Format)
}
pub fn predicted_pd(cfg: &AcqConfig, period_s: f64, cn0_dbhz: f64, pfa_cell: f64) -> f64 {
let m = cfg.noncoherent as f64;
let rho = 10f64.ppowf(cn0_dbhz / 10.0) * cfg.coherent_periods as f64 * period_s;
pd_square_law(threshold_for_pfa(pfa_cell, m), m, rho)
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct CfarConfig {
pub reference_cells: usize,
pub guard_lags: usize,
pub stride_lags: usize,
pub pfa_cell: f64,
}
impl CfarConfig {
pub fn new(
spec: &SampleSpec,
code: &dyn SpreadingCode,
reference_cells: usize,
guard_lags: usize,
pfa_cell: f64,
) -> Self {
Self {
reference_cells,
guard_lags,
stride_lags: (spec.fs_hz / code.chip_rate_hz()).ceil() as usize + 1,
pfa_cell,
}
}
fn check(&self, samples_per_period: usize) -> Result<(), String> {
let n = self.reference_cells;
if n < 2 || n % 2 != 0 {
return Err(format!(
"CFAR reference cells must be even and at least 2 (got {n})"
));
}
if self.stride_lags == 0 {
return Err("CFAR stride must be at least 1 lag".into());
}
if !(self.pfa_cell > 0.0 && self.pfa_cell < 1.0) {
return Err(format!(
"CFAR per-cell Pfa must lie in (0, 1) (got {})",
self.pfa_cell
));
}
let reach = self.guard_lags + (n / 2) * self.stride_lags;
if 2 * reach >= samples_per_period {
return Err(format!(
"CFAR window reaches {reach} lags each side, too wide for a {samples_per_period}-lag period"
));
}
Ok(())
}
fn reference_mean(&self, row: &[f64], lag: usize) -> f64 {
let len = row.len();
let half = self.reference_cells / 2;
let mut sum = 0.0;
for k in 1..=half {
let d = self.guard_lags + k * self.stride_lags;
sum += row[(lag + d) % len] + row[(lag + len - d % len) % len];
}
sum / self.reference_cells as f64
}
}
pub fn ca_cfar_pfa(alpha: f64, n_ref: usize, m: usize) -> f64 {
let nm = (n_ref * m) as f64;
let t = alpha / n_ref as f64;
let mut term = (-nm * t.pln_1p()).pexp();
let mut sum = term;
for k in 1..m {
term *= (nm + k as f64 - 1.0) / k as f64 * t / (1.0 + t);
sum += term;
}
sum
}
pub fn ca_cfar_alpha(pfa: f64, n_ref: usize, m: usize) -> f64 {
let (mut lo, mut hi) = (0.0f64, 1.0f64);
while ca_cfar_pfa(hi, n_ref, m) > pfa {
hi *= 2.0;
}
for _ in 0..200 {
let mid = 0.5 * (lo + hi);
if ca_cfar_pfa(mid, n_ref, m) > pfa {
lo = mid;
} else {
hi = mid;
}
if hi - lo <= 1e-12 * hi {
break;
}
}
0.5 * (lo + hi)
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct CfarResult {
pub alpha: f64,
pub peak_ratio: f64,
pub detected: bool,
pub doppler_index: usize,
pub delay_samples: usize,
}
pub fn ca_cfar(grid: &AcqGrid, cfg: &AcqConfig, cfar: &CfarConfig) -> Result<CfarResult, String> {
let r = &grid.result;
cfar.check(r.samples_per_period)?;
let alpha = ca_cfar_alpha(cfar.pfa_cell, cfar.reference_cells, cfg.noncoherent);
let row = &grid.grid[r.doppler_index];
let peak_ratio = row[r.delay_samples] / cfar.reference_mean(row, r.delay_samples);
Ok(CfarResult {
alpha,
peak_ratio,
detected: peak_ratio > alpha,
doppler_index: r.doppler_index,
delay_samples: r.delay_samples,
})
}
pub fn ca_cfar_cells(
grid: &AcqGrid,
cfg: &AcqConfig,
cfar: &CfarConfig,
) -> Result<Vec<Vec<bool>>, String> {
cfar.check(grid.result.samples_per_period)?;
let alpha = ca_cfar_alpha(cfar.pfa_cell, cfar.reference_cells, cfg.noncoherent);
Ok(grid
.grid
.iter()
.map(|row| {
(0..row.len())
.map(|l| row[l] > alpha * cfar.reference_mean(row, l))
.collect()
})
.collect())
}
#[cfg(test)]
mod tests {
use super::{
auto_coherent_periods, ca_cfar_alpha, ca_cfar_pfa, default_step_hz, PULL_IN_STEP_MARGIN,
};
use crate::portable_math::PortableFloat;
#[test]
fn ca_cfar_closed_form_reduces_to_the_textbook_case_and_inverts() {
for n in [8usize, 16, 32] {
for a in [1.0, 5.0, 12.5] {
let want = (1.0 + a / n as f64).ppowi(-(n as i32));
assert!((ca_cfar_pfa(a, n, 1) - want).abs() <= 1e-15 * want.max(1e-300));
}
}
assert!((ca_cfar_pfa(0.0, 16, 4) - 1.0).abs() < 1e-12);
for m in [1usize, 2, 4] {
for pfa in [1e-2, 1e-3, 1e-6] {
let a = ca_cfar_alpha(pfa, 16, m);
assert!(
(ca_cfar_pfa(a, 16, m) / pfa - 1.0).abs() < 1e-9,
"{m} {pfa}"
);
}
}
}
#[test]
fn the_auto_coherent_length_is_about_four_milliseconds() {
assert_eq!(auto_coherent_periods(1.0e-3), 4); assert_eq!(auto_coherent_periods(4.0e-3), 1); assert_eq!(auto_coherent_periods(10.0e-3), 1); assert_eq!(auto_coherent_periods(100.0e-3), 1); assert_eq!(auto_coherent_periods(1.5e-3), 3);
assert_eq!(auto_coherent_periods(0.0), 1);
}
#[test]
fn the_default_step_leaves_a_residual_inside_the_fll_pull_in() {
use crate::iq::signals::{beidou, galileo, glonass, gps};
use crate::iq::SpreadingCode;
let mut codes = vec![
gps::l1ca(3).unwrap(),
gps::l5_i5(3).unwrap(),
gps::l5_q5(3).unwrap(),
gps::l2c_cm(3).unwrap(),
galileo::e5a_i(3).unwrap(),
galileo::e5a_q(3).unwrap(),
beidou::b1i(6).unwrap(),
beidou::b1c_data(19).unwrap(),
glonass::l1of(1).unwrap(),
];
codes.extend(galileo::e1b(3));
codes.extend(galileo::e1c(3));
let mut worst: f64 = 0.0;
for code in &codes {
for rx in [code.clone(), code.primary_only()] {
let t_code = rx.period_s();
let n = auto_coherent_periods(t_code);
for periods in [1usize, 5] {
let t_track = periods as f64 * t_code;
let step = default_step_hz(t_code, n, t_track);
let pull_in = 1.0 / (4.0 * t_track);
worst = worst.max(step / pull_in);
for (what, residual) in [("half-step", step / 2.0), ("neighbour", step)] {
assert!(
residual <= 0.8 * pull_in + 1e-9,
"{} ({periods} period(s)): {what} residual {residual} Hz vs pull-in {pull_in} Hz",
rx.name()
);
}
}
}
}
assert!(worst > 0.0);
}
#[test]
fn the_cap_changes_nothing_for_the_one_millisecond_codes() {
let t = 1.0e-3;
assert_eq!(default_step_hz(t, 4, t), 2.0 / (3.0 * 4.0 * t));
assert!((default_step_hz(4.0e-3, 1, 4.0e-3) - 50.0).abs() < 1e-9);
assert_eq!(PULL_IN_STEP_MARGIN, 0.2);
assert_eq!(default_step_hz(4.0e-3, 1, 0.0), 2.0 / (3.0 * 4.0e-3));
}
}