Skip to main content

sidereon_core/
signal.rs

1//! GPS L1 C/A code generation, coherent correlation, and acquisition.
2//!
3//! The functions here are deterministic time-domain signal primitives. They
4//! intentionally keep the same simple operation order as the original Sidereon
5//! implementation: C/A chips are generated by clocking the two IS-GPS-200 LFSRs,
6//! sampled by zero-order hold, and correlation/acquisition are direct nested
7//! sums over samples and Doppler bins.
8
9pub mod analysis;
10
11use crate::constants::F_L1_HZ;
12use crate::tolerances::DOPPLER_GRID_EDGE_EPS_HZ;
13use crate::validate;
14
15/// GPS C/A code length in chips.
16pub const CA_CODE_LENGTH: usize = 1023;
17
18/// GPS C/A chipping rate in chips per second.
19pub const CA_CHIP_RATE_HZ: f64 = 1_023_000.0;
20
21const TWO_PI: f64 = 2.0 * std::f64::consts::PI;
22const DEFAULT_DOPPLER_MIN_HZ: f64 = -2500.0;
23const DEFAULT_DOPPLER_MAX_HZ: f64 = 2500.0;
24const DEFAULT_DOPPLER_STEP_HZ: f64 = 500.0;
25const DEFAULT_SAMPLE_RATE_HZ: f64 = 2.046e6;
26const MAX_DOPPLER_BINS: usize = 4096;
27
28/// Error returned by GPS C/A signal helpers.
29#[derive(Debug, Clone, PartialEq)]
30pub enum SignalError {
31    /// Supported GPS space-vehicle PRNs are 1 through 32.
32    UnsupportedPrn(i64),
33    /// A boundary input was malformed before signal processing could start.
34    InvalidInput {
35        /// Name of the malformed field.
36        field: &'static str,
37        /// Stable validation reason.
38        reason: &'static str,
39    },
40    /// A correlation/acquisition record had no samples.
41    EmptySamples,
42    /// The acquisition record was shorter than one sampled C/A-code period.
43    TooShort,
44}
45
46impl core::fmt::Display for SignalError {
47    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
48        match self {
49            Self::UnsupportedPrn(prn) => write!(f, "unsupported GPS C/A PRN {prn}"),
50            Self::InvalidInput { field, reason } => {
51                write!(f, "invalid signal input {field}: {reason}")
52            }
53            Self::EmptySamples => write!(f, "empty sample vector"),
54            Self::TooShort => write!(f, "sample vector shorter than one C/A code period"),
55        }
56    }
57}
58
59impl std::error::Error for SignalError {}
60
61/// One complex baseband sample.
62#[derive(Debug, Clone, Copy, PartialEq)]
63pub struct IqSample {
64    /// In-phase component.
65    pub i: f64,
66    /// Quadrature component.
67    pub q: f64,
68}
69
70impl IqSample {
71    /// Construct a complex sample.
72    pub const fn new(i: f64, q: f64) -> Self {
73        Self { i, q }
74    }
75
76    /// Construct a real-valued sample with zero quadrature.
77    pub const fn real(i: f64) -> Self {
78        Self { i, q: 0.0 }
79    }
80}
81
82/// Options for [`replica`].
83#[derive(Debug, Clone, Copy, PartialEq)]
84pub struct ReplicaOptions {
85    /// Sampling rate in hertz.
86    pub sample_rate_hz: f64,
87    /// Number of output samples.
88    pub num_samples: usize,
89    /// Initial C/A code phase in chips.
90    pub code_phase_chips: f64,
91    /// Code-rate Doppler in hertz.
92    pub code_doppler_hz: f64,
93}
94
95impl ReplicaOptions {
96    /// One C/A-code period at 2.046 MHz (two samples per chip).
97    pub fn one_code_period() -> Self {
98        let sample_rate_hz = DEFAULT_SAMPLE_RATE_HZ;
99        let integration_time_s = CA_CODE_LENGTH as f64 / CA_CHIP_RATE_HZ;
100        Self {
101            sample_rate_hz,
102            num_samples: (sample_rate_hz * integration_time_s).round() as usize,
103            code_phase_chips: 0.0,
104            code_doppler_hz: 0.0,
105        }
106    }
107}
108
109/// Options for [`correlate`].
110#[derive(Debug, Clone, Copy, PartialEq)]
111pub struct CorrelateOptions {
112    /// Sampling rate in hertz.
113    pub sample_rate_hz: f64,
114    /// Residual carrier Doppler to wipe off in hertz.
115    pub doppler_hz: f64,
116    /// Replica C/A code phase in chips.
117    pub code_phase_chips: f64,
118    /// Replica code-rate Doppler in hertz.
119    pub code_doppler_hz: f64,
120}
121
122impl Default for CorrelateOptions {
123    fn default() -> Self {
124        Self {
125            sample_rate_hz: DEFAULT_SAMPLE_RATE_HZ,
126            doppler_hz: 0.0,
127            code_phase_chips: 0.0,
128            code_doppler_hz: 0.0,
129        }
130    }
131}
132
133/// Coherent correlation result.
134#[derive(Debug, Clone, Copy, PartialEq)]
135pub struct CorrelationResult {
136    /// Real in-phase coherent sum.
137    pub i: f64,
138    /// Imaginary quadrature coherent sum.
139    pub q: f64,
140    /// Squared magnitude `i*i + q*q`.
141    pub power: f64,
142}
143
144/// Options for [`acquire`].
145#[derive(Debug, Clone, Copy, PartialEq)]
146pub struct AcquisitionOptions {
147    /// Sampling rate in hertz.
148    pub sample_rate_hz: f64,
149    /// Minimum Doppler bin in hertz.
150    pub doppler_min_hz: f64,
151    /// Maximum Doppler bin in hertz.
152    pub doppler_max_hz: f64,
153    /// Doppler bin step in hertz.
154    pub doppler_step_hz: f64,
155}
156
157impl Default for AcquisitionOptions {
158    fn default() -> Self {
159        Self {
160            sample_rate_hz: DEFAULT_SAMPLE_RATE_HZ,
161            doppler_min_hz: DEFAULT_DOPPLER_MIN_HZ,
162            doppler_max_hz: DEFAULT_DOPPLER_MAX_HZ,
163            doppler_step_hz: DEFAULT_DOPPLER_STEP_HZ,
164        }
165    }
166}
167
168/// Acquisition-grid metadata.
169#[derive(Debug, Clone, PartialEq)]
170pub struct AcquisitionGrid {
171    /// Doppler bins searched, in hertz.
172    pub doppler_hz: Vec<f64>,
173    /// Number of code-phase bins searched.
174    pub code_phase_bins: usize,
175    /// Doppler step in hertz.
176    pub doppler_step_hz: f64,
177    /// Samples per C/A chip at the configured sample rate.
178    pub samples_per_chip: f64,
179}
180
181/// Result of a 2D code-phase/Doppler acquisition search.
182#[derive(Debug, Clone, PartialEq)]
183pub struct AcquisitionResult {
184    /// Recovered code phase in chips.
185    pub code_phase_chips: f64,
186    /// Recovered Doppler bin in hertz.
187    pub doppler_hz: f64,
188    /// Peak-to-mean-off-peak metric.
189    pub peak_metric: f64,
190    /// Alias for [`peak_metric`](Self::peak_metric).
191    pub metric: f64,
192    /// Peak correlator power.
193    pub peak_power: f64,
194    /// Search-grid metadata.
195    pub grid: AcquisitionGrid,
196}
197
198/// Return the 1023 bipolar (`+1`/`-1`) GPS C/A chips for a PRN.
199pub fn ca_code(prn: i64) -> Result<Vec<i8>, SignalError> {
200    let taps = phase_select(prn)?;
201    let raw = raw_code(taps);
202    Ok(raw.into_iter().map(|bit| 1 - 2 * bit as i8).collect())
203}
204
205/// Return one bipolar chip at a wrapping zero-based index.
206pub fn ca_chip(prn: i64, index: i64) -> Result<i8, SignalError> {
207    let code = ca_code(prn)?;
208    let idx = index.rem_euclid(CA_CODE_LENGTH as i64) as usize;
209    Ok(code[idx])
210}
211
212/// Circular autocorrelation over all lags.
213pub fn autocorrelation(code: &[i8]) -> Vec<i32> {
214    (0..code.len())
215        .map(|lag| correlation_at_equal_len(code, code, lag as i64))
216        .collect()
217}
218
219/// Circular cross-correlation over all lags.
220pub fn cross_correlation(code_a: &[i8], code_b: &[i8]) -> Result<Vec<i32>, SignalError> {
221    if code_a.len() != code_b.len() {
222        return Err(invalid_signal_input("code_lengths", "length mismatch"));
223    }
224    Ok((0..code_a.len())
225        .map(|lag| correlation_at_equal_len(code_a, code_b, lag as i64))
226        .collect())
227}
228
229/// Single-lag circular correlation.
230pub fn correlation_at(code_a: &[i8], code_b: &[i8], lag: i64) -> Result<i32, SignalError> {
231    if code_a.len() != code_b.len() {
232        return Err(invalid_signal_input("code_lengths", "length mismatch"));
233    }
234    validate_correlation_lag(code_a.len(), lag)?;
235    Ok(correlation_at_equal_len(code_a, code_b, lag))
236}
237
238fn correlation_at_equal_len(code_a: &[i8], code_b: &[i8], lag: i64) -> i32 {
239    let n = code_a.len() as i64;
240    let mut acc = 0_i32;
241    for (i, &chip_a) in code_a.iter().enumerate() {
242        let j = (i as i64 + lag).rem_euclid(n) as usize;
243        acc += i32::from(chip_a) * i32::from(code_b[j]);
244    }
245    acc
246}
247
248fn validate_correlation_lag(len: usize, lag: i64) -> Result<(), SignalError> {
249    if len == 0 || lag <= 0 {
250        return Ok(());
251    }
252    let max_index =
253        i64::try_from(len - 1).map_err(|_| invalid_signal_input("code_lengths", "out of range"))?;
254    if max_index > i64::MAX - lag {
255        return Err(invalid_signal_input("lag", "out of range"));
256    }
257    Ok(())
258}
259
260/// Build a sampled C/A-code replica.
261pub fn replica(prn: i64, options: ReplicaOptions) -> Result<Vec<i8>, SignalError> {
262    let sample_rate_hz = signal_positive_step(options.sample_rate_hz, "sample_rate_hz")?;
263    let code_phase_chips = signal_finite(options.code_phase_chips, "code_phase_chips")?;
264    let code_doppler_hz = signal_finite(options.code_doppler_hz, "code_doppler_hz")?;
265    let code = ca_code(prn)?;
266    Ok(sample_code(
267        &code,
268        options.num_samples,
269        sample_rate_hz,
270        code_phase_chips,
271        code_doppler_hz,
272    ))
273}
274
275/// Coherently correlate a sample record against a PRN replica.
276pub fn correlate(
277    iq: &[IqSample],
278    prn: i64,
279    options: CorrelateOptions,
280) -> Result<CorrelationResult, SignalError> {
281    if iq.is_empty() {
282        return Err(SignalError::EmptySamples);
283    }
284    validate_iq_samples(iq, "samples")?;
285    let sample_rate_hz = signal_positive_step(options.sample_rate_hz, "sample_rate_hz")?;
286    let doppler_hz = signal_finite(options.doppler_hz, "doppler_hz")?;
287    let code_phase_chips = signal_finite(options.code_phase_chips, "code_phase_chips")?;
288    let code_doppler_hz = signal_finite(options.code_doppler_hz, "code_doppler_hz")?;
289    let code = ca_code(prn)?;
290    let sampled = sample_code(
291        &code,
292        iq.len(),
293        sample_rate_hz,
294        code_phase_chips,
295        code_doppler_hz,
296    );
297    let (i, q) = correlate_against(iq, &sampled, sample_rate_hz, doppler_hz)?;
298    let power = signal_finite(i * i + q * q, "correlation_power")?;
299    Ok(CorrelationResult { i, q, power })
300}
301
302/// Coherent correlation against an explicit sampled code.
303///
304/// The sum follows `zip(iq, code)` semantics and therefore uses the shorter of
305/// the two non-empty input lengths.
306pub fn correlate_against(
307    iq: &[IqSample],
308    code: &[i8],
309    fs: f64,
310    doppler_hz: f64,
311) -> Result<(f64, f64), SignalError> {
312    if iq.is_empty() {
313        return Err(SignalError::EmptySamples);
314    }
315    validate_iq_samples(iq, "samples")?;
316    if code.is_empty() {
317        return Err(invalid_signal_input("code", "empty"));
318    }
319    let fs = signal_positive_step(fs, "sample_rate_hz")?;
320    let doppler_hz = signal_finite(doppler_hz, "doppler_hz")?;
321    let w = TWO_PI * doppler_hz / fs;
322    let mut acc_i = 0.0;
323    let mut acc_q = 0.0;
324    for (n, (sample, &c)) in iq.iter().zip(code.iter()).enumerate() {
325        let theta = w * n as f64;
326        let cos = theta.cos();
327        let sin = theta.sin();
328        let cc = c as f64;
329        let di = (sample.i * cos + sample.q * sin) * cc;
330        let dq = (sample.q * cos - sample.i * sin) * cc;
331        acc_i += di;
332        acc_q += dq;
333    }
334    Ok((
335        signal_finite(acc_i, "correlation_i")?,
336        signal_finite(acc_q, "correlation_q")?,
337    ))
338}
339
340/// Acquire a PRN by direct 2D code-phase/Doppler search.
341pub fn acquire(
342    samples: &[IqSample],
343    prn: i64,
344    options: AcquisitionOptions,
345) -> Result<AcquisitionResult, SignalError> {
346    if samples.is_empty() {
347        return Err(SignalError::EmptySamples);
348    }
349    validate_iq_samples(samples, "samples")?;
350    let sample_rate_hz = signal_positive_step(options.sample_rate_hz, "sample_rate_hz")?;
351    let doppler_min_hz = signal_finite(options.doppler_min_hz, "doppler_min_hz")?;
352    let doppler_max_hz = signal_finite(options.doppler_max_hz, "doppler_max_hz")?;
353    let doppler_step_hz = signal_positive_step(options.doppler_step_hz, "doppler_step_hz")?;
354    signal_range_order(doppler_min_hz, doppler_max_hz, "doppler_max_hz")?;
355    let options = AcquisitionOptions {
356        sample_rate_hz,
357        doppler_min_hz,
358        doppler_max_hz,
359        doppler_step_hz,
360    };
361
362    let samples_per_chip = options.sample_rate_hz / CA_CHIP_RATE_HZ;
363    let samples_per_code = (samples_per_chip * CA_CODE_LENGTH as f64).round() as usize;
364    if samples_per_code == 0 {
365        return Err(SignalError::InvalidInput {
366            field: "sample_rate_hz",
367            reason: "out of range",
368        });
369    }
370    if samples.len() < samples_per_code {
371        return Err(SignalError::TooShort);
372    }
373
374    let code = ca_code(prn)?;
375    do_acquire(samples, &code, options, samples_per_chip, samples_per_code)
376}
377
378/// Coherent integration loss from residual frequency error.
379pub fn coherent_loss(freq_error_hz: f64, integration_time_s: f64) -> Result<f64, SignalError> {
380    let freq_error_hz = signal_finite(freq_error_hz, "freq_error_hz")?;
381    let integration_time_s = signal_positive_step(integration_time_s, "integration_time_s")?;
382    let x = signal_finite(
383        std::f64::consts::PI * freq_error_hz * integration_time_s,
384        "coherent_loss",
385    )?;
386    if x == 0.0 {
387        Ok(1.0)
388    } else {
389        let s = x.sin() / x;
390        signal_finite(s * s, "coherent_loss")
391    }
392}
393
394/// Coherent integration loss in decibels.
395pub fn coherent_loss_db(freq_error_hz: f64, integration_time_s: f64) -> Result<f64, SignalError> {
396    let loss = coherent_loss(freq_error_hz, integration_time_s)?;
397    if loss <= 0.0 {
398        return Err(invalid_signal_input("coherent_loss_db", "out of range"));
399    }
400    let loss_db = 10.0 * loss.log10();
401    if loss_db.is_finite() {
402        Ok(loss_db)
403    } else {
404        Err(invalid_signal_input("coherent_loss_db", "out of range"))
405    }
406}
407
408/// Post-correlation predetection SNR in dB.
409pub fn snr_post_db(cn0_dbhz: f64, integration_time_s: f64) -> Result<f64, SignalError> {
410    let cn0_dbhz = signal_finite(cn0_dbhz, "cn0_dbhz")?;
411    let integration_time_s = signal_positive_step(integration_time_s, "integration_time_s")?;
412    signal_finite(cn0_dbhz + 10.0 * integration_time_s.log10(), "snr_post_db")
413}
414
415fn do_acquire(
416    samples: &[IqSample],
417    code: &[i8],
418    options: AcquisitionOptions,
419    samples_per_chip: f64,
420    samples_per_code: usize,
421) -> Result<AcquisitionResult, SignalError> {
422    let doppler_bins = doppler_grid(
423        options.doppler_min_hz,
424        options.doppler_max_hz,
425        options.doppler_step_hz,
426    )?;
427
428    let record = &samples[..samples_per_code];
429    let base_code = sample_code(code, samples_per_code, options.sample_rate_hz, 0.0, 0.0);
430
431    let mut grid = Vec::with_capacity(doppler_bins.len());
432    for &d in &doppler_bins {
433        let wiped = carrier_wipeoff(record, options.sample_rate_hz, d);
434        validate_iq_samples(&wiped, "wiped samples")?;
435        let powers = code_phase_powers(&wiped, &base_code);
436        validate::finite_slice(&powers, "code phase powers").map_err(map_signal_input)?;
437        grid.push((d, powers));
438    }
439
440    let mut peak_power = -1.0;
441    let mut peak_doppler = 0.0;
442    let mut peak_offset = 0_usize;
443    for (d, powers) in &grid {
444        for (off, &p) in powers.iter().enumerate() {
445            if p > peak_power {
446                peak_power = p;
447                peak_doppler = *d;
448                peak_offset = off;
449            }
450        }
451    }
452
453    let metric = peak_to_mean_off_peak(&grid, peak_power, peak_doppler, peak_offset);
454    let code_phase_chips = peak_offset as f64 / samples_per_chip;
455
456    Ok(AcquisitionResult {
457        code_phase_chips,
458        doppler_hz: peak_doppler,
459        peak_metric: metric,
460        metric,
461        peak_power,
462        grid: AcquisitionGrid {
463            doppler_hz: doppler_bins,
464            code_phase_bins: samples_per_code,
465            doppler_step_hz: options.doppler_step_hz,
466            samples_per_chip,
467        },
468    })
469}
470
471fn phase_select(prn: i64) -> Result<(usize, usize), SignalError> {
472    match prn {
473        1 => Ok((2, 6)),
474        2 => Ok((3, 7)),
475        3 => Ok((4, 8)),
476        4 => Ok((5, 9)),
477        5 => Ok((1, 9)),
478        6 => Ok((2, 10)),
479        7 => Ok((1, 8)),
480        8 => Ok((2, 9)),
481        9 => Ok((3, 10)),
482        10 => Ok((2, 3)),
483        11 => Ok((3, 4)),
484        12 => Ok((5, 6)),
485        13 => Ok((6, 7)),
486        14 => Ok((7, 8)),
487        15 => Ok((8, 9)),
488        16 => Ok((9, 10)),
489        17 => Ok((1, 4)),
490        18 => Ok((2, 5)),
491        19 => Ok((3, 6)),
492        20 => Ok((4, 7)),
493        21 => Ok((5, 8)),
494        22 => Ok((6, 9)),
495        23 => Ok((1, 3)),
496        24 => Ok((4, 6)),
497        25 => Ok((5, 7)),
498        26 => Ok((6, 8)),
499        27 => Ok((7, 9)),
500        28 => Ok((8, 10)),
501        29 => Ok((1, 6)),
502        30 => Ok((2, 7)),
503        31 => Ok((3, 8)),
504        32 => Ok((4, 9)),
505        _ => Err(SignalError::UnsupportedPrn(prn)),
506    }
507}
508
509fn raw_code((tap_a, tap_b): (usize, usize)) -> Vec<u8> {
510    let mut g1 = [1_u8; 10];
511    let mut g2 = [1_u8; 10];
512    let mut chips = Vec::with_capacity(CA_CODE_LENGTH);
513
514    for _ in 0..CA_CODE_LENGTH {
515        let g1_out = g1[9];
516        let g2i = g2[tap_a - 1] ^ g2[tap_b - 1];
517        chips.push(g1_out ^ g2i);
518        step_g1(&mut g1);
519        step_g2(&mut g2);
520    }
521
522    chips
523}
524
525fn step_g1(g1: &mut [u8; 10]) {
526    let feedback = g1[2] ^ g1[9];
527    shift(g1, feedback);
528}
529
530fn step_g2(g2: &mut [u8; 10]) {
531    let feedback = g2[1] ^ g2[2] ^ g2[5] ^ g2[7] ^ g2[8] ^ g2[9];
532    shift(g2, feedback);
533}
534
535fn shift(reg: &mut [u8; 10], feedback: u8) {
536    for i in (1..reg.len()).rev() {
537        reg[i] = reg[i - 1];
538    }
539    reg[0] = feedback;
540}
541
542fn sample_code(code: &[i8], n: usize, fs: f64, code_phase: f64, code_doppler: f64) -> Vec<i8> {
543    let code_rate = CA_CHIP_RATE_HZ * (1.0 + code_doppler / F_L1_HZ);
544    let per_sample = code_rate / fs;
545    let len = code.len() as i64;
546
547    (0..n)
548        .map(|k| {
549            let pos = code_phase + k as f64 * per_sample;
550            let idx = (pos.floor() as i64).rem_euclid(len) as usize;
551            code[idx]
552        })
553        .collect()
554}
555
556fn carrier_wipeoff(iq: &[IqSample], fs: f64, doppler_hz: f64) -> Vec<IqSample> {
557    let w = TWO_PI * doppler_hz / fs;
558    iq.iter()
559        .enumerate()
560        .map(|(k, sample)| {
561            let theta = w * k as f64;
562            let cos = theta.cos();
563            let sin = theta.sin();
564            IqSample {
565                i: sample.i * cos + sample.q * sin,
566                q: sample.q * cos - sample.i * sin,
567            }
568        })
569        .collect()
570}
571
572fn code_phase_powers(wiped: &[IqSample], base_code: &[i8]) -> Vec<f64> {
573    let n = wiped.len();
574    (0..n)
575        .map(|offset| {
576            let mut i = 0.0;
577            let mut q = 0.0;
578            for k in 0..n {
579                let sample = wiped[k];
580                let c = base_code[(k + offset) % n] as f64;
581                i += sample.i * c;
582                q += sample.q * c;
583            }
584            i * i + q * q
585        })
586        .collect()
587}
588
589fn peak_to_mean_off_peak(
590    grid: &[(f64, Vec<f64>)],
591    peak_power: f64,
592    peak_doppler: f64,
593    peak_offset: usize,
594) -> f64 {
595    let n = grid.first().map_or(0, |(_, powers)| powers.len());
596    let mut sum = 0.0;
597    let mut count = 0_usize;
598
599    for (d, powers) in grid {
600        for (off, power) in powers.iter().enumerate() {
601            if *d == peak_doppler && abs_circular_diff(off, peak_offset, n) <= 1 {
602                continue;
603            }
604            sum += *power;
605            count += 1;
606        }
607    }
608
609    if count == 0 {
610        0.0
611    } else if sum <= 0.0 && peak_power > 0.0 {
612        1.0e12
613    } else if sum <= 0.0 {
614        0.0
615    } else {
616        peak_power / (sum / count as f64)
617    }
618}
619
620fn doppler_grid(dmin: f64, dmax: f64, dstep: f64) -> Result<Vec<f64>, SignalError> {
621    let dstep = signal_positive_step(dstep, "doppler_step_hz")?;
622    let last_bin_index = doppler_last_bin_index(dmin, dmax, dstep)?;
623    Ok((0..=last_bin_index)
624        .map(|k| dmin + k as f64 * dstep)
625        .filter(|d| *d <= dmax + DOPPLER_GRID_EDGE_EPS_HZ)
626        .collect())
627}
628
629fn doppler_last_bin_index(dmin: f64, dmax: f64, dstep: f64) -> Result<usize, SignalError> {
630    let last_bin_index = ((dmax - dmin) / dstep).round();
631    if !last_bin_index.is_finite() || last_bin_index < 0.0 {
632        return Err(invalid_signal_input("doppler_grid", "out of range"));
633    }
634    let bin_count = last_bin_index + 1.0;
635    if bin_count > MAX_DOPPLER_BINS as f64 {
636        return Err(invalid_signal_input("doppler_grid", "out of range"));
637    }
638    Ok(last_bin_index as usize)
639}
640
641fn signal_positive_step(x: f64, field: &'static str) -> Result<f64, SignalError> {
642    validate::positive_step(x, field).map_err(map_signal_input)
643}
644
645fn signal_finite(x: f64, field: &'static str) -> Result<f64, SignalError> {
646    validate::finite(x, field).map_err(map_signal_input)
647}
648
649fn signal_range_order(lo: f64, hi: f64, field: &'static str) -> Result<(), SignalError> {
650    validate::range_order(lo, hi, field).map_err(map_signal_input)
651}
652
653fn validate_iq_samples(samples: &[IqSample], field: &'static str) -> Result<(), SignalError> {
654    for sample in samples {
655        if !sample.i.is_finite() || !sample.q.is_finite() {
656            return Err(invalid_signal_input(field, "not finite"));
657        }
658    }
659    Ok(())
660}
661
662fn map_signal_input(error: validate::FieldError) -> SignalError {
663    invalid_signal_input(error.field(), error.reason())
664}
665
666fn invalid_signal_input(field: &'static str, reason: &'static str) -> SignalError {
667    SignalError::InvalidInput { field, reason }
668}
669
670fn abs_circular_diff(a: usize, b: usize, n: usize) -> usize {
671    let d = a.abs_diff(b) % n;
672    d.min(n - d)
673}
674
675#[cfg(test)]
676mod tests {
677    use super::*;
678
679    #[test]
680    fn unsupported_prn_is_tagged() {
681        assert_eq!(ca_code(33), Err(SignalError::UnsupportedPrn(33)));
682        assert_eq!(ca_chip(0, 0), Err(SignalError::UnsupportedPrn(0)));
683    }
684
685    #[test]
686    fn code_balance_and_correlation_shape_are_pinned() {
687        for prn in 1..=32 {
688            let code = ca_code(prn).unwrap();
689            assert_eq!(code.len(), CA_CODE_LENGTH);
690            assert_eq!(code.iter().filter(|&&chip| chip == -1).count(), 512);
691            assert_eq!(code.iter().filter(|&&chip| chip == 1).count(), 511);
692            assert_eq!(code.iter().map(|&chip| i32::from(chip)).sum::<i32>(), -1);
693        }
694
695        let code = ca_code(1).unwrap();
696        let corr = autocorrelation(&code);
697        assert_eq!(corr[0], 1023);
698        assert!(!corr[1..].contains(&1023));
699        let mut values = corr[1..].to_vec();
700        values.sort_unstable();
701        values.dedup();
702        assert_eq!(values, vec![-65, -1, 63]);
703    }
704
705    #[test]
706    fn loss_and_snr_primitives_are_deterministic() {
707        assert_eq!(
708            coherent_loss(0.0, 1.0e-3).unwrap().to_bits(),
709            1.0_f64.to_bits()
710        );
711        assert_eq!(
712            snr_post_db(40.0, 1.0e-3).unwrap().to_bits(),
713            10.0_f64.to_bits()
714        );
715        assert_eq!(
716            coherent_loss(f64::MAX, 1.0),
717            Err(invalid_signal_input("coherent_loss", "not finite"))
718        );
719    }
720
721    #[test]
722    fn correlation_rejects_nonfinite_derived_outputs() {
723        let samples = [IqSample::real(f64::MAX), IqSample::real(f64::MAX)];
724        let code = [1_i8, 1_i8];
725
726        assert_eq!(
727            correlate_against(&samples, &code, DEFAULT_SAMPLE_RATE_HZ, 0.0),
728            Err(invalid_signal_input("correlation_i", "not finite"))
729        );
730        assert_eq!(
731            correlate(
732                &samples[..1],
733                1,
734                CorrelateOptions {
735                    sample_rate_hz: DEFAULT_SAMPLE_RATE_HZ,
736                    doppler_hz: 0.0,
737                    code_phase_chips: 0.0,
738                    code_doppler_hz: 0.0,
739                }
740            ),
741            Err(invalid_signal_input("correlation_power", "not finite"))
742        );
743    }
744}