1use crate::constants::C_M_S;
22use crate::validate;
23
24pub const REFERENCE_CHIP_RATE_HZ: f64 = 1_023_000.0;
26
27pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
116pub enum BocPhasing {
117 Sine,
119 Cosine,
121}
122
123#[derive(Debug, Clone, Copy, PartialEq, Eq)]
125pub enum CbocSign {
126 Plus,
128 Minus,
130}
131
132#[derive(Debug, Clone, PartialEq)]
134pub struct SignalModulation {
135 kind: ModulationKind,
136 label: &'static str,
137}
138
139#[derive(Debug, Clone, PartialEq)]
141pub struct WeightedComponent {
142 pub weight: f64,
144 pub modulation: SignalModulation,
146}
147
148#[derive(Debug, Clone, PartialEq)]
150pub struct InterferenceTerm {
151 pub modulation: SignalModulation,
153 pub power_ratio_to_carrier: f64,
155}
156
157#[derive(Debug, Clone, Copy, PartialEq)]
159pub struct Cn0Degradation {
160 pub effective_cn0_hz: f64,
162 pub effective_cn0_db_hz: f64,
164 pub degradation_db: f64,
166}
167
168#[derive(Debug, Clone, Copy, PartialEq, Eq)]
170pub enum DllProcessing {
171 Coherent,
173 NonCoherent,
175}
176
177#[derive(Debug, Clone, Copy, PartialEq)]
179pub struct DllTrackingOptions {
180 pub cn0_db_hz: f64,
182 pub loop_bandwidth_hz: f64,
184 pub integration_time_s: f64,
186 pub correlator_spacing_chips: f64,
188 pub receiver_bandwidth_hz: f64,
190}
191
192#[derive(Debug, Clone, Copy, PartialEq)]
194pub struct DllJitter {
195 pub seconds: f64,
197 pub chips: f64,
199 pub meters: f64,
201 pub squaring_loss: f64,
203}
204
205#[derive(Debug, Clone, Copy, PartialEq)]
207pub struct MultipathOptions {
208 pub multipath_to_direct_ratio: f64,
210 pub correlator_spacing_chips: f64,
212 pub receiver_bandwidth_hz: f64,
214}
215
216#[derive(Debug, Clone, Copy, PartialEq)]
218pub struct MultipathEnvelopePoint {
219 pub delay_chips: f64,
221 pub delay_s: f64,
223 pub in_phase_chips: f64,
225 pub in_phase_s: f64,
227 pub in_phase_m: f64,
229 pub anti_phase_chips: f64,
231 pub anti_phase_s: f64,
233 pub anti_phase_m: f64,
235 pub running_average_chips: f64,
237 pub running_average_s: f64,
239 pub running_average_m: f64,
241}
242
243#[derive(Debug, Clone, PartialEq)]
245pub enum SignalAnalysisError {
246 InvalidInput {
248 field: &'static str,
250 reason: &'static str,
252 },
253 EmptyComponents,
255 NoDiscriminatorRoot {
257 delay_chips: f64,
259 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 pub fn new(weight: f64, modulation: SignalModulation) -> Self {
305 Self { weight, modulation }
306 }
307}
308
309impl InterferenceTerm {
310 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 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 pub fn bpsk1() -> Self {
332 Self::pulse("BPSK(1)", REFERENCE_CHIP_RATE_HZ, vec![1.0])
333 }
334
335 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 pub fn boc_sine(m: f64, n: f64) -> Result<Self, SignalAnalysisError> {
355 Self::boc(m, n, BocPhasing::Sine)
356 }
357
358 pub fn boc_cosine(m: f64, n: f64) -> Result<Self, SignalAnalysisError> {
360 Self::boc(m, n, BocPhasing::Cosine)
361 }
362
363 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 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 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 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 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 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 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
555pub 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
564pub 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
572pub 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
589pub 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
611pub 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
631pub 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
650pub 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
660pub 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
702pub 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
765pub 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
786pub 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 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 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 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 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 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 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 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}