Skip to main content

sidereon_core/signal/
analysis.rs

1//! Closed-form navigation-signal figures of merit.
2//!
3//! This module evaluates modulation spectra and spectrum-derived metrics from
4//! analytic chip-pulse transforms. It does not generate IF samples, local
5//! replicas, acquisition grids, tracking loops, or receiver state.
6//!
7//! Shipped modulation variants:
8//!
9//! - BPSK(n), with code rate `n * 1.023 MHz`.
10//! - sine-phased BOC(m,n), with `2m/n` an integer subchip count.
11//! - cosine-phased BOC(m,n), with `4m/n` an integer quarter-subchip count.
12//! - MBOC(6,1,1/11), as `10/11 BOC(1,1) + 1/11 BOC(6,1)`.
13//! - GPS L1C pilot TMBOC(6,1,4/33), as
14//!   `29/33 BOC(1,1) + 4/33 BOC(6,1)`.
15//! - Galileo E1 CBOC(6,1,1/11) data or pilot chip pulses, formed as the
16//!   weighted sum of BOC(1,1) and BOC(6,1) subcarriers with selectable sign.
17//!
18//! AltBOC, ACE-BOC, interplex products, receiver-specific filtered replicas,
19//! and arbitrary channel multiplexing are non-goals for this pass.
20
21use crate::constants::C_M_S;
22use crate::validate;
23
24/// Reference chipping-rate unit used by BPSK(n) and BOC(m,n), in hertz.
25pub const REFERENCE_CHIP_RATE_HZ: f64 = 1_023_000.0;
26
27/// Receiver bandwidth used by the Betz L1 SSC fixture, in hertz.
28pub const BETZ_L1_RECEIVER_BANDWIDTH_HZ: f64 = 24_000_000.0;
29
30const PI: f64 = std::f64::consts::PI;
31const TWO_PI: f64 = 2.0 * PI;
32const MAX_SUBCHIPS: usize = 4096;
33const MAX_WEIGHTED_COMPONENTS: usize = 32;
34const MAX_QUADRATURE_PANELS: usize = 32768;
35const QUADRATURE_PANEL_HZ: f64 = REFERENCE_CHIP_RATE_HZ / 4.0;
36const ROOT_SCAN_STEPS: usize = 96;
37const ROOT_BISECTION_STEPS: usize = 48;
38const WEIGHT_SUM_TOL: f64 = 1.0e-12;
39const INTEGER_RATIO_TOL: f64 = 1.0e-12;
40const DEGENERATE_DENOMINATOR: f64 = 1.0e-300;
41
42#[allow(clippy::excessive_precision)]
43const GL64_POSITIVE_NODES: [f64; 32] = [
44    2.43502926634244325e-02,
45    7.29931217877990424e-02,
46    1.21462819296120544e-01,
47    1.69644420423992831e-01,
48    2.17423643740007083e-01,
49    2.64687162208767424e-01,
50    3.11322871990210970e-01,
51    3.57220158337668126e-01,
52    4.02270157963991570e-01,
53    4.46366017253464087e-01,
54    4.89403145707052956e-01,
55    5.31279464019894565e-01,
56    5.71895646202634000e-01,
57    6.11155355172393278e-01,
58    6.48965471254657311e-01,
59    6.85236313054233270e-01,
60    7.19881850171610771e-01,
61    7.52819907260531940e-01,
62    7.83972358943341385e-01,
63    8.13265315122797539e-01,
64    8.40629296252580316e-01,
65    8.65999398154092770e-01,
66    8.89315445995114140e-01,
67    9.10522137078502825e-01,
68    9.29569172131939570e-01,
69    9.46411374858402765e-01,
70    9.61008799652053658e-01,
71    9.73326827789910975e-01,
72    9.83336253884625977e-01,
73    9.91013371476744287e-01,
74    9.96340116771955220e-01,
75    9.99305041735772170e-01,
76];
77
78#[allow(clippy::excessive_precision)]
79const GL64_POSITIVE_WEIGHTS: [f64; 32] = [
80    4.86909570091397237e-02,
81    4.85754674415033935e-02,
82    4.83447622348029404e-02,
83    4.79993885964583034e-02,
84    4.75401657148303222e-02,
85    4.69681828162099788e-02,
86    4.62847965813143539e-02,
87    4.54916279274180727e-02,
88    4.45905581637566079e-02,
89    4.35837245293234157e-02,
90    4.24735151236535352e-02,
91    4.12625632426234581e-02,
92    3.99537411327204536e-02,
93    3.85501531786155635e-02,
94    3.70551285402399844e-02,
95    3.54722132568822401e-02,
96    3.38051618371418630e-02,
97    3.20579283548514254e-02,
98    3.02346570724024884e-02,
99    2.83396726142594486e-02,
100    2.63774697150548978e-02,
101    2.43527025687111306e-02,
102    2.22701738083829967e-02,
103    2.01348231535300216e-02,
104    1.79517157756973571e-02,
105    1.57260304760250269e-02,
106    1.34630478967191179e-02,
107    1.11681394601309738e-02,
108    8.84675982636339009e-03,
109    6.50445796897848854e-03,
110    4.14703326056443250e-03,
111    1.78328072169469699e-03,
112];
113
114/// Phasing used for a BOC(m,n) square-wave subcarrier.
115#[derive(Debug, Clone, Copy, PartialEq, Eq)]
116pub enum BocPhasing {
117    /// Sine-phased BOC, represented by alternating half-subcarrier chips.
118    Sine,
119    /// Cosine-phased BOC, represented on quarter-subcarrier chips.
120    Cosine,
121}
122
123/// Sign convention for Galileo E1 CBOC(6,1,1/11) chip pulses.
124#[derive(Debug, Clone, Copy, PartialEq, Eq)]
125pub enum CbocSign {
126    /// Add the BOC(6,1) component to the BOC(1,1) component.
127    Plus,
128    /// Subtract the BOC(6,1) component from the BOC(1,1) component.
129    Minus,
130}
131
132/// Navigation modulation model used by spectrum-domain metrics.
133#[derive(Debug, Clone, PartialEq)]
134pub struct SignalModulation {
135    kind: ModulationKind,
136    label: &'static str,
137}
138
139/// One component in a normalized weighted composite spectrum.
140#[derive(Debug, Clone, PartialEq)]
141pub struct WeightedComponent {
142    /// Fractional power assigned to this component.
143    pub weight: f64,
144    /// Component modulation whose normalized PSD is scaled by `weight`.
145    pub modulation: SignalModulation,
146}
147
148/// Interfering signal and received power used in C/N0 degradation metrics.
149#[derive(Debug, Clone, PartialEq)]
150pub struct InterferenceTerm {
151    /// Interference modulation spectrum.
152    pub modulation: SignalModulation,
153    /// Interference received power divided by desired-signal received power.
154    pub power_ratio_to_carrier: f64,
155}
156
157/// Effective C/N0 result with the corresponding degradation.
158#[derive(Debug, Clone, Copy, PartialEq)]
159pub struct Cn0Degradation {
160    /// Effective carrier-to-noise-density ratio in hertz.
161    pub effective_cn0_hz: f64,
162    /// Effective carrier-to-noise-density ratio in decibel-hertz.
163    pub effective_cn0_db_hz: f64,
164    /// Loss from the input C/N0 to the effective C/N0, in decibels.
165    pub degradation_db: f64,
166}
167
168/// Processing mode for early-late DLL thermal-noise jitter.
169#[derive(Debug, Clone, Copy, PartialEq, Eq)]
170pub enum DllProcessing {
171    /// Coherent early-minus-late processing.
172    Coherent,
173    /// Non-coherent early-minus-late power processing with squaring loss.
174    NonCoherent,
175}
176
177/// Inputs for code-tracking thermal-noise figures.
178#[derive(Debug, Clone, Copy, PartialEq)]
179pub struct DllTrackingOptions {
180    /// Carrier-to-noise-density ratio, in decibel-hertz.
181    pub cn0_db_hz: f64,
182    /// One-sided DLL loop bandwidth, in hertz.
183    pub loop_bandwidth_hz: f64,
184    /// Predetection coherent integration time, in seconds.
185    pub integration_time_s: f64,
186    /// Early-late correlator spacing, in code chips.
187    pub correlator_spacing_chips: f64,
188    /// Two-sided receiver bandwidth, in hertz.
189    pub receiver_bandwidth_hz: f64,
190}
191
192/// Code-tracking thermal-noise result.
193#[derive(Debug, Clone, Copy, PartialEq)]
194pub struct DllJitter {
195    /// One-sigma delay jitter, in seconds.
196    pub seconds: f64,
197    /// One-sigma delay jitter, in code chips.
198    pub chips: f64,
199    /// One-sigma range jitter, in metres.
200    pub meters: f64,
201    /// Non-coherent squaring-loss multiplier. Coherent processing returns `1`.
202    pub squaring_loss: f64,
203}
204
205/// Inputs for one-path specular multipath envelope metrics.
206#[derive(Debug, Clone, Copy, PartialEq)]
207pub struct MultipathOptions {
208    /// Reflected-path amplitude divided by direct-path amplitude, in `[0, 1)`.
209    pub multipath_to_direct_ratio: f64,
210    /// Early-late correlator spacing, in code chips.
211    pub correlator_spacing_chips: f64,
212    /// Two-sided receiver bandwidth, in hertz.
213    pub receiver_bandwidth_hz: f64,
214}
215
216/// Multipath envelope value at one reflected-path delay.
217#[derive(Debug, Clone, Copy, PartialEq)]
218pub struct MultipathEnvelopePoint {
219    /// Reflected-path delay relative to the direct path, in code chips.
220    pub delay_chips: f64,
221    /// Reflected-path delay relative to the direct path, in seconds.
222    pub delay_s: f64,
223    /// In-phase multipath tracking error, in code chips.
224    pub in_phase_chips: f64,
225    /// In-phase multipath tracking error, in seconds.
226    pub in_phase_s: f64,
227    /// In-phase multipath tracking error, in metres.
228    pub in_phase_m: f64,
229    /// Anti-phase multipath tracking error, in code chips.
230    pub anti_phase_chips: f64,
231    /// Anti-phase multipath tracking error, in seconds.
232    pub anti_phase_s: f64,
233    /// Anti-phase multipath tracking error, in metres.
234    pub anti_phase_m: f64,
235    /// Running average of the absolute envelope, in code chips.
236    pub running_average_chips: f64,
237    /// Running average of the absolute envelope, in seconds.
238    pub running_average_s: f64,
239    /// Running average of the absolute envelope, in metres.
240    pub running_average_m: f64,
241}
242
243/// Error returned by signal-analysis functions.
244#[derive(Debug, Clone, PartialEq)]
245pub enum SignalAnalysisError {
246    /// A boundary input was malformed before a metric could be computed.
247    InvalidInput {
248        /// Name of the malformed field.
249        field: &'static str,
250        /// Stable validation reason.
251        reason: &'static str,
252    },
253    /// A weighted composite was supplied without any components.
254    EmptyComponents,
255    /// A deterministic root search did not bracket a discriminator zero.
256    NoDiscriminatorRoot {
257        /// Reflected-path delay for the failed root, in code chips.
258        delay_chips: f64,
259        /// Multipath phase sign used by the failed root.
260        phase_sign: f64,
261    },
262}
263
264impl core::fmt::Display for SignalAnalysisError {
265    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
266        match self {
267            Self::InvalidInput { field, reason } => {
268                write!(f, "invalid signal-analysis input {field}: {reason}")
269            }
270            Self::EmptyComponents => write!(f, "weighted composite has no components"),
271            Self::NoDiscriminatorRoot {
272                delay_chips,
273                phase_sign,
274            } => write!(
275                f,
276                "no early-late discriminator root for delay {delay_chips} chips and phase sign {phase_sign}"
277            ),
278        }
279    }
280}
281
282impl std::error::Error for SignalAnalysisError {}
283
284#[derive(Debug, Clone, PartialEq)]
285enum ModulationKind {
286    Pulse(ChipPulse),
287    Weighted(Vec<WeightedComponent>),
288}
289
290#[derive(Debug, Clone, PartialEq)]
291struct ChipPulse {
292    code_rate_hz: f64,
293    amplitudes: Vec<f64>,
294}
295
296struct CorrelationKernel<'a> {
297    modulation: &'a SignalModulation,
298    receiver_bandwidth_hz: f64,
299    power: f64,
300}
301
302impl WeightedComponent {
303    /// Construct one weighted component for [`SignalModulation::weighted`].
304    pub fn new(weight: f64, modulation: SignalModulation) -> Self {
305        Self { weight, modulation }
306    }
307}
308
309impl InterferenceTerm {
310    /// Construct one interference term from a modulation and power ratio.
311    pub fn new(modulation: SignalModulation, power_ratio_to_carrier: f64) -> Self {
312        Self {
313            modulation,
314            power_ratio_to_carrier,
315        }
316    }
317}
318
319impl SignalModulation {
320    /// Construct BPSK(n), with code rate `n * 1.023 MHz`.
321    pub fn bpsk(order: f64) -> Result<Self, SignalAnalysisError> {
322        let order = positive(order, "order")?;
323        Ok(Self::pulse(
324            "BPSK(n)",
325            order * REFERENCE_CHIP_RATE_HZ,
326            vec![1.0],
327        ))
328    }
329
330    /// Construct BPSK(1), the GPS C/A spectral shape for long random codes.
331    pub fn bpsk1() -> Self {
332        Self::pulse("BPSK(1)", REFERENCE_CHIP_RATE_HZ, vec![1.0])
333    }
334
335    /// Construct BOC(m,n) with the requested square-wave phasing.
336    pub fn boc(m: f64, n: f64, phasing: BocPhasing) -> Result<Self, SignalAnalysisError> {
337        let m = positive(m, "m")?;
338        let n = positive(n, "n")?;
339        let code_rate_hz = n * REFERENCE_CHIP_RATE_HZ;
340        let amplitudes = match phasing {
341            BocPhasing::Sine => {
342                let subchips = integer_ratio(2.0 * m / n, "2m/n")?;
343                alternating_subchips(subchips)
344            }
345            BocPhasing::Cosine => {
346                let subchips = integer_ratio(4.0 * m / n, "4m/n")?;
347                cosine_subchips(subchips)
348            }
349        };
350        Ok(Self::pulse("BOC(m,n)", code_rate_hz, amplitudes))
351    }
352
353    /// Construct sine-phased BOC(m,n).
354    pub fn boc_sine(m: f64, n: f64) -> Result<Self, SignalAnalysisError> {
355        Self::boc(m, n, BocPhasing::Sine)
356    }
357
358    /// Construct cosine-phased BOC(m,n).
359    pub fn boc_cosine(m: f64, n: f64) -> Result<Self, SignalAnalysisError> {
360        Self::boc(m, n, BocPhasing::Cosine)
361    }
362
363    /// Construct the normalized MBOC(6,1,1/11) PSD.
364    pub fn mboc_6_1_1_over_11() -> Self {
365        Self::weighted_unchecked(
366            "MBOC(6,1,1/11)",
367            vec![
368                WeightedComponent::new(10.0 / 11.0, Self::boc11()),
369                WeightedComponent::new(1.0 / 11.0, Self::boc61()),
370            ],
371        )
372    }
373
374    /// Construct the GPS L1C pilot TMBOC(6,1,4/33) PSD.
375    pub fn tmboc_6_1_4_over_33() -> Self {
376        Self::weighted_unchecked(
377            "TMBOC(6,1,4/33)",
378            vec![
379                WeightedComponent::new(29.0 / 33.0, Self::boc11()),
380                WeightedComponent::new(4.0 / 33.0, Self::boc61()),
381            ],
382        )
383    }
384
385    /// Construct one Galileo E1 CBOC(6,1,1/11) chip pulse.
386    pub fn cboc_6_1_1_over_11(sign: CbocSign) -> Self {
387        let sign = match sign {
388            CbocSign::Plus => 1.0,
389            CbocSign::Minus => -1.0,
390        };
391        let low = libm::sqrt(10.0 / 11.0);
392        let high = libm::sqrt(1.0 / 11.0);
393        let mut amplitudes = Vec::with_capacity(12);
394        for k in 0..12 {
395            let boc11 = if k < 6 { 1.0 } else { -1.0 };
396            let boc61 = if k % 2 == 0 { 1.0 } else { -1.0 };
397            amplitudes.push(low * boc11 + sign * high * boc61);
398        }
399        Self::pulse("CBOC(6,1,1/11)", REFERENCE_CHIP_RATE_HZ, amplitudes)
400    }
401
402    /// Construct a custom normalized weighted PSD composite.
403    ///
404    /// The component weights must be finite, non-negative, and sum to `1`
405    /// within `1e-12`. The resulting PSD is the weighted sum of the component
406    /// PSDs, so it models time multiplexing or an explicitly published average
407    /// spectrum rather than an arbitrary coherent channel sum.
408    pub fn weighted(components: Vec<WeightedComponent>) -> Result<Self, SignalAnalysisError> {
409        validate_weighted_components(&components)?;
410        Ok(Self::weighted_unchecked("weighted composite", components))
411    }
412
413    /// Return the normalized power spectral density at offset frequency `f`.
414    ///
415    /// The return value has units of `1/Hz` and integrates to unit power over
416    /// infinite bandwidth for every shipped modulation.
417    pub fn psd_hz(&self, offset_hz: f64) -> Result<f64, SignalAnalysisError> {
418        let offset_hz = finite(offset_hz, "offset_hz")?;
419        match &self.kind {
420            ModulationKind::Pulse(pulse) => pulse.psd_hz(offset_hz),
421            ModulationKind::Weighted(components) => {
422                let mut acc = 0.0;
423                for component in components {
424                    acc += component.weight * component.modulation.psd_hz(offset_hz)?;
425                }
426                finite(acc, "psd_hz")
427            }
428        }
429    }
430
431    /// Return the code rate in hertz when the modulation has one unambiguous rate.
432    pub fn code_rate_hz(&self) -> Result<f64, SignalAnalysisError> {
433        match &self.kind {
434            ModulationKind::Pulse(pulse) => Ok(pulse.code_rate_hz),
435            ModulationKind::Weighted(components) => {
436                let mut iter = components.iter();
437                let first = iter
438                    .next()
439                    .ok_or(SignalAnalysisError::EmptyComponents)?
440                    .modulation
441                    .code_rate_hz()?;
442                for component in iter {
443                    let rate = component.modulation.code_rate_hz()?;
444                    if rate.to_bits() != first.to_bits() {
445                        return Err(invalid("code_rate_hz", "mixed component rates"));
446                    }
447                }
448                Ok(first)
449            }
450        }
451    }
452
453    /// Return a short stable label for the modulation.
454    pub fn label(&self) -> &'static str {
455        self.label
456    }
457
458    fn boc11() -> Self {
459        Self::pulse(
460            "BOCsin(1,1)",
461            REFERENCE_CHIP_RATE_HZ,
462            alternating_subchips(2),
463        )
464    }
465
466    fn boc61() -> Self {
467        Self::pulse(
468            "BOCsin(6,1)",
469            REFERENCE_CHIP_RATE_HZ,
470            alternating_subchips(12),
471        )
472    }
473
474    fn pulse(label: &'static str, code_rate_hz: f64, amplitudes: Vec<f64>) -> Self {
475        Self {
476            kind: ModulationKind::Pulse(ChipPulse {
477                code_rate_hz,
478                amplitudes,
479            }),
480            label,
481        }
482    }
483
484    fn weighted_unchecked(label: &'static str, components: Vec<WeightedComponent>) -> Self {
485        Self {
486            kind: ModulationKind::Weighted(components),
487            label,
488        }
489    }
490}
491
492impl ChipPulse {
493    fn psd_hz(&self, offset_hz: f64) -> Result<f64, SignalAnalysisError> {
494        let (real, imag) = self.transform(offset_hz);
495        let energy = self.energy_s();
496        let psd = (real * real + imag * imag) / energy;
497        finite(psd, "psd_hz")
498    }
499
500    fn transform(&self, offset_hz: f64) -> (f64, f64) {
501        let chip_s = 1.0 / self.code_rate_hz;
502        let dt = chip_s / self.amplitudes.len() as f64;
503        if offset_hz == 0.0 {
504            let real = self.amplitudes.iter().sum::<f64>() * dt;
505            return (real, 0.0);
506        }
507
508        let w = TWO_PI * offset_hz;
509        let mut real = 0.0;
510        let mut imag = 0.0;
511        for (idx, amplitude) in self.amplitudes.iter().enumerate() {
512            let a = idx as f64 * dt;
513            let b = (idx + 1) as f64 * dt;
514            real += amplitude * (libm::sin(w * b) - libm::sin(w * a)) / w;
515            imag += amplitude * (libm::cos(w * b) - libm::cos(w * a)) / w;
516        }
517        (real, imag)
518    }
519
520    fn energy_s(&self) -> f64 {
521        let chip_s = 1.0 / self.code_rate_hz;
522        let dt = chip_s / self.amplitudes.len() as f64;
523        self.amplitudes
524            .iter()
525            .map(|amplitude| amplitude * amplitude * dt)
526            .sum()
527    }
528}
529
530impl<'a> CorrelationKernel<'a> {
531    fn new(
532        modulation: &'a SignalModulation,
533        receiver_bandwidth_hz: f64,
534    ) -> Result<Self, SignalAnalysisError> {
535        let power = power_in_band(modulation, receiver_bandwidth_hz)?;
536        if power <= 0.0 {
537            return Err(invalid("receiver_bandwidth_hz", "zero in-band power"));
538        }
539        Ok(Self {
540            modulation,
541            receiver_bandwidth_hz,
542            power,
543        })
544    }
545
546    fn autocorrelation(&self, delay_s: f64) -> Result<f64, SignalAnalysisError> {
547        let corr = integrate_symmetric(self.receiver_bandwidth_hz, |f| {
548            let psd = self.modulation.psd_hz(f)?;
549            finite(psd * libm::cos(TWO_PI * f * delay_s), "autocorrelation")
550        })?;
551        finite(corr / self.power, "autocorrelation")
552    }
553}
554
555/// Integrate the normalized PSD over a two-sided receiver bandwidth.
556pub fn power_in_band(
557    modulation: &SignalModulation,
558    receiver_bandwidth_hz: f64,
559) -> Result<f64, SignalAnalysisError> {
560    let bandwidth = positive(receiver_bandwidth_hz, "receiver_bandwidth_hz")?;
561    integrate_symmetric(bandwidth, |f| modulation.psd_hz(f))
562}
563
564/// Compute the fraction of total signal power inside a two-sided bandwidth.
565pub fn fraction_power_in_band(
566    modulation: &SignalModulation,
567    receiver_bandwidth_hz: f64,
568) -> Result<f64, SignalAnalysisError> {
569    power_in_band(modulation, receiver_bandwidth_hz)
570}
571
572/// Compute the RMS, or Gabor, bandwidth over a two-sided receiver bandwidth.
573pub fn rms_bandwidth_hz(
574    modulation: &SignalModulation,
575    receiver_bandwidth_hz: f64,
576) -> Result<f64, SignalAnalysisError> {
577    let bandwidth = positive(receiver_bandwidth_hz, "receiver_bandwidth_hz")?;
578    let power = power_in_band(modulation, bandwidth)?;
579    if power <= 0.0 {
580        return Err(invalid("receiver_bandwidth_hz", "zero in-band power"));
581    }
582    let moment2 = integrate_symmetric(bandwidth, |f| {
583        let psd = modulation.psd_hz(f)?;
584        finite(f * f * psd, "rms_bandwidth_hz")
585    })?;
586    finite(libm::sqrt(moment2 / power), "rms_bandwidth_hz")
587}
588
589/// Compute the normalized in-band autocorrelation at a delay.
590///
591/// The value is normalized by the in-band power, so `delay_s = 0` returns `1`
592/// for every modulation with non-zero in-band power.
593pub fn autocorrelation(
594    modulation: &SignalModulation,
595    delay_s: f64,
596    receiver_bandwidth_hz: f64,
597) -> Result<f64, SignalAnalysisError> {
598    let delay_s = finite(delay_s, "delay_s")?;
599    let bandwidth = positive(receiver_bandwidth_hz, "receiver_bandwidth_hz")?;
600    let power = power_in_band(modulation, bandwidth)?;
601    if power <= 0.0 {
602        return Err(invalid("receiver_bandwidth_hz", "zero in-band power"));
603    }
604    let corr = integrate_symmetric(bandwidth, |f| {
605        let psd = modulation.psd_hz(f)?;
606        finite(psd * libm::cos(TWO_PI * f * delay_s), "autocorrelation")
607    })?;
608    finite(corr / power, "autocorrelation")
609}
610
611/// Compute the spectral separation coefficient between two modulations.
612///
613/// This is the inner product `int Gs(f) Gi(f) df` over the stated two-sided
614/// receiver bandwidth. Both PSDs are normalized over infinite bandwidth.
615pub fn spectral_separation_coefficient_hz(
616    desired: &SignalModulation,
617    interference: &SignalModulation,
618    receiver_bandwidth_hz: f64,
619) -> Result<f64, SignalAnalysisError> {
620    let bandwidth = positive(receiver_bandwidth_hz, "receiver_bandwidth_hz")?;
621    integrate_symmetric(bandwidth, |f| {
622        let desired_psd = desired.psd_hz(f)?;
623        let interference_psd = interference.psd_hz(f)?;
624        finite(
625            desired_psd * interference_psd,
626            "spectral_separation_coefficient_hz",
627        )
628    })
629}
630
631/// Compute a spectral separation coefficient in decibel-hertz.
632pub fn spectral_separation_coefficient_db_hz(
633    desired: &SignalModulation,
634    interference: &SignalModulation,
635    receiver_bandwidth_hz: f64,
636) -> Result<f64, SignalAnalysisError> {
637    let ssc = spectral_separation_coefficient_hz(desired, interference, receiver_bandwidth_hz)?;
638    if ssc <= 0.0 {
639        return Err(invalid(
640            "spectral_separation_coefficient_hz",
641            "not positive",
642        ));
643    }
644    finite(
645        10.0 * libm::log10(ssc),
646        "spectral_separation_coefficient_db_hz",
647    )
648}
649
650/// Compute the SSC against white interference normalized over the band.
651pub fn white_noise_spectral_separation_hz(
652    desired: &SignalModulation,
653    receiver_bandwidth_hz: f64,
654) -> Result<f64, SignalAnalysisError> {
655    let bandwidth = positive(receiver_bandwidth_hz, "receiver_bandwidth_hz")?;
656    let power = power_in_band(desired, bandwidth)?;
657    finite(power / bandwidth, "white_noise_spectral_separation_hz")
658}
659
660/// Compute effective C/N0 and degradation for finite-band interference.
661///
662/// The input `cn0_db_hz` is the desired carrier power divided by thermal-noise
663/// density before the listed interference terms are added. Interference powers
664/// are supplied as ratios to desired carrier power.
665pub fn effective_cn0_degradation(
666    desired: &SignalModulation,
667    cn0_db_hz: f64,
668    receiver_bandwidth_hz: f64,
669    interferences: &[InterferenceTerm],
670) -> Result<Cn0Degradation, SignalAnalysisError> {
671    let cn0_db_hz = finite(cn0_db_hz, "cn0_db_hz")?;
672    let cn0_hz = db_hz_to_hz(cn0_db_hz)?;
673    let bandwidth = positive(receiver_bandwidth_hz, "receiver_bandwidth_hz")?;
674    let signal_power = power_in_band(desired, bandwidth)?;
675    if signal_power <= 0.0 {
676        return Err(invalid("receiver_bandwidth_hz", "zero in-band power"));
677    }
678
679    let mut inverse_effective = 1.0 / cn0_hz;
680    for term in interferences {
681        let ratio = nonnegative(
682            term.power_ratio_to_carrier,
683            "interference_power_ratio_to_carrier",
684        )?;
685        let ssc =
686            spectral_separation_coefficient_hz(desired, &term.modulation, receiver_bandwidth_hz)?;
687        inverse_effective += ratio * ssc / signal_power;
688    }
689
690    if inverse_effective <= 0.0 {
691        return Err(invalid("effective_cn0_hz", "not positive"));
692    }
693    let effective_cn0_hz = 1.0 / inverse_effective;
694    let effective_cn0_db_hz = hz_to_db_hz(effective_cn0_hz)?;
695    Ok(Cn0Degradation {
696        effective_cn0_hz,
697        effective_cn0_db_hz,
698        degradation_db: cn0_db_hz - effective_cn0_db_hz,
699    })
700}
701
702/// Compute early-late DLL thermal-noise jitter for a modulation.
703pub fn dll_thermal_noise_jitter(
704    modulation: &SignalModulation,
705    options: DllTrackingOptions,
706    processing: DllProcessing,
707) -> Result<DllJitter, SignalAnalysisError> {
708    let code_rate_hz = modulation.code_rate_hz()?;
709    let cn0_hz = db_hz_to_hz(finite(options.cn0_db_hz, "cn0_db_hz")?)?;
710    let loop_factor = dll_loop_factor(options.loop_bandwidth_hz, options.integration_time_s)?;
711    let spacing_chips = positive(options.correlator_spacing_chips, "correlator_spacing_chips")?;
712    let spacing_s = spacing_chips / code_rate_hz;
713    let bandwidth = positive(options.receiver_bandwidth_hz, "receiver_bandwidth_hz")?;
714
715    let noise_integral = integrate_symmetric(bandwidth, |f| {
716        let psd = modulation.psd_hz(f)?;
717        let s = libm::sin(PI * f * spacing_s);
718        finite(psd * s * s, "dll_noise_integral")
719    })?;
720    let discriminator_integral = integrate_symmetric(bandwidth, |f| {
721        let psd = modulation.psd_hz(f)?;
722        finite(
723            f * psd * libm::sin(PI * f * spacing_s),
724            "dll_discriminator_integral",
725        )
726    })?;
727    if discriminator_integral.abs() <= DEGENERATE_DENOMINATOR {
728        return Err(invalid(
729            "correlator_spacing_chips",
730            "degenerate discriminator",
731        ));
732    }
733
734    let mut variance_s2 = loop_factor * noise_integral
735        / ((TWO_PI * TWO_PI) * cn0_hz * discriminator_integral * discriminator_integral);
736    let squaring_loss = match processing {
737        DllProcessing::Coherent => 1.0,
738        DllProcessing::NonCoherent => {
739            let integration_time_s = positive(options.integration_time_s, "integration_time_s")?;
740            let cos2_integral = integrate_symmetric(bandwidth, |f| {
741                let psd = modulation.psd_hz(f)?;
742                let c = libm::cos(PI * f * spacing_s);
743                finite(psd * c * c, "dll_squaring_loss")
744            })?;
745            let cos_integral = integrate_symmetric(bandwidth, |f| {
746                let psd = modulation.psd_hz(f)?;
747                finite(psd * libm::cos(PI * f * spacing_s), "dll_squaring_loss")
748            })?;
749            if cos_integral.abs() <= DEGENERATE_DENOMINATOR {
750                return Err(invalid(
751                    "correlator_spacing_chips",
752                    "degenerate discriminator",
753                ));
754            }
755            let loss =
756                1.0 + cos2_integral / (integration_time_s * cn0_hz * cos_integral * cos_integral);
757            variance_s2 *= loss;
758            finite(loss, "dll_squaring_loss")?
759        }
760    };
761    let seconds = finite(libm::sqrt(variance_s2), "dll_thermal_noise_jitter")?;
762    jitter_from_seconds(seconds, code_rate_hz, squaring_loss)
763}
764
765/// Compute the published lower bound for code-delay tracking jitter.
766pub fn dll_lower_bound(
767    modulation: &SignalModulation,
768    options: DllTrackingOptions,
769) -> Result<DllJitter, SignalAnalysisError> {
770    let code_rate_hz = modulation.code_rate_hz()?;
771    let cn0_hz = db_hz_to_hz(finite(options.cn0_db_hz, "cn0_db_hz")?)?;
772    let loop_factor = dll_loop_factor(options.loop_bandwidth_hz, options.integration_time_s)?;
773    let bandwidth = positive(options.receiver_bandwidth_hz, "receiver_bandwidth_hz")?;
774    let moment2 = integrate_symmetric(bandwidth, |f| {
775        let psd = modulation.psd_hz(f)?;
776        finite(f * f * psd, "dll_lower_bound")
777    })?;
778    if moment2 <= 0.0 {
779        return Err(invalid("receiver_bandwidth_hz", "zero spectral moment"));
780    }
781    let variance_s2 = loop_factor / ((TWO_PI * TWO_PI) * cn0_hz * moment2);
782    let seconds = finite(libm::sqrt(variance_s2), "dll_lower_bound")?;
783    jitter_from_seconds(seconds, code_rate_hz, 1.0)
784}
785
786/// Compute one-path early-late multipath error envelopes on a delay grid.
787///
788/// Delays must be finite, non-negative, and non-decreasing. The running average
789/// is the trapezoidal cumulative average of
790/// `max(abs(in_phase), abs(anti_phase))` over the supplied delay axis.
791pub fn multipath_error_envelope(
792    modulation: &SignalModulation,
793    options: MultipathOptions,
794    delay_chips: &[f64],
795) -> Result<Vec<MultipathEnvelopePoint>, SignalAnalysisError> {
796    let code_rate_hz = modulation.code_rate_hz()?;
797    let amplitude = unit_interval_exclusive_upper(
798        options.multipath_to_direct_ratio,
799        "multipath_to_direct_ratio",
800    )?;
801    let spacing_chips = positive(options.correlator_spacing_chips, "correlator_spacing_chips")?;
802    let bandwidth = positive(options.receiver_bandwidth_hz, "receiver_bandwidth_hz")?;
803    let spacing_s = spacing_chips / code_rate_hz;
804    let kernel = CorrelationKernel::new(modulation, bandwidth)?;
805    let mut previous_delay = 0.0;
806    let mut area_chips = 0.0;
807    let mut previous_envelope = 0.0;
808    let mut out = Vec::with_capacity(delay_chips.len());
809
810    for (idx, &delay_chips) in delay_chips.iter().enumerate() {
811        let delay_chips = nonnegative(delay_chips, "delay_chips")?;
812        if idx > 0 && delay_chips < previous_delay {
813            return Err(invalid("delay_chips", "not non-decreasing"));
814        }
815        let delay_s = delay_chips / code_rate_hz;
816        let in_phase_s =
817            multipath_tracking_error_s(&kernel, amplitude, delay_s, delay_chips, spacing_s, 1.0)?;
818        let anti_phase_s =
819            multipath_tracking_error_s(&kernel, amplitude, delay_s, delay_chips, spacing_s, -1.0)?;
820        let in_phase_chips = in_phase_s * code_rate_hz;
821        let anti_phase_chips = anti_phase_s * code_rate_hz;
822        let envelope = in_phase_chips.abs().max(anti_phase_chips.abs());
823        if idx == 0 {
824            area_chips = 0.0;
825        } else {
826            area_chips += 0.5 * (previous_envelope + envelope) * (delay_chips - previous_delay);
827        }
828        let running_average_chips = if delay_chips > 0.0 {
829            area_chips / delay_chips
830        } else {
831            envelope
832        };
833        out.push(MultipathEnvelopePoint {
834            delay_chips,
835            delay_s,
836            in_phase_chips,
837            in_phase_s,
838            in_phase_m: in_phase_s * C_M_S,
839            anti_phase_chips,
840            anti_phase_s,
841            anti_phase_m: anti_phase_s * C_M_S,
842            running_average_chips,
843            running_average_s: running_average_chips / code_rate_hz,
844            running_average_m: running_average_chips / code_rate_hz * C_M_S,
845        });
846        previous_delay = delay_chips;
847        previous_envelope = envelope;
848    }
849
850    Ok(out)
851}
852
853fn multipath_tracking_error_s(
854    kernel: &CorrelationKernel<'_>,
855    amplitude: f64,
856    delay_s: f64,
857    delay_chips: f64,
858    spacing_s: f64,
859    phase_sign: f64,
860) -> Result<f64, SignalAnalysisError> {
861    let search_half_width = delay_s + 2.0 * spacing_s + 2.0 / kernel.modulation.code_rate_hz()?;
862    let left = -search_half_width;
863    let right = search_half_width;
864    let step = (right - left) / ROOT_SCAN_STEPS as f64;
865    let mut best_root = None;
866    let mut best_abs = f64::INFINITY;
867    let mut prev_x = left;
868    let mut prev_y =
869        early_late_discriminator(kernel, amplitude, delay_s, spacing_s, phase_sign, prev_x)?;
870    if prev_y == 0.0 {
871        best_root = Some(prev_x);
872        best_abs = prev_x.abs();
873    }
874
875    for i in 1..=ROOT_SCAN_STEPS {
876        let x = left + i as f64 * step;
877        let y = early_late_discriminator(kernel, amplitude, delay_s, spacing_s, phase_sign, x)?;
878        if y == 0.0 {
879            let abs_x = x.abs();
880            if abs_x < best_abs {
881                best_root = Some(x);
882                best_abs = abs_x;
883            }
884        } else if (prev_y < 0.0 && y > 0.0) || (prev_y > 0.0 && y < 0.0) {
885            let root = bisect_root(
886                |candidate| {
887                    early_late_discriminator(
888                        kernel, amplitude, delay_s, spacing_s, phase_sign, candidate,
889                    )
890                },
891                prev_x,
892                x,
893            )?;
894            let abs_root = root.abs();
895            if abs_root < best_abs {
896                best_root = Some(root);
897                best_abs = abs_root;
898            }
899        }
900        prev_x = x;
901        prev_y = y;
902    }
903
904    best_root.ok_or(SignalAnalysisError::NoDiscriminatorRoot {
905        delay_chips,
906        phase_sign,
907    })
908}
909
910fn early_late_discriminator(
911    kernel: &CorrelationKernel<'_>,
912    amplitude: f64,
913    delay_s: f64,
914    spacing_s: f64,
915    phase_sign: f64,
916    error_s: f64,
917) -> Result<f64, SignalAnalysisError> {
918    let half = 0.5 * spacing_s;
919    let early = kernel.autocorrelation(error_s + half)?
920        + phase_sign * amplitude * kernel.autocorrelation(error_s - delay_s + half)?;
921    let late = kernel.autocorrelation(error_s - half)?
922        + phase_sign * amplitude * kernel.autocorrelation(error_s - delay_s - half)?;
923    finite(early * early - late * late, "early_late_discriminator")
924}
925
926fn bisect_root<F>(mut f: F, mut left: f64, mut right: f64) -> Result<f64, SignalAnalysisError>
927where
928    F: FnMut(f64) -> Result<f64, SignalAnalysisError>,
929{
930    let mut f_left = f(left)?;
931    for _ in 0..ROOT_BISECTION_STEPS {
932        let mid = 0.5 * (left + right);
933        let f_mid = f(mid)?;
934        if f_mid == 0.0 {
935            return Ok(mid);
936        }
937        if (f_left < 0.0 && f_mid > 0.0) || (f_left > 0.0 && f_mid < 0.0) {
938            right = mid;
939        } else {
940            left = mid;
941            f_left = f_mid;
942        }
943    }
944    Ok(0.5 * (left + right))
945}
946
947fn jitter_from_seconds(
948    seconds: f64,
949    code_rate_hz: f64,
950    squaring_loss: f64,
951) -> Result<DllJitter, SignalAnalysisError> {
952    Ok(DllJitter {
953        seconds,
954        chips: seconds * code_rate_hz,
955        meters: seconds * C_M_S,
956        squaring_loss,
957    })
958}
959
960fn dll_loop_factor(
961    loop_bandwidth_hz: f64,
962    integration_time_s: f64,
963) -> Result<f64, SignalAnalysisError> {
964    let loop_bandwidth_hz = positive(loop_bandwidth_hz, "loop_bandwidth_hz")?;
965    let integration_time_s = positive(integration_time_s, "integration_time_s")?;
966    let factor = loop_bandwidth_hz * (1.0 - 0.5 * loop_bandwidth_hz * integration_time_s);
967    if factor <= 0.0 {
968        return Err(invalid("loop_bandwidth_hz", "loop factor not positive"));
969    }
970    finite(factor, "dll_loop_factor")
971}
972
973fn integrate_symmetric<F>(bandwidth_hz: f64, f: F) -> Result<f64, SignalAnalysisError>
974where
975    F: FnMut(f64) -> Result<f64, SignalAnalysisError>,
976{
977    let half_band = 0.5 * bandwidth_hz;
978    let integral = integrate_interval(0.0, half_band, f)?;
979    finite(2.0 * integral, "quadrature")
980}
981
982fn integrate_interval<F>(a: f64, b: f64, mut f: F) -> Result<f64, SignalAnalysisError>
983where
984    F: FnMut(f64) -> Result<f64, SignalAnalysisError>,
985{
986    if b < a {
987        return Err(invalid("quadrature_interval", "out of range"));
988    }
989    if b == a {
990        return Ok(0.0);
991    }
992    let width = b - a;
993    let panels = panel_count(width)?;
994    let panel_width = width / panels as f64;
995    let mut acc = 0.0;
996    for panel in 0..panels {
997        let pa = a + panel as f64 * panel_width;
998        let pb = if panel + 1 == panels {
999            b
1000        } else {
1001            pa + panel_width
1002        };
1003        acc += integrate_panel(pa, pb, &mut f)?;
1004    }
1005    finite(acc, "quadrature")
1006}
1007
1008fn integrate_panel<F>(a: f64, b: f64, f: &mut F) -> Result<f64, SignalAnalysisError>
1009where
1010    F: FnMut(f64) -> Result<f64, SignalAnalysisError>,
1011{
1012    let mid = 0.5 * (a + b);
1013    let half = 0.5 * (b - a);
1014    let mut acc = 0.0;
1015    for (&node, &weight) in GL64_POSITIVE_NODES.iter().zip(GL64_POSITIVE_WEIGHTS.iter()) {
1016        acc += weight * (f(mid - half * node)? + f(mid + half * node)?);
1017    }
1018    Ok(half * acc)
1019}
1020
1021fn panel_count(width_hz: f64) -> Result<usize, SignalAnalysisError> {
1022    let panels = libm::ceil(width_hz / QUADRATURE_PANEL_HZ).max(1.0);
1023    if panels > MAX_QUADRATURE_PANELS as f64 {
1024        return Err(invalid("receiver_bandwidth_hz", "too wide"));
1025    }
1026    Ok(panels as usize)
1027}
1028
1029fn alternating_subchips(subchips: usize) -> Vec<f64> {
1030    (0..subchips)
1031        .map(|idx| if idx % 2 == 0 { 1.0 } else { -1.0 })
1032        .collect()
1033}
1034
1035fn cosine_subchips(subchips: usize) -> Vec<f64> {
1036    (0..subchips)
1037        .map(|idx| match idx % 4 {
1038            0 | 3 => 1.0,
1039            _ => -1.0,
1040        })
1041        .collect()
1042}
1043
1044fn integer_ratio(value: f64, field: &'static str) -> Result<usize, SignalAnalysisError> {
1045    let value = positive(value, field)?;
1046    let rounded = libm::round(value);
1047    if (value - rounded).abs() > INTEGER_RATIO_TOL {
1048        return Err(invalid(field, "not an integer"));
1049    }
1050    if rounded < 1.0 || rounded > MAX_SUBCHIPS as f64 {
1051        return Err(invalid(field, "out of range"));
1052    }
1053    Ok(rounded as usize)
1054}
1055
1056fn validate_weighted_components(
1057    components: &[WeightedComponent],
1058) -> Result<(), SignalAnalysisError> {
1059    if components.is_empty() {
1060        return Err(SignalAnalysisError::EmptyComponents);
1061    }
1062    if components.len() > MAX_WEIGHTED_COMPONENTS {
1063        return Err(invalid("components", "too many"));
1064    }
1065    let mut sum = 0.0;
1066    for component in components {
1067        sum += nonnegative(component.weight, "component_weight")?;
1068    }
1069    if (sum - 1.0).abs() > WEIGHT_SUM_TOL {
1070        return Err(invalid("component_weight", "weights must sum to one"));
1071    }
1072    Ok(())
1073}
1074
1075fn db_hz_to_hz(db_hz: f64) -> Result<f64, SignalAnalysisError> {
1076    let hz = libm::pow(10.0, db_hz / 10.0);
1077    if hz > 0.0 {
1078        finite(hz, "cn0_hz")
1079    } else {
1080        Err(invalid("cn0_db_hz", "out of range"))
1081    }
1082}
1083
1084fn hz_to_db_hz(hz: f64) -> Result<f64, SignalAnalysisError> {
1085    let hz = positive(hz, "cn0_hz")?;
1086    finite(10.0 * libm::log10(hz), "cn0_db_hz")
1087}
1088
1089fn positive(x: f64, field: &'static str) -> Result<f64, SignalAnalysisError> {
1090    validate::finite_positive(x, field).map_err(map_analysis_input)
1091}
1092
1093fn nonnegative(x: f64, field: &'static str) -> Result<f64, SignalAnalysisError> {
1094    validate::finite_nonneg(x, field).map_err(map_analysis_input)
1095}
1096
1097fn finite(x: f64, field: &'static str) -> Result<f64, SignalAnalysisError> {
1098    validate::finite(x, field).map_err(map_analysis_input)
1099}
1100
1101fn unit_interval_exclusive_upper(x: f64, field: &'static str) -> Result<f64, SignalAnalysisError> {
1102    validate::finite_in_range_exclusive_upper(x, 0.0, 1.0, field).map_err(map_analysis_input)
1103}
1104
1105fn map_analysis_input(error: validate::FieldError) -> SignalAnalysisError {
1106    invalid(error.field(), error.reason())
1107}
1108
1109fn invalid(field: &'static str, reason: &'static str) -> SignalAnalysisError {
1110    SignalAnalysisError::InvalidInput { field, reason }
1111}
1112
1113#[cfg(test)]
1114mod tests {
1115    //! Provenance:
1116    //! - PSD definitions follow Betz, "The Offset Carrier Modulation for GPS
1117    //!   Modernization", ION NTM 1999, and the Navipedia BOC sine/cosine PSD
1118    //!   derivations by J. A. Avila Rodriguez.
1119    //! - SSC fixture rows are transcribed from Betz, "Intersystem and
1120    //!   Intrasystem Interference with Signal Imperfections", Table 3, using
1121    //!   the 24 MHz receiver-bandwidth rows and the table note for theoretical
1122    //!   BPSK self SSC.
1123    //! - DLL formulas follow Betz and Kolodziejski as reproduced in Gazda et
1124    //!   al., "Simulation of Code Tracking Error Variance with Early-Late DLL
1125    //!   for BOC Modulation", equations 13 and 14.
1126    //! - MBOC/CBOC constants follow public GPS L1C IS-GPS-800 and the Galileo
1127    //!   MBOC/CBOC descriptions in Inside GNSS, "The MBOC Modulation".
1128    //! - The cosine-BOC fixture uses Galileo E6 PRS BOCcos(10,5), cited in
1129    //!   public Galileo signal-plan descriptions and Inside GNSS MBOC notes.
1130
1131    use super::*;
1132
1133    const WIDE_BAND_HZ: f64 = 512.0 * REFERENCE_CHIP_RATE_HZ;
1134    const HIGH_COMPONENT_WIDE_BAND_HZ: f64 = 2_048.0 * REFERENCE_CHIP_RATE_HZ;
1135    const COSINE_WIDE_BAND_HZ: f64 = 16_384.0 * REFERENCE_CHIP_RATE_HZ;
1136    const MULTIPATH_BAND_HZ: f64 = 64.0 * REFERENCE_CHIP_RATE_HZ;
1137
1138    #[test]
1139    fn psd_closed_forms_match_pinned_values() {
1140        let bpsk = SignalModulation::bpsk1();
1141        let boc11 = SignalModulation::boc_sine(1.0, 1.0).unwrap();
1142        let boc61 = SignalModulation::boc_sine(6.0, 1.0).unwrap();
1143        let boc_cos_10_5 = SignalModulation::boc_cosine(10.0, 5.0).unwrap();
1144        let cboc_plus = SignalModulation::cboc_6_1_1_over_11(CbocSign::Plus);
1145        let cboc_minus = SignalModulation::cboc_6_1_1_over_11(CbocSign::Minus);
1146
1147        // Betz random-code BPSK PSD with the public GPS C/A BPSK(1) rate.
1148        assert_close(
1149            bpsk.psd_hz(0.0).unwrap(),
1150            9.775171065493646e-7,
1151            1.0e-21,
1152            "BPSK(1) PSD at band center",
1153        );
1154        // Betz/Navipedia sine-BOC expression for the public BOC(1,1) family.
1155        assert_close(
1156            boc11.psd_hz(0.5 * REFERENCE_CHIP_RATE_HZ).unwrap(),
1157            3.9617276106485926e-7,
1158            1.0e-21,
1159            "BOCsin(1,1) PSD at 0.5 chip-rate offset",
1160        );
1161        // Betz/Navipedia sine-BOC expression at the BOC(6,1) high-frequency lobe.
1162        assert_close(
1163            boc61.psd_hz(5.5 * REFERENCE_CHIP_RATE_HZ).unwrap(),
1164            1.8890394898251426e-7,
1165            1.0e-21,
1166            "BOCsin(6,1) PSD at 5.5 chip-rate offset",
1167        );
1168        // Navipedia cosine-BOC expression for Galileo E6 PRS BOCcos(10,5).
1169        assert_close(
1170            boc_cos_10_5.psd_hz(10.0 * REFERENCE_CHIP_RATE_HZ).unwrap(),
1171            7.923455221297186e-8,
1172            1.0e-22,
1173            "BOCcos(10,5) PSD at 10.23 MHz offset",
1174        );
1175        // Inside GNSS MBOC/CBOC weights for the Galileo E1 CBOC plus chip pulse.
1176        assert_close(
1177            cboc_plus.psd_hz(0.5 * REFERENCE_CHIP_RATE_HZ).unwrap(),
1178            3.907695366836319e-7,
1179            1.0e-20,
1180            "CBOC plus PSD at 0.5 chip-rate offset",
1181        );
1182        // Inside GNSS MBOC/CBOC weights for the Galileo E1 CBOC minus chip pulse.
1183        assert_close(
1184            cboc_minus.psd_hz(0.5 * REFERENCE_CHIP_RATE_HZ).unwrap(),
1185            3.307930501412689e-7,
1186            1.0e-21,
1187            "CBOC minus PSD at 0.5 chip-rate offset",
1188        );
1189    }
1190
1191    #[test]
1192    fn psds_have_unit_power_to_fixed_wide_band() {
1193        let cases = [
1194            (SignalModulation::bpsk1(), WIDE_BAND_HZ, 1.3e-3),
1195            (
1196                SignalModulation::boc_sine(1.0, 1.0).unwrap(),
1197                WIDE_BAND_HZ,
1198                1.3e-3,
1199            ),
1200            (
1201                SignalModulation::boc_cosine(10.0, 5.0).unwrap(),
1202                COSINE_WIDE_BAND_HZ,
1203                2.0e-3,
1204            ),
1205            (
1206                SignalModulation::mboc_6_1_1_over_11(),
1207                HIGH_COMPONENT_WIDE_BAND_HZ,
1208                1.0e-3,
1209            ),
1210            (
1211                SignalModulation::tmboc_6_1_4_over_33(),
1212                HIGH_COMPONENT_WIDE_BAND_HZ,
1213                1.0e-3,
1214            ),
1215            (
1216                SignalModulation::cboc_6_1_1_over_11(CbocSign::Plus),
1217                HIGH_COMPONENT_WIDE_BAND_HZ,
1218                1.0e-3,
1219            ),
1220        ];
1221        for (modulation, bandwidth_hz, tolerance) in cases {
1222            let power = power_in_band(&modulation, bandwidth_hz).unwrap();
1223            assert!(
1224                (power - 1.0).abs() < tolerance,
1225                "{} power {power}",
1226                modulation.label()
1227            );
1228        }
1229    }
1230
1231    #[test]
1232    fn mboc_and_tmboc_weights_are_pinned_to_public_signal_definitions() {
1233        let f = 5.5 * REFERENCE_CHIP_RATE_HZ;
1234        let boc11 = SignalModulation::boc_sine(1.0, 1.0).unwrap();
1235        let boc61 = SignalModulation::boc_sine(6.0, 1.0).unwrap();
1236        let mboc = SignalModulation::mboc_6_1_1_over_11();
1237        let tmboc = SignalModulation::tmboc_6_1_4_over_33();
1238
1239        let mboc_expected =
1240            10.0 / 11.0 * boc11.psd_hz(f).unwrap() + 1.0 / 11.0 * boc61.psd_hz(f).unwrap();
1241        let tmboc_expected =
1242            29.0 / 33.0 * boc11.psd_hz(f).unwrap() + 4.0 / 33.0 * boc61.psd_hz(f).unwrap();
1243
1244        assert_eq!(mboc.psd_hz(f).unwrap().to_bits(), mboc_expected.to_bits());
1245        assert_eq!(tmboc.psd_hz(f).unwrap().to_bits(), tmboc_expected.to_bits());
1246    }
1247
1248    #[test]
1249    fn ssc_rows_match_betz_l1_table_values() {
1250        let bpsk = SignalModulation::bpsk1();
1251        let boc11 = SignalModulation::boc_sine(1.0, 1.0).unwrap();
1252
1253        let rows = [
1254            (
1255                "BPSK theoretical self, Betz Table 3 note",
1256                &bpsk,
1257                &bpsk,
1258                -61.8,
1259                0.08,
1260            ),
1261            (
1262                "C/A desired, BOC(1,1) interference",
1263                &bpsk,
1264                &boc11,
1265                -67.8,
1266                0.12,
1267            ),
1268            (
1269                "BOC(1,1) desired, C/A interference",
1270                &boc11,
1271                &bpsk,
1272                -67.9,
1273                0.12,
1274            ),
1275            ("BOC(1,1) self", &boc11, &boc11, -64.8, 0.12),
1276        ];
1277
1278        for (label, desired, interference, expected_db_hz, tolerance_db) in rows {
1279            let got = spectral_separation_coefficient_db_hz(
1280                desired,
1281                interference,
1282                BETZ_L1_RECEIVER_BANDWIDTH_HZ,
1283            )
1284            .unwrap();
1285            assert!(
1286                (got - expected_db_hz).abs() <= tolerance_db,
1287                "{label}: got {got}, want {expected_db_hz}"
1288            );
1289        }
1290    }
1291
1292    #[test]
1293    fn dll_jitter_reduces_to_textbook_bpsk_case() {
1294        let bpsk = SignalModulation::bpsk1();
1295        let options = DllTrackingOptions {
1296            cn0_db_hz: 45.0,
1297            loop_bandwidth_hz: 1.0,
1298            integration_time_s: 0.02,
1299            correlator_spacing_chips: 0.5,
1300            receiver_bandwidth_hz: WIDE_BAND_HZ,
1301        };
1302        let got = dll_thermal_noise_jitter(&bpsk, options, DllProcessing::Coherent).unwrap();
1303        let cn0 = db_hz_to_hz(options.cn0_db_hz).unwrap();
1304        let loop_factor = options.loop_bandwidth_hz
1305            * (1.0 - 0.5 * options.loop_bandwidth_hz * options.integration_time_s);
1306        let expected_chips =
1307            libm::sqrt(loop_factor * options.correlator_spacing_chips / (2.0 * cn0));
1308        assert_close(
1309            got.chips,
1310            expected_chips,
1311            8.0e-5,
1312            "BPSK coherent DLL jitter",
1313        );
1314
1315        let noncoherent =
1316            dll_thermal_noise_jitter(&bpsk, options, DllProcessing::NonCoherent).unwrap();
1317        let expected_loss = 1.0
1318            + 1.0
1319                / (options.integration_time_s
1320                    * cn0
1321                    * (1.0 - 0.5 * options.correlator_spacing_chips));
1322        assert_close(
1323            noncoherent.squaring_loss,
1324            expected_loss,
1325            4.0e-4,
1326            "BPSK noncoherent squaring loss",
1327        );
1328    }
1329
1330    #[test]
1331    fn lower_bound_tracks_rms_bandwidth_definition() {
1332        let boc11 = SignalModulation::boc_sine(1.0, 1.0).unwrap();
1333        let options = DllTrackingOptions {
1334            cn0_db_hz: 45.0,
1335            loop_bandwidth_hz: 1.0,
1336            integration_time_s: 0.02,
1337            correlator_spacing_chips: 0.1,
1338            receiver_bandwidth_hz: BETZ_L1_RECEIVER_BANDWIDTH_HZ,
1339        };
1340        let bound = dll_lower_bound(&boc11, options).unwrap();
1341        let power = power_in_band(&boc11, options.receiver_bandwidth_hz).unwrap();
1342        let beta = rms_bandwidth_hz(&boc11, options.receiver_bandwidth_hz).unwrap();
1343        let cn0 = db_hz_to_hz(options.cn0_db_hz).unwrap();
1344        let loop_factor = options.loop_bandwidth_hz
1345            * (1.0 - 0.5 * options.loop_bandwidth_hz * options.integration_time_s);
1346        let expected_s = libm::sqrt(loop_factor / ((TWO_PI * TWO_PI) * cn0 * beta * beta * power));
1347        assert_close(bound.seconds, expected_s, 1.0e-18, "DLL lower bound");
1348    }
1349
1350    #[test]
1351    fn effective_cn0_degradation_matches_closed_form() {
1352        let desired = SignalModulation::bpsk1();
1353        let interference = SignalModulation::boc_sine(1.0, 1.0).unwrap();
1354        let cn0_db_hz = 45.0;
1355        let ratio = 10.0_f64.powf(-20.0 / 10.0);
1356        let result = effective_cn0_degradation(
1357            &desired,
1358            cn0_db_hz,
1359            BETZ_L1_RECEIVER_BANDWIDTH_HZ,
1360            &[InterferenceTerm::new(interference.clone(), ratio)],
1361        )
1362        .unwrap();
1363        let cn0 = db_hz_to_hz(cn0_db_hz).unwrap();
1364        let signal_power = power_in_band(&desired, BETZ_L1_RECEIVER_BANDWIDTH_HZ).unwrap();
1365        let ssc = spectral_separation_coefficient_hz(
1366            &desired,
1367            &interference,
1368            BETZ_L1_RECEIVER_BANDWIDTH_HZ,
1369        )
1370        .unwrap();
1371        let expected = 1.0 / (1.0 / cn0 + ratio * ssc / signal_power);
1372        assert_close(result.effective_cn0_hz, expected, 1.0e-11, "effective C/N0");
1373    }
1374
1375    #[test]
1376    fn bpsk_multipath_envelope_matches_classic_canonical_case() {
1377        let bpsk = SignalModulation::bpsk1();
1378        let points = multipath_error_envelope(
1379            &bpsk,
1380            MultipathOptions {
1381                multipath_to_direct_ratio: 0.5,
1382                correlator_spacing_chips: 1.0,
1383                receiver_bandwidth_hz: MULTIPATH_BAND_HZ,
1384            },
1385            &[0.0, 0.5, 1.0],
1386        )
1387        .unwrap();
1388        assert_close(
1389            points[1].in_phase_chips,
1390            1.0 / 6.0,
1391            2.5e-3,
1392            "BPSK in-phase multipath envelope at 0.5 chip",
1393        );
1394        assert!(
1395            points[1].anti_phase_chips < 0.0,
1396            "anti-phase envelope should be negative for the canonical delay"
1397        );
1398        assert!(
1399            points[2].running_average_chips > 0.0,
1400            "running average envelope should accumulate"
1401        );
1402    }
1403
1404    #[test]
1405    fn multipath_envelope_rejects_complete_cancellation_case() {
1406        let bpsk = SignalModulation::bpsk1();
1407        let got = multipath_error_envelope(
1408            &bpsk,
1409            MultipathOptions {
1410                multipath_to_direct_ratio: 1.0,
1411                correlator_spacing_chips: 1.0,
1412                receiver_bandwidth_hz: MULTIPATH_BAND_HZ,
1413            },
1414            &[0.0],
1415        );
1416
1417        assert_eq!(
1418            got,
1419            Err(invalid("multipath_to_direct_ratio", "out of range"))
1420        );
1421    }
1422
1423    fn assert_close(got: f64, expected: f64, tolerance: f64, label: &str) {
1424        assert!(
1425            (got - expected).abs() <= tolerance,
1426            "{label}: got {got:e}, want {expected:e}, tolerance {tolerance:e}"
1427        );
1428    }
1429}