use crate::sdr::{
correlate, synth_if, Acquisition, CaCode, Cf64, CorrParams, CorrelatorDump, TrackConfig,
CA_CHIP_RATE_HZ, CA_CODE_LEN,
};
use std::f64::consts::TAU;
#[derive(Clone, Copy, Debug)]
pub struct CaptureConfig {
pub prn: u8,
pub fs_hz: f64,
pub if_hz: f64,
pub auth_doppler_hz: f64,
pub auth_code_phase_chips: f64,
pub n_epochs: usize,
pub track: TrackConfig,
pub noise_std: f64,
pub seed: u64,
pub capture_tol_chips: f64,
pub lock_frac: f64,
}
impl Default for CaptureConfig {
fn default() -> Self {
Self {
prn: 10,
fs_hz: 5_000_000.0,
if_hz: 50_000.0,
auth_doppler_hz: 800.0,
auth_code_phase_chips: 123.0,
n_epochs: 150,
track: TrackConfig::default(),
noise_std: 0.0,
seed: 2024,
capture_tol_chips: 0.35,
lock_frac: 0.3,
}
}
}
#[derive(Clone, Copy, Debug)]
pub struct CaptureOutcome {
pub power_advantage_db: f64,
pub code_offset_chips: f64,
pub carrier_offset_hz: f64,
pub captured: bool,
pub final_code_err_chips: f64,
pub residual_to_spoofer_chips: f64,
pub final_carrier_err_hz: f64,
pub lock_time_s: f64,
pub prompt_lock_ratio: f64,
}
#[inline]
fn signed_wrap_chips(dx: f64) -> f64 {
let n = CA_CODE_LEN as f64;
let m = dx.rem_euclid(n);
if m > n / 2.0 {
m - n
} else {
m
}
}
struct TrackTrace {
dumps: Vec<CorrelatorDump>,
code_phase: Vec<f64>,
carrier_freq: Vec<f64>,
spe: usize,
epoch_dur: f64,
}
fn track_traced(
iq: &[Cf64],
code: &CaCode,
acq: &Acquisition,
fs_hz: f64,
if_hz: f64,
cfg: &TrackConfig,
n_epochs: usize,
) -> TrackTrace {
let spe = (fs_hz / 1000.0).round() as usize;
let epoch_dur = if spe == 0 { 0.0 } else { spe as f64 / fs_hz };
let mut code_phase = acq.code_phase_chips;
let mut carrier_freq = if_hz + acq.doppler_hz;
let mut carrier_phase = 0.0_f64;
let mut dumps = Vec::with_capacity(n_epochs);
let mut cp_hist = Vec::with_capacity(n_epochs);
let mut cf_hist = Vec::with_capacity(n_epochs);
if spe == 0 {
return TrackTrace {
dumps,
code_phase: cp_hist,
carrier_freq: cf_hist,
spe,
epoch_dur,
};
}
for e in 0..n_epochs {
let start = e * spe;
let end = start + spe;
if end > iq.len() {
break;
}
cp_hist.push(code_phase);
cf_hist.push(carrier_freq);
let block = &iq[start..end];
let base = CorrParams {
fs_hz,
carrier_freq_hz: carrier_freq,
carrier_phase_rad: carrier_phase,
code_rate_hz: CA_CHIP_RATE_HZ,
code_phase_chips: code_phase,
corr_spacing_chips: cfg.dll_spacing_chips,
};
let loop_corr = correlate(block, code, &base);
let mon = correlate(
block,
code,
&CorrParams {
corr_spacing_chips: cfg.sqm_spacing_chips,
..base
},
);
dumps.push(CorrelatorDump {
epoch_s: e as f64 * epoch_dur,
prn: code.prn,
early: mon.early,
prompt: mon.prompt,
late: mon.late,
});
let (em, lm) = (loop_corr.early.abs(), loop_corr.late.abs());
let dll = if em + lm > 0.0 {
0.5 * (em - lm) / (em + lm)
} else {
0.0
};
let pll = if loop_corr.prompt.re != 0.0 {
(loop_corr.prompt.im / loop_corr.prompt.re).atan()
} else {
0.0
};
let freq_err = pll / (TAU * epoch_dur);
carrier_freq += cfg.pll_freq_gain * freq_err;
code_phase += CA_CHIP_RATE_HZ * epoch_dur + cfg.dll_gain * dll;
code_phase = code_phase.rem_euclid(CA_CODE_LEN as f64);
carrier_phase += TAU * carrier_freq * epoch_dur + cfg.pll_phase_gain * pll;
carrier_phase = carrier_phase.rem_euclid(TAU);
}
TrackTrace {
dumps,
code_phase: cp_hist,
carrier_freq: cf_hist,
spe,
epoch_dur,
}
}
fn build_composite(
cfg: &CaptureConfig,
code: &CaCode,
power_advantage_db: f64,
code_offset_chips: f64,
carrier_offset_hz: f64,
n_samples: usize,
) -> Vec<Cf64> {
let auth_carrier = cfg.if_hz + cfg.auth_doppler_hz;
let authentic = synth_if(
code,
cfg.fs_hz,
auth_carrier,
CA_CHIP_RATE_HZ,
cfg.auth_code_phase_chips,
1.0,
n_samples,
cfg.noise_std,
cfg.seed,
);
let spoof_amp = 10f64.powf(power_advantage_db / 20.0);
let spoofer = synth_if(
code,
cfg.fs_hz,
auth_carrier + carrier_offset_hz,
CA_CHIP_RATE_HZ,
cfg.auth_code_phase_chips + code_offset_chips,
spoof_amp,
n_samples,
0.0,
cfg.seed.wrapping_add(1),
);
authentic
.iter()
.zip(&spoofer)
.map(|(a, s)| Cf64::new(a.re + s.re, a.im + s.im))
.collect()
}
pub fn run_capture(
cfg: &CaptureConfig,
power_advantage_db: f64,
code_offset_chips: f64,
carrier_offset_hz: f64,
) -> CaptureOutcome {
let code = match CaCode::new(cfg.prn) {
Some(c) => c,
None => {
return CaptureOutcome {
power_advantage_db,
code_offset_chips,
carrier_offset_hz,
captured: false,
final_code_err_chips: f64::NAN,
residual_to_spoofer_chips: f64::NAN,
final_carrier_err_hz: f64::NAN,
lock_time_s: f64::NAN,
prompt_lock_ratio: 0.0,
};
}
};
let spe = (cfg.fs_hz / 1000.0).round() as usize;
let n_samples = spe.saturating_mul(cfg.n_epochs);
let iq = build_composite(
cfg,
&code,
power_advantage_db,
code_offset_chips,
carrier_offset_hz,
n_samples,
);
let acq = Acquisition {
prn: cfg.prn,
code_phase_chips: cfg.auth_code_phase_chips,
doppler_hz: cfg.auth_doppler_hz,
peak_ratio: f64::INFINITY,
acquired: true,
};
let trace = track_traced(
&iq,
&code,
&acq,
cfg.fs_hz,
cfg.if_hz,
&cfg.track,
cfg.n_epochs,
);
let n = trace.code_phase.len();
if n == 0 {
return CaptureOutcome {
power_advantage_db,
code_offset_chips,
carrier_offset_hz,
captured: false,
final_code_err_chips: f64::NAN,
residual_to_spoofer_chips: f64::NAN,
final_carrier_err_hz: f64::NAN,
lock_time_s: f64::NAN,
prompt_lock_ratio: 0.0,
};
}
let true_phase = |e: usize, base_offset: f64| -> f64 {
let t = (e * trace.spe) as f64 / cfg.fs_hz;
cfg.auth_code_phase_chips + base_offset + CA_CHIP_RATE_HZ * t
};
let last = n - 1;
let err_auth_last = signed_wrap_chips(trace.code_phase[last] - true_phase(last, 0.0));
let err_spoof_last =
signed_wrap_chips(trace.code_phase[last] - true_phase(last, code_offset_chips));
let peak_prompt = trace
.dumps
.iter()
.map(|d| d.prompt.abs())
.fold(0.0_f64, f64::max);
let final_prompt = trace.dumps[last].prompt.abs();
let prompt_lock_ratio = if peak_prompt > 0.0 {
final_prompt / peak_prompt
} else {
0.0
};
let locked = prompt_lock_ratio >= cfg.lock_frac;
let captured = locked
&& err_spoof_last.abs() < err_auth_last.abs()
&& err_spoof_last.abs() < cfg.capture_tol_chips;
let settled_carrier =
cfg.if_hz + cfg.auth_doppler_hz + if captured { carrier_offset_hz } else { 0.0 };
let final_carrier_err_hz = trace.carrier_freq[last] - settled_carrier;
let target_offset = if captured { code_offset_chips } else { 0.0 };
let mut settle_epoch: Option<usize> = None;
for e in (0..n).rev() {
let err = signed_wrap_chips(trace.code_phase[e] - true_phase(e, target_offset));
if err.abs() >= cfg.capture_tol_chips {
settle_epoch = Some((e + 1).min(n - 1));
break;
}
if e == 0 {
settle_epoch = Some(0);
}
}
let lock_time_s = match settle_epoch {
Some(e)
if e < n && {
let err = signed_wrap_chips(trace.code_phase[e] - true_phase(e, target_offset));
err.abs() < cfg.capture_tol_chips
} =>
{
e as f64 * trace.epoch_dur
}
_ => f64::NAN,
};
CaptureOutcome {
power_advantage_db,
code_offset_chips,
carrier_offset_hz,
captured,
final_code_err_chips: err_auth_last,
residual_to_spoofer_chips: err_spoof_last,
final_carrier_err_hz,
lock_time_s,
prompt_lock_ratio,
}
}
#[derive(Clone, Debug)]
pub struct CaptureMap {
pub powers_db: Vec<f64>,
pub offsets_chips: Vec<f64>,
pub outcomes: Vec<Vec<CaptureOutcome>>,
}
impl CaptureMap {
pub fn min_capture_power_db(&self, offset_index: usize) -> Option<f64> {
for (i, &p) in self.powers_db.iter().enumerate() {
if self.outcomes[i][offset_index].captured {
return Some(p);
}
}
None
}
}
pub fn capture_map(
cfg: &CaptureConfig,
powers_db: &[f64],
offsets_chips: &[f64],
carrier_offset_hz: f64,
) -> CaptureMap {
let outcomes = powers_db
.iter()
.map(|&p| {
offsets_chips
.iter()
.map(|&off| run_capture(cfg, p, off, carrier_offset_hz))
.collect()
})
.collect();
CaptureMap {
powers_db: powers_db.to_vec(),
offsets_chips: offsets_chips.to_vec(),
outcomes,
}
}
#[derive(Clone, Debug)]
pub struct CaptureCube {
pub powers_db: Vec<f64>,
pub offsets_chips: Vec<f64>,
pub carrier_offsets_hz: Vec<f64>,
pub outcomes: Vec<Vec<Vec<CaptureOutcome>>>,
}
impl CaptureCube {
pub fn min_capture_power_db(&self, offset_index: usize, carrier_index: usize) -> Option<f64> {
for (i, &p) in self.powers_db.iter().enumerate() {
if self.outcomes[i][offset_index][carrier_index].captured {
return Some(p);
}
}
None
}
}
pub fn capture_cube(
cfg: &CaptureConfig,
powers_db: &[f64],
offsets_chips: &[f64],
carrier_offsets_hz: &[f64],
) -> CaptureCube {
let outcomes = powers_db
.iter()
.map(|&p| {
offsets_chips
.iter()
.map(|&off| {
carrier_offsets_hz
.iter()
.map(|&carrier| run_capture(cfg, p, off, carrier))
.collect()
})
.collect()
})
.collect();
CaptureCube {
powers_db: powers_db.to_vec(),
offsets_chips: offsets_chips.to_vec(),
carrier_offsets_hz: carrier_offsets_hz.to_vec(),
outcomes,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::sdr::track;
#[test]
fn traced_loop_reproduces_sdr_track() {
let code = CaCode::new(10).unwrap();
let fs = 5_000_000.0;
let if_hz = 50_000.0;
let doppler = 800.0;
let n_epochs = 20;
let n = (fs / 1000.0) as usize * n_epochs;
let iq = synth_if(
&code,
fs,
if_hz + doppler,
CA_CHIP_RATE_HZ,
123.0,
1.0,
n,
0.0,
7,
);
let acq = Acquisition {
prn: 10,
code_phase_chips: 123.0,
doppler_hz: doppler,
peak_ratio: f64::INFINITY,
acquired: true,
};
let cfg = TrackConfig::default();
let ref_dumps = track(&iq, &code, &acq, fs, if_hz, &cfg, n_epochs);
let trace = track_traced(&iq, &code, &acq, fs, if_hz, &cfg, n_epochs);
assert_eq!(ref_dumps.len(), trace.dumps.len());
for (r, t) in ref_dumps.iter().zip(&trace.dumps) {
assert_eq!(r.prompt.re, t.prompt.re);
assert_eq!(r.prompt.im, t.prompt.im);
assert_eq!(r.early.re, t.early.re);
assert_eq!(r.late.im, t.late.im);
}
}
#[test]
fn out_of_range_offset_never_captures_regardless_of_power() {
let cfg = CaptureConfig::default();
for &power in &[0.0, 6.0, 12.0, 20.0, 30.0] {
let out = run_capture(&cfg, power, 1.5, 0.0);
assert!(
!out.captured,
"1.5-chip-offset spoofer at {power} dB must NOT capture (out of pull-in); \
err_auth={:.3} err_spoof={:.3}",
out.final_code_err_chips, out.residual_to_spoofer_chips
);
assert!(
out.residual_to_spoofer_chips.abs() > 0.5,
"loop must stay far from the out-of-range spoofer, residual={:.3}",
out.residual_to_spoofer_chips
);
}
}
#[test]
fn in_range_offset_with_power_captures_and_tracks_spoofer() {
let cfg = CaptureConfig::default();
let out = run_capture(&cfg, 6.0, 0.3, 0.0);
assert!(
out.captured,
"0.3-chip spoofer at +6 dB should capture; err_auth={:.3} err_spoof={:.3} lock={:.2}",
out.final_code_err_chips, out.residual_to_spoofer_chips, out.prompt_lock_ratio
);
assert!(
out.residual_to_spoofer_chips.abs() < 0.1,
"captured loop must sit on the spoofer, residual={:.3}",
out.residual_to_spoofer_chips
);
assert!(
(out.final_code_err_chips - 0.3).abs() < 0.1,
"drag from authentic should ≈ offset 0.3, got {:.3}",
out.final_code_err_chips
);
assert!(out.lock_time_s.is_finite() && out.lock_time_s >= 0.0);
}
#[test]
fn in_range_offset_without_power_does_not_capture() {
let cfg = CaptureConfig::default();
let out = run_capture(&cfg, -6.0, 0.3, 0.0);
assert!(
!out.captured,
"weaker spoofer (−6 dB) at 0.3 chip must not capture; err_spoof={:.3}",
out.residual_to_spoofer_chips
);
}
#[test]
fn required_power_is_monotonic_in_offset() {
let cfg = CaptureConfig::default();
let powers: Vec<f64> = vec![-6.0, -3.0, 0.0, 3.0, 6.0, 9.0, 12.0];
let offsets: Vec<f64> = vec![0.15, 0.4, 0.7, 0.95];
let map = capture_map(&cfg, &powers, &offsets, 0.0);
let req: Vec<Option<f64>> = (0..offsets.len())
.map(|j| map.min_capture_power_db(j))
.collect();
for (j, r) in req.iter().enumerate() {
assert!(
r.is_some(),
"offset {} should be capturable within the grid",
offsets[j]
);
}
for w in req.windows(2) {
let (a, b) = (w[0].unwrap(), w[1].unwrap());
assert!(
b >= a,
"required capture power must be non-decreasing in offset: {a} then {b}"
);
}
assert!(
req.last().unwrap().unwrap() > req.first().unwrap().unwrap(),
"near-edge offset must need strictly more power than a close-in offset"
);
}
#[test]
fn capture_map_shape_is_consistent() {
let cfg = CaptureConfig::default();
let powers = vec![10.0];
let offsets = vec![0.2, 1.5];
let map = capture_map(&cfg, &powers, &offsets, 0.0);
assert_eq!(map.outcomes.len(), 1);
assert_eq!(map.outcomes[0].len(), 2);
assert!(map.outcomes[0][0].captured, "0.2 chip at +10 dB captures");
assert!(
!map.outcomes[0][1].captured,
"1.5 chip at +10 dB does not capture"
);
}
#[test]
fn cube_zero_carrier_slice_reduces_to_capture_map() {
let cfg = CaptureConfig::default();
let powers: Vec<f64> = vec![-6.0, 0.0, 6.0, 12.0];
let offsets: Vec<f64> = vec![0.15, 0.4, 0.7, 1.5];
let map = capture_map(&cfg, &powers, &offsets, 0.0);
let cube = capture_cube(&cfg, &powers, &offsets, &[0.0]);
assert_eq!(cube.carrier_offsets_hz, vec![0.0]);
assert_eq!(cube.outcomes.len(), powers.len());
for (i, &power) in powers.iter().enumerate() {
assert_eq!(cube.outcomes[i].len(), offsets.len());
for (j, &offset) in offsets.iter().enumerate() {
assert_eq!(cube.outcomes[i][j].len(), 1);
let m = &map.outcomes[i][j];
let c = &cube.outcomes[i][j][0];
assert_eq!(
c.captured, m.captured,
"captured mismatch at power {} offset {}",
power, offset
);
assert_eq!(
c.final_code_err_chips, m.final_code_err_chips,
"final_code_err mismatch at power {} offset {}",
power, offset
);
}
}
}
#[test]
fn cube_reports_finite_lock_time_for_captured_cells() {
let cfg = CaptureConfig::default();
let powers: Vec<f64> = vec![6.0, 12.0];
let offsets: Vec<f64> = vec![0.2, 0.4];
let carriers: Vec<f64> = vec![0.0, 100.0];
let cube = capture_cube(&cfg, &powers, &offsets, &carriers);
let mut saw_capture = false;
for (i, &power) in powers.iter().enumerate() {
for (j, &offset) in offsets.iter().enumerate() {
for (k, &carrier) in carriers.iter().enumerate() {
let c = &cube.outcomes[i][j][k];
if c.captured {
saw_capture = true;
assert!(
c.lock_time_s.is_finite() && c.lock_time_s >= 0.0,
"captured cell must report finite lock_time_s >= 0, got {} \
at power {} offset {} carrier {}",
c.lock_time_s,
power,
offset,
carrier
);
let direct = run_capture(&cfg, power, offset, carrier);
assert_eq!(c.lock_time_s, direct.lock_time_s);
}
}
}
}
assert!(
saw_capture,
"grid should contain at least one captured cell"
);
}
#[test]
fn far_carrier_offset_denies_in_range_capture() {
let cfg = CaptureConfig::default();
let base = run_capture(&cfg, 6.0, 0.3, 0.0);
assert!(
base.captured,
"baseline (0 Hz carrier, +6 dB, 0.3 chip) must capture; lock={:.3}",
base.prompt_lock_ratio
);
let carriers: Vec<f64> = vec![1500.0, 2000.0, 3000.0, 4000.0];
let cube = capture_cube(&cfg, &[6.0], &[0.3], &carriers);
for (k, &c) in carriers.iter().enumerate() {
let out = &cube.outcomes[0][0][k];
assert!(
!out.captured,
"far carrier offset {c} Hz must deny capture; captured={} resid_spoof={:.3}",
out.captured, out.residual_to_spoofer_chips
);
assert!(
out.residual_to_spoofer_chips.abs() > 0.2,
"loop must hold the authentic signal (stay far from spoofer) at {c} Hz, \
residual={:.3}",
out.residual_to_spoofer_chips
);
}
}
#[test]
fn cube_is_deterministic_across_carrier_axis() {
let cfg = CaptureConfig::default();
let powers: Vec<f64> = vec![3.0, 9.0];
let offsets: Vec<f64> = vec![0.25, 0.6];
let carriers: Vec<f64> = vec![0.0, 400.0, 900.0];
let a = capture_cube(&cfg, &powers, &offsets, &carriers);
let b = capture_cube(&cfg, &powers, &offsets, &carriers);
for i in 0..powers.len() {
for j in 0..offsets.len() {
for k in 0..carriers.len() {
assert_eq!(a.outcomes[i][j][k].captured, b.outcomes[i][j][k].captured);
assert_eq!(
a.outcomes[i][j][k].final_code_err_chips,
b.outcomes[i][j][k].final_code_err_chips
);
assert_eq!(
a.outcomes[i][j][k].final_carrier_err_hz,
b.outcomes[i][j][k].final_carrier_err_hz
);
}
}
}
}
#[test]
fn cube_min_capture_power_matches_map_at_zero_carrier() {
let cfg = CaptureConfig::default();
let powers: Vec<f64> = vec![-6.0, -3.0, 0.0, 3.0, 6.0, 9.0, 12.0];
let offsets: Vec<f64> = vec![0.15, 0.4, 0.7, 0.95];
let map = capture_map(&cfg, &powers, &offsets, 0.0);
let cube = capture_cube(&cfg, &powers, &offsets, &[0.0]);
for (j, &offset) in offsets.iter().enumerate() {
assert_eq!(
cube.min_capture_power_db(j, 0),
map.min_capture_power_db(j),
"cube accessor must match map at offset {}",
offset
);
}
}
#[test]
fn deterministic_under_thermal_noise() {
let cfg = CaptureConfig {
noise_std: 0.05,
..CaptureConfig::default()
};
let a = run_capture(&cfg, 6.0, 0.3, 0.0);
let b = run_capture(&cfg, 6.0, 0.3, 0.0);
assert_eq!(a.captured, b.captured);
assert_eq!(a.final_code_err_chips, b.final_code_err_chips);
assert!(a.captured, "in-range +6 dB spoof still captures over noise");
}
}