1use crate::jamming::{
89 effective_cn0_dbhz, j_over_s_db, nominal_cn0_dbhz, q_factor, CA_CHIP_RATE_HZ, C_M_PER_S, L1_HZ,
90};
91use crate::portable_math::PortableFloat;
92use serde::{Deserialize, Serialize};
93
94pub const COSTAS_THRESHOLD_DEG: f64 = 45.0;
100
101pub const SECOND_ORDER_BN_OVER_WN: f64 = 0.53;
105
106pub const RADIUS_BRACKET_MIN_M: f64 = 1.0;
108
109pub const RADIUS_BRACKET_MAX_M: f64 = 1.0e10;
111
112pub const CN0_SEARCH_MIN_DBHZ: f64 = -20.0;
114
115pub const CN0_SEARCH_MAX_DBHZ: f64 = 80.0;
117
118pub const MAX_DETECTOR_STEPS: usize = 2_000_000;
121
122pub const MAX_AXIS_LEN: usize = 64;
124
125#[inline]
126fn lin(db: f64) -> f64 {
127 10f64.ppowf(db / 10.0)
128}
129
130pub fn pll_measurement_jitter_rad(cn0_dbhz: f64, t_s: f64) -> f64 {
144 let c = lin(cn0_dbhz);
145 let t = t_s.max(f64::MIN_POSITIVE);
146 ((1.0 / (2.0 * c * t)) * (1.0 + 1.0 / (2.0 * t * c))).sqrt()
147}
148
149pub fn pll_thermal_jitter_rad(cn0_dbhz: f64, bn_hz: f64, t_s: f64) -> f64 {
155 let c = lin(cn0_dbhz);
156 let t = t_s.max(f64::MIN_POSITIVE);
157 ((bn_hz.max(0.0) / c) * (1.0 + 1.0 / (2.0 * t * c))).sqrt()
158}
159
160pub fn dll_measurement_jitter_chips(cn0_dbhz: f64, t_s: f64, spacing_chips: f64) -> f64 {
168 let c = lin(cn0_dbhz);
169 let t = t_s.max(f64::MIN_POSITIVE);
170 let d = spacing_chips.clamp(1e-6, 1.9);
171 ((d / (4.0 * t * c)) * (1.0 + 2.0 / ((2.0 - d) * t * c))).sqrt()
172}
173
174pub fn dll_thermal_jitter_chips(cn0_dbhz: f64, bn_hz: f64, t_s: f64, spacing_chips: f64) -> f64 {
177 let c = lin(cn0_dbhz);
178 let t = t_s.max(f64::MIN_POSITIVE);
179 let d = spacing_chips.clamp(1e-6, 1.9);
180 ((d * bn_hz.max(0.0) / (2.0 * c)) * (1.0 + 2.0 / ((2.0 - d) * t * c))).sqrt()
181}
182
183pub fn pll_dynamic_stress_deg(doppler_rate_hz_per_s: f64, bn_hz: f64) -> f64 {
191 let b = bn_hz.max(f64::MIN_POSITIVE);
192 360.0 * doppler_rate_hz_per_s * SECOND_ORDER_BN_OVER_WN * SECOND_ORDER_BN_OVER_WN / (b * b)
193}
194
195pub fn dll_ramp_lag_chips(code_slew_chips_per_s: f64, bn_hz: f64) -> f64 {
198 code_slew_chips_per_s / (4.0 * bn_hz.max(f64::MIN_POSITIVE))
199}
200
201pub fn early_late_amplitude_discriminator_slope(spacing_chips: f64) -> f64 {
207 1.0 / (2.0 - spacing_chips.clamp(1e-6, 1.9))
208}
209
210pub fn equivalent_first_order_bandwidth_hz(gain: f64, t_s: f64, discriminator_slope: f64) -> f64 {
216 gain * discriminator_slope / (4.0 * t_s.max(f64::MIN_POSITIVE))
217}
218
219fn bisect_cn0(f: impl Fn(f64) -> f64) -> Option<f64> {
224 let (mut lo, mut hi) = (CN0_SEARCH_MIN_DBHZ, CN0_SEARCH_MAX_DBHZ);
225 let (flo, fhi) = (f(lo), f(hi));
226 if !flo.is_finite() || !fhi.is_finite() || flo > 0.0 || fhi < 0.0 {
227 return None;
228 }
229 for _ in 0..200 {
230 let mid = 0.5 * (lo + hi);
231 if f(mid) < 0.0 {
232 lo = mid;
233 } else {
234 hi = mid;
235 }
236 }
237 Some(0.5 * (lo + hi))
238}
239
240pub fn carrier_tracking_threshold_cn0_dbhz(
248 bn_hz: f64,
249 t_s: f64,
250 allowance_deg: f64,
251 doppler_rate_hz_per_s: f64,
252) -> Option<f64> {
253 let stress = pll_dynamic_stress_deg(doppler_rate_hz_per_s, bn_hz);
254 bisect_cn0(|cn0| {
255 allowance_deg - stress - 3.0 * pll_thermal_jitter_rad(cn0, bn_hz, t_s).to_degrees()
256 })
257}
258
259pub fn code_tracking_threshold_cn0_dbhz(
264 bn_hz: f64,
265 t_s: f64,
266 spacing_chips: f64,
267 allowance_chips: f64,
268 code_slew_chips_per_s: f64,
269) -> Option<f64> {
270 let lag = dll_ramp_lag_chips(code_slew_chips_per_s, bn_hz).abs();
271 bisect_cn0(|cn0| {
272 allowance_chips - lag - 3.0 * dll_thermal_jitter_chips(cn0, bn_hz, t_s, spacing_chips)
273 })
274}
275
276pub fn bessel_i0(x: f64) -> f64 {
283 let ax = x.abs();
284 if ax <= 3.75 {
285 let t = (ax / 3.75) * (ax / 3.75);
286 1.0 + t
287 * (3.515_622_9
288 + t * (3.089_942_4
289 + t * (1.206_749_2 + t * (0.265_973_2 + t * (0.036_076_8 + t * 0.004_581_3)))))
290 } else {
291 (ln_bessel_i0(ax)).pexp()
292 }
293}
294
295pub fn ln_bessel_i0(x: f64) -> f64 {
300 let ax = x.abs();
301 if ax <= 3.75 {
302 bessel_i0(ax).pln()
303 } else {
304 let t = 3.75 / ax;
305 let poly = 0.398_942_28
306 + t * (0.013_285_92
307 + t * (0.002_253_19
308 + t * (-0.001_575_65
309 + t * (0.009_162_81
310 + t * (-0.020_577_06
311 + t * (0.026_355_37 + t * (-0.016_476_33 + t * 0.003_923_77)))))));
312 ax - 0.5 * ax.pln() + poly.pln()
313 }
314}
315
316pub fn log10_mean_time_to_cycle_slip_s(cn0_dbhz: f64, bn_hz: f64, t_s: f64) -> f64 {
329 let sigma = pll_thermal_jitter_rad(cn0_dbhz, bn_hz, t_s);
330 let rho = 0.25 / (sigma * sigma).max(f64::MIN_POSITIVE);
331 let ln10 = std::f64::consts::LN_10;
332 2.0 * std::f64::consts::PI.plog10() + rho.plog10() + 2.0 * ln_bessel_i0(rho) / ln10
333 - (2.0 * bn_hz.max(f64::MIN_POSITIVE)).plog10()
334}
335
336#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
340pub enum LockState {
341 Locked,
343 Unlocked,
346}
347
348impl LockState {
349 pub fn as_str(self) -> &'static str {
351 match self {
352 LockState::Locked => "LOCKED",
353 LockState::Unlocked => "UNLOCKED",
354 }
355 }
356}
357
358#[derive(Clone, Copy, Debug)]
364pub struct LockDetector {
365 pub drop_cn0_dbhz: f64,
367 pub relock_cn0_dbhz: f64,
369 pub drop_confirm_epochs: usize,
371 pub relock_confirm_epochs: usize,
373 pub state: LockState,
375 pub streak: usize,
377}
378
379impl LockDetector {
380 pub fn new(
382 drop_cn0_dbhz: f64,
383 relock_cn0_dbhz: f64,
384 drop_confirm_epochs: usize,
385 relock_confirm_epochs: usize,
386 ) -> Self {
387 Self {
388 drop_cn0_dbhz,
389 relock_cn0_dbhz,
390 drop_confirm_epochs,
391 relock_confirm_epochs,
392 state: LockState::Locked,
393 streak: 0,
394 }
395 }
396
397 pub fn step(&mut self, cn0_dbhz: f64) -> LockState {
399 match self.state {
400 LockState::Locked => {
401 if cn0_dbhz < self.drop_cn0_dbhz {
402 self.streak += 1;
403 if self.streak >= self.drop_confirm_epochs.max(1) {
404 self.state = LockState::Unlocked;
405 self.streak = 0;
406 }
407 } else {
408 self.streak = 0;
409 }
410 }
411 LockState::Unlocked => {
412 if cn0_dbhz >= self.relock_cn0_dbhz {
413 self.streak += 1;
414 if self.streak >= self.relock_confirm_epochs.max(1) {
415 self.state = LockState::Locked;
416 self.streak = 0;
417 }
418 } else {
419 self.streak = 0;
420 }
421 }
422 }
423 self.state
424 }
425}
426
427#[derive(Clone, Copy, Debug)]
429pub struct LoopThresholds {
430 pub carrier_drop_cn0_dbhz: Option<f64>,
432 pub code_drop_cn0_dbhz: Option<f64>,
434 pub drop_cn0_dbhz: Option<f64>,
436 pub binding_loop: &'static str,
438 pub relock_cn0_dbhz: Option<f64>,
440 pub hysteresis_db: Option<f64>,
442}
443
444#[derive(Clone, Copy, Debug)]
446pub struct LoopConfig {
447 pub pll_bandwidth_hz: f64,
449 pub dll_bandwidth_hz: f64,
451 pub integration_s: f64,
453 pub spacing_chips: f64,
455 pub carrier_allowance_deg: f64,
457 pub code_allowance_chips: f64,
459 pub pullin_bandwidth_ratio: f64,
461}
462
463impl LoopConfig {
464 pub fn thresholds(
467 &self,
468 doppler_rate_hz_per_s: f64,
469 code_slew_chips_per_s: f64,
470 ) -> LoopThresholds {
471 let carrier = carrier_tracking_threshold_cn0_dbhz(
472 self.pll_bandwidth_hz,
473 self.integration_s,
474 self.carrier_allowance_deg,
475 doppler_rate_hz_per_s,
476 );
477 let code = code_tracking_threshold_cn0_dbhz(
478 self.dll_bandwidth_hz,
479 self.integration_s,
480 self.spacing_chips,
481 self.code_allowance_chips,
482 code_slew_chips_per_s,
483 );
484 let (drop, binding) = match (carrier, code) {
485 (Some(a), Some(b)) => {
486 if a >= b {
487 (Some(a), "carrier")
488 } else {
489 (Some(b), "code")
490 }
491 }
492 (Some(a), None) => (Some(a), "carrier"),
493 (None, Some(b)) => (Some(b), "code"),
494 (None, None) => (None, "none"),
495 };
496 let r = self.pullin_bandwidth_ratio.max(1.0);
497 let relock_carrier = carrier_tracking_threshold_cn0_dbhz(
498 self.pll_bandwidth_hz * r,
499 self.integration_s,
500 self.carrier_allowance_deg,
501 doppler_rate_hz_per_s,
502 );
503 let relock_code = code_tracking_threshold_cn0_dbhz(
504 self.dll_bandwidth_hz * r,
505 self.integration_s,
506 self.spacing_chips,
507 self.code_allowance_chips,
508 code_slew_chips_per_s,
509 );
510 let relock = match (relock_carrier, relock_code) {
511 (Some(a), Some(b)) => Some(a.max(b)),
512 (Some(a), None) => Some(a),
513 (None, Some(b)) => Some(b),
514 (None, None) => None,
515 };
516 LoopThresholds {
517 carrier_drop_cn0_dbhz: carrier,
518 code_drop_cn0_dbhz: code,
519 drop_cn0_dbhz: drop,
520 binding_loop: binding,
521 relock_cn0_dbhz: relock,
522 hysteresis_db: match (relock, drop) {
523 (Some(r), Some(d)) => Some(r - d),
524 _ => None,
525 },
526 }
527 }
528}
529
530pub fn max_code_slew_chips_per_s(bn_hz: f64, allowance_chips: f64) -> f64 {
541 4.0 * bn_hz.max(0.0) * allowance_chips.max(0.0)
542}
543
544pub fn max_code_slew_at_cn0_chips_per_s(
550 bn_hz: f64,
551 cn0_dbhz: f64,
552 t_s: f64,
553 spacing_chips: f64,
554 allowance_chips: f64,
555) -> f64 {
556 let jitter = 3.0 * dll_thermal_jitter_chips(cn0_dbhz, bn_hz, t_s, spacing_chips);
557 let headroom = allowance_chips - jitter;
558 if headroom <= 0.0 {
559 return 0.0;
560 }
561 4.0 * bn_hz.max(0.0) * headroom
562}
563
564pub fn max_doppler_rate_hz_per_s(bn_hz: f64, cn0_dbhz: f64, t_s: f64, allowance_deg: f64) -> f64 {
568 let jitter = 3.0 * pll_thermal_jitter_rad(cn0_dbhz, bn_hz, t_s).to_degrees();
569 let headroom = allowance_deg - jitter;
570 if headroom <= 0.0 {
571 return 0.0;
572 }
573 headroom * bn_hz * bn_hz / (360.0 * SECOND_ORDER_BN_OVER_WN * SECOND_ORDER_BN_OVER_WN)
574}
575
576#[derive(Clone, Copy, Debug)]
580pub struct PullInCell {
581 pub code_slew_chips_per_s: f64,
583 pub doppler_rate_hz_per_s: f64,
585 pub code_lag_chips: f64,
587 pub carrier_stress_deg: f64,
589 pub carrier_budget_deg: f64,
591 pub code_follows: bool,
593 pub carrier_follows: bool,
595 pub pulled_in: bool,
597}
598
599pub fn pull_in_cell(
601 cfg: &LoopConfig,
602 cn0_dbhz: f64,
603 code_slew_chips_per_s: f64,
604 doppler_rate_hz_per_s: f64,
605) -> PullInCell {
606 let lag = dll_ramp_lag_chips(code_slew_chips_per_s, cfg.dll_bandwidth_hz).abs();
607 let stress = pll_dynamic_stress_deg(doppler_rate_hz_per_s, cfg.pll_bandwidth_hz).abs();
608 let jitter = 3.0
609 * pll_thermal_jitter_rad(cn0_dbhz, cfg.pll_bandwidth_hz, cfg.integration_s).to_degrees();
610 let code_jitter = 3.0
611 * dll_thermal_jitter_chips(
612 cn0_dbhz,
613 cfg.dll_bandwidth_hz,
614 cfg.integration_s,
615 cfg.spacing_chips,
616 );
617 let budget = jitter + stress;
618 let code_follows = lag + code_jitter <= cfg.code_allowance_chips;
619 let carrier_follows = budget <= cfg.carrier_allowance_deg;
620 PullInCell {
621 code_slew_chips_per_s,
622 doppler_rate_hz_per_s,
623 code_lag_chips: lag,
624 carrier_stress_deg: stress,
625 carrier_budget_deg: budget,
626 code_follows,
627 carrier_follows,
628 pulled_in: code_follows && carrier_follows,
629 }
630}
631
632#[derive(Clone, Copy, Debug)]
638pub struct DenialLink {
639 pub jammer_power_dbw: f64,
641 pub jammer_gain_dbi: f64,
643 pub rx_gain_toward_jammer_db: f64,
645 pub rx_gain_toward_sat_db: f64,
647 pub signal_power_dbw: f64,
649 pub freq_hz: f64,
651 pub chip_rate_hz: f64,
653 pub q: f64,
655 pub temp_k: f64,
657}
658
659impl DenialLink {
660 pub fn js_db_at(&self, range_m: f64) -> f64 {
662 j_over_s_db(
663 self.jammer_power_dbw,
664 self.jammer_gain_dbi,
665 self.rx_gain_toward_jammer_db,
666 range_m,
667 self.freq_hz,
668 self.signal_power_dbw,
669 self.rx_gain_toward_sat_db,
670 )
671 }
672
673 pub fn nominal_cn0_dbhz(&self) -> f64 {
675 nominal_cn0_dbhz(
676 self.signal_power_dbw,
677 self.rx_gain_toward_sat_db,
678 self.temp_k,
679 )
680 }
681
682 pub fn effective_cn0_dbhz_at(&self, range_m: f64) -> f64 {
684 effective_cn0_dbhz(
685 self.nominal_cn0_dbhz(),
686 self.js_db_at(range_m),
687 self.q,
688 self.chip_rate_hz,
689 )
690 }
691
692 pub fn radius_for_js_db(&self, target_js_db: f64) -> (f64, &'static str) {
697 self.bisect_radius(&|r| self.js_db_at(r) - target_js_db)
698 }
699
700 pub fn radius_for_cn0_dbhz(&self, target_cn0_dbhz: f64) -> (f64, &'static str) {
704 self.bisect_radius(&|r| target_cn0_dbhz - self.effective_cn0_dbhz_at(r))
705 }
706
707 fn bisect_radius(&self, f: &dyn Fn(f64) -> f64) -> (f64, &'static str) {
709 let (mut lo, mut hi) = (RADIUS_BRACKET_MIN_M, RADIUS_BRACKET_MAX_M);
710 if f(lo) <= 0.0 {
711 return (lo, "below-bracket");
712 }
713 if f(hi) >= 0.0 {
714 return (hi, "above-bracket");
715 }
716 for _ in 0..200 {
717 let mid = 0.5 * (lo + hi);
718 if f(mid) > 0.0 {
719 lo = mid;
720 } else {
721 hi = mid;
722 }
723 }
724 (0.5 * (lo + hi), "bracketed")
725 }
726}
727
728fn d_pll_bandwidth_hz() -> f64 {
731 10.0
732}
733fn d_dll_bandwidth_hz() -> f64 {
734 1.0
735}
736fn d_integration_s() -> f64 {
737 0.001
738}
739fn d_spacing_chips() -> f64 {
740 0.5
741}
742fn d_carrier_allowance_deg() -> f64 {
743 COSTAS_THRESHOLD_DEG
744}
745fn d_pullin_ratio() -> f64 {
746 2.0
747}
748fn d_drop_confirm_epochs() -> usize {
749 50
750}
751fn d_relock_confirm_epochs() -> usize {
752 200
753}
754fn d_cn0_high_dbhz() -> f64 {
755 45.0
756}
757fn d_cn0_low_dbhz() -> f64 {
758 15.0
759}
760fn d_ramp_duration_s() -> f64 {
761 60.0
762}
763fn d_jammer_power_dbw() -> f64 {
764 10.0
765}
766fn d_signal_power_dbw() -> f64 {
767 crate::jamming::DEFAULT_SIGNAL_POWER_DBW
768}
769fn d_temp_k() -> f64 {
770 crate::jamming::DEFAULT_TEMP_K
771}
772fn d_freq_hz() -> f64 {
773 L1_HZ
774}
775fn d_chip_rate_hz() -> f64 {
776 CA_CHIP_RATE_HZ
777}
778fn d_jammer_type() -> String {
779 "broadband".to_string()
780}
781fn d_denial_js_threshold_db() -> f64 {
782 30.0
783}
784fn d_capture_js_threshold_db() -> f64 {
785 3.0
786}
787fn d_code_slews() -> Vec<f64> {
788 vec![0.1, 0.5, 1.0, 2.0, 5.0]
789}
790fn d_doppler_rates() -> Vec<f64> {
791 vec![1.0, 10.0, 40.0, 100.0, 500.0]
792}
793fn d_jitter_table_cn0() -> Vec<f64> {
794 vec![45.0, 40.0, 35.0, 30.0, 27.5, 25.0, 22.5, 20.0]
795}
796
797#[derive(Clone, Debug, Deserialize, Serialize)]
800pub struct TrackingLoopScenario {
801 #[serde(default = "d_pll_bandwidth_hz")]
803 pub pll_bandwidth_hz: f64,
804 #[serde(default = "d_dll_bandwidth_hz")]
806 pub dll_bandwidth_hz: f64,
807 #[serde(default = "d_integration_s")]
809 pub predetection_integration_s: f64,
810 #[serde(default = "d_spacing_chips")]
812 pub correlator_spacing_chips: f64,
813 #[serde(default = "d_carrier_allowance_deg")]
815 pub carrier_allowance_deg: f64,
816 #[serde(default)]
819 pub code_allowance_chips: Option<f64>,
820 #[serde(default = "d_pullin_ratio")]
823 pub pullin_bandwidth_ratio: f64,
824 #[serde(default = "d_drop_confirm_epochs")]
826 pub drop_confirm_epochs: usize,
827 #[serde(default = "d_relock_confirm_epochs")]
829 pub relock_confirm_epochs: usize,
830 #[serde(default)]
832 pub doppler_rate_hz_per_s: f64,
833 #[serde(default)]
835 pub code_slew_chips_per_s: f64,
836 #[serde(default = "d_cn0_high_dbhz")]
838 pub cn0_high_dbhz: f64,
839 #[serde(default = "d_cn0_low_dbhz")]
841 pub cn0_low_dbhz: f64,
842 #[serde(default = "d_ramp_duration_s")]
844 pub ramp_duration_s: f64,
845 #[serde(default = "d_jitter_table_cn0")]
847 pub jitter_table_cn0_dbhz: Vec<f64>,
848 #[serde(default = "d_jammer_power_dbw")]
850 pub jammer_power_dbw: f64,
851 #[serde(default)]
853 pub jammer_gain_dbi: f64,
854 #[serde(default)]
856 pub rx_gain_toward_jammer_db: f64,
857 #[serde(default)]
859 pub rx_gain_toward_sat_db: f64,
860 #[serde(default = "d_signal_power_dbw")]
862 pub signal_power_dbw: f64,
863 #[serde(default = "d_temp_k")]
865 pub temp_k: f64,
866 #[serde(default = "d_freq_hz")]
868 pub freq_hz: f64,
869 #[serde(default = "d_chip_rate_hz")]
871 pub chip_rate_hz: f64,
872 #[serde(default = "d_jammer_type")]
874 pub jammer_type: String,
875 #[serde(default)]
877 pub q_override: Option<f64>,
878 #[serde(default = "d_denial_js_threshold_db")]
880 pub denial_js_threshold_db: f64,
881 #[serde(default = "d_capture_js_threshold_db")]
883 pub capture_js_threshold_db: f64,
884 #[serde(default = "d_code_slews")]
886 pub spoof_code_slew_chips_per_s: Vec<f64>,
887 #[serde(default = "d_doppler_rates")]
889 pub spoof_doppler_rate_hz_per_s: Vec<f64>,
890}
891
892impl Default for TrackingLoopScenario {
893 fn default() -> Self {
894 Self {
895 pll_bandwidth_hz: d_pll_bandwidth_hz(),
896 dll_bandwidth_hz: d_dll_bandwidth_hz(),
897 predetection_integration_s: d_integration_s(),
898 correlator_spacing_chips: d_spacing_chips(),
899 carrier_allowance_deg: d_carrier_allowance_deg(),
900 code_allowance_chips: None,
901 pullin_bandwidth_ratio: d_pullin_ratio(),
902 drop_confirm_epochs: d_drop_confirm_epochs(),
903 relock_confirm_epochs: d_relock_confirm_epochs(),
904 doppler_rate_hz_per_s: 0.0,
905 code_slew_chips_per_s: 0.0,
906 cn0_high_dbhz: d_cn0_high_dbhz(),
907 cn0_low_dbhz: d_cn0_low_dbhz(),
908 ramp_duration_s: d_ramp_duration_s(),
909 jitter_table_cn0_dbhz: d_jitter_table_cn0(),
910 jammer_power_dbw: d_jammer_power_dbw(),
911 jammer_gain_dbi: 0.0,
912 rx_gain_toward_jammer_db: 0.0,
913 rx_gain_toward_sat_db: 0.0,
914 signal_power_dbw: d_signal_power_dbw(),
915 temp_k: d_temp_k(),
916 freq_hz: d_freq_hz(),
917 chip_rate_hz: d_chip_rate_hz(),
918 jammer_type: d_jammer_type(),
919 q_override: None,
920 denial_js_threshold_db: d_denial_js_threshold_db(),
921 capture_js_threshold_db: d_capture_js_threshold_db(),
922 spoof_code_slew_chips_per_s: d_code_slews(),
923 spoof_doppler_rate_hz_per_s: d_doppler_rates(),
924 }
925 }
926}
927
928fn profile_cn0_dbhz(t: f64, high: f64, low: f64, leg: f64) -> f64 {
932 if leg <= 0.0 {
933 return high;
934 }
935 if t <= leg {
936 high + (low - high) * (t / leg)
937 } else if t <= 2.0 * leg {
938 low + (high - low) * ((t - leg) / leg)
939 } else {
940 high
941 }
942}
943
944#[derive(Clone, Copy, Debug)]
946pub struct LockHistory {
947 pub drop_crossing_s: f64,
949 pub declared_loss_s: f64,
951 pub relock_crossing_s: f64,
953 pub declared_relock_s: f64,
955 pub unlocked_duration_s: f64,
957 pub steps: usize,
959}
960
961pub fn run_lock_history(
964 detector: &mut LockDetector,
965 high_dbhz: f64,
966 low_dbhz: f64,
967 leg_s: f64,
968 epoch_s: f64,
969) -> Result<LockHistory, String> {
970 let total = 2.0 * leg_s;
971 if !(epoch_s.is_finite() && epoch_s > 0.0 && total.is_finite()) || total < 0.0 {
972 return Err("tracking-loop: the C/N0 profile needs a positive epoch and duration".into());
973 }
974 let n = (total / epoch_s).ceil();
975 if !n.is_finite() || n > MAX_DETECTOR_STEPS as f64 {
976 return Err(format!(
977 "tracking-loop: the C/N0 profile would need more than {MAX_DETECTOR_STEPS} detector \
978 steps ({total} s at {epoch_s} s per epoch); shorten ramp_duration_s or lengthen \
979 predetection_integration_s"
980 ));
981 }
982 let steps = n as usize;
983 let mut h = LockHistory {
984 drop_crossing_s: f64::NAN,
985 declared_loss_s: f64::NAN,
986 relock_crossing_s: f64::NAN,
987 declared_relock_s: f64::NAN,
988 unlocked_duration_s: 0.0,
989 steps,
990 };
991 let mut prev = LockState::Locked;
992 for i in 0..steps {
993 let t = i as f64 * epoch_s;
994 let cn0 = profile_cn0_dbhz(t, high_dbhz, low_dbhz, leg_s);
995 if h.drop_crossing_s.is_nan() && cn0 < detector.drop_cn0_dbhz {
996 h.drop_crossing_s = t;
997 }
998 if !h.drop_crossing_s.is_nan()
999 && h.relock_crossing_s.is_nan()
1000 && cn0 >= detector.relock_cn0_dbhz
1001 {
1002 h.relock_crossing_s = t;
1003 }
1004 let s = detector.step(cn0);
1005 if s == LockState::Unlocked {
1006 h.unlocked_duration_s += epoch_s;
1007 }
1008 if prev == LockState::Locked && s == LockState::Unlocked && h.declared_loss_s.is_nan() {
1009 h.declared_loss_s = t;
1010 }
1011 if prev == LockState::Unlocked && s == LockState::Locked && h.declared_relock_s.is_nan() {
1012 h.declared_relock_s = t;
1013 }
1014 prev = s;
1015 }
1016 Ok(h)
1017}
1018
1019fn jnum(v: f64) -> serde_json::Value {
1020 if v.is_finite() {
1021 serde_json::json!(v)
1022 } else {
1023 serde_json::Value::Null
1024 }
1025}
1026
1027fn jopt(v: Option<f64>) -> serde_json::Value {
1028 match v {
1029 Some(x) if x.is_finite() => serde_json::json!(x),
1030 _ => serde_json::Value::Null,
1031 }
1032}
1033
1034impl TrackingLoopScenario {
1035 pub fn loop_config(&self) -> LoopConfig {
1037 LoopConfig {
1038 pll_bandwidth_hz: self.pll_bandwidth_hz,
1039 dll_bandwidth_hz: self.dll_bandwidth_hz,
1040 integration_s: self.predetection_integration_s,
1041 spacing_chips: self.correlator_spacing_chips,
1042 carrier_allowance_deg: self.carrier_allowance_deg,
1043 code_allowance_chips: self
1044 .code_allowance_chips
1045 .unwrap_or(self.correlator_spacing_chips / 2.0),
1046 pullin_bandwidth_ratio: self.pullin_bandwidth_ratio,
1047 }
1048 }
1049
1050 pub fn denial_link(&self) -> DenialLink {
1052 DenialLink {
1053 jammer_power_dbw: self.jammer_power_dbw,
1054 jammer_gain_dbi: self.jammer_gain_dbi,
1055 rx_gain_toward_jammer_db: self.rx_gain_toward_jammer_db,
1056 rx_gain_toward_sat_db: self.rx_gain_toward_sat_db,
1057 signal_power_dbw: self.signal_power_dbw,
1058 freq_hz: self.freq_hz,
1059 chip_rate_hz: self.chip_rate_hz,
1060 q: q_factor(&self.jammer_type, self.q_override),
1061 temp_k: self.temp_k,
1062 }
1063 }
1064
1065 fn validate(&self) -> Result<(), String> {
1066 let finite = [
1067 ("pll_bandwidth_hz", self.pll_bandwidth_hz),
1068 ("dll_bandwidth_hz", self.dll_bandwidth_hz),
1069 (
1070 "predetection_integration_s",
1071 self.predetection_integration_s,
1072 ),
1073 ("correlator_spacing_chips", self.correlator_spacing_chips),
1074 ("carrier_allowance_deg", self.carrier_allowance_deg),
1075 ("pullin_bandwidth_ratio", self.pullin_bandwidth_ratio),
1076 ("cn0_high_dbhz", self.cn0_high_dbhz),
1077 ("cn0_low_dbhz", self.cn0_low_dbhz),
1078 ("ramp_duration_s", self.ramp_duration_s),
1079 ("jammer_power_dbw", self.jammer_power_dbw),
1080 ("signal_power_dbw", self.signal_power_dbw),
1081 ("temp_k", self.temp_k),
1082 ("freq_hz", self.freq_hz),
1083 ("chip_rate_hz", self.chip_rate_hz),
1084 ("doppler_rate_hz_per_s", self.doppler_rate_hz_per_s),
1085 ("code_slew_chips_per_s", self.code_slew_chips_per_s),
1086 ];
1087 for (name, v) in finite {
1088 if !v.is_finite() {
1089 return Err(format!("tracking-loop: {name} must be finite"));
1090 }
1091 }
1092 let positive = [
1093 ("pll_bandwidth_hz", self.pll_bandwidth_hz),
1094 ("dll_bandwidth_hz", self.dll_bandwidth_hz),
1095 (
1096 "predetection_integration_s",
1097 self.predetection_integration_s,
1098 ),
1099 ("correlator_spacing_chips", self.correlator_spacing_chips),
1100 ("carrier_allowance_deg", self.carrier_allowance_deg),
1101 ("temp_k", self.temp_k),
1102 ("freq_hz", self.freq_hz),
1103 ("chip_rate_hz", self.chip_rate_hz),
1104 ];
1105 for (name, v) in positive {
1106 if v <= 0.0 {
1107 return Err(format!("tracking-loop: {name} must be positive (got {v})"));
1108 }
1109 }
1110 if self.correlator_spacing_chips >= 1.9 {
1111 return Err(format!(
1112 "tracking-loop: correlator_spacing_chips must be below 1.9 chips (got {})",
1113 self.correlator_spacing_chips
1114 ));
1115 }
1116 if self.pullin_bandwidth_ratio < 1.0 {
1117 return Err(format!(
1118 "tracking-loop: pullin_bandwidth_ratio must be at least 1 (got {}) — a loop that \
1119 re-pulls-in with a NARROWER bandwidth than it tracks with would invert the \
1120 hysteresis",
1121 self.pullin_bandwidth_ratio
1122 ));
1123 }
1124 if self.ramp_duration_s < 0.0 {
1125 return Err("tracking-loop: ramp_duration_s must not be negative".into());
1126 }
1127 if self.cn0_low_dbhz > self.cn0_high_dbhz {
1128 return Err(format!(
1129 "tracking-loop: cn0_low_dbhz ({}) must not exceed cn0_high_dbhz ({})",
1130 self.cn0_low_dbhz, self.cn0_high_dbhz
1131 ));
1132 }
1133 for (name, axis) in [
1134 ("jitter_table_cn0_dbhz", &self.jitter_table_cn0_dbhz),
1135 (
1136 "spoof_code_slew_chips_per_s",
1137 &self.spoof_code_slew_chips_per_s,
1138 ),
1139 (
1140 "spoof_doppler_rate_hz_per_s",
1141 &self.spoof_doppler_rate_hz_per_s,
1142 ),
1143 ] {
1144 if axis.is_empty() {
1145 return Err(format!("tracking-loop: {name} must not be empty"));
1146 }
1147 if axis.len() > MAX_AXIS_LEN {
1148 return Err(format!(
1149 "tracking-loop: {name} has {} entries; the cap is {MAX_AXIS_LEN}",
1150 axis.len()
1151 ));
1152 }
1153 if axis.iter().any(|v| !v.is_finite()) {
1154 return Err(format!("tracking-loop: {name} holds a non-finite value"));
1155 }
1156 }
1157 if self.drop_confirm_epochs == 0 || self.relock_confirm_epochs == 0 {
1158 return Err("tracking-loop: the confirmation dwells must be at least 1 epoch".into());
1159 }
1160 Ok(())
1161 }
1162
1163 pub fn run_json(&self) -> Result<(String, String), String> {
1166 self.validate()?;
1167 let cfg = self.loop_config();
1168 let th = cfg.thresholds(self.doppler_rate_hz_per_s, self.code_slew_chips_per_s);
1169 let link = self.denial_link();
1170 let t = cfg.integration_s;
1171
1172 let jitter_rows: Vec<serde_json::Value> = self
1174 .jitter_table_cn0_dbhz
1175 .iter()
1176 .map(|&c| {
1177 let sp = pll_thermal_jitter_rad(c, cfg.pll_bandwidth_hz, t).to_degrees();
1178 let sd = dll_thermal_jitter_chips(c, cfg.dll_bandwidth_hz, t, cfg.spacing_chips);
1179 serde_json::json!({
1180 "cn0_dbhz": c,
1181 "pll_jitter_deg": sp,
1182 "pll_three_sigma_deg": 3.0 * sp,
1183 "dll_jitter_chips": sd,
1184 "dll_three_sigma_chips": 3.0 * sd,
1185 "carrier_within_allowance": 3.0 * sp <= cfg.carrier_allowance_deg,
1186 "code_within_allowance": 3.0 * sd <= cfg.code_allowance_chips,
1187 "log10_mean_time_to_cycle_slip_s":
1188 log10_mean_time_to_cycle_slip_s(c, cfg.pll_bandwidth_hz, t),
1189 })
1190 })
1191 .collect();
1192
1193 let r = cfg.pullin_bandwidth_ratio.max(1.0);
1195 let hyst_lo = 5.0 * r.plog10();
1196 let hyst_hi = 10.0 * r.plog10();
1197
1198 let (history, history_status) = match (th.drop_cn0_dbhz, th.relock_cn0_dbhz) {
1200 (Some(d), Some(rl)) => {
1201 let mut det =
1202 LockDetector::new(d, rl, self.drop_confirm_epochs, self.relock_confirm_epochs);
1203 (
1204 Some(run_lock_history(
1205 &mut det,
1206 self.cn0_high_dbhz,
1207 self.cn0_low_dbhz,
1208 self.ramp_duration_s,
1209 t,
1210 )?),
1211 "run",
1212 )
1213 }
1214 _ => (
1215 None,
1216 "not-run: the loop has no C/N0 threshold under the stated dynamics",
1217 ),
1218 };
1219
1220 let (r_thresh_m, r_thresh_status) = link.radius_for_js_db(self.denial_js_threshold_db);
1222 let (r_capture_m, r_capture_status) = link.radius_for_js_db(self.capture_js_threshold_db);
1223 let (r_loop_m, r_loop_status, loop_js_db) = match th.drop_cn0_dbhz {
1224 Some(d) => {
1225 let (rm, st) = link.radius_for_cn0_dbhz(d);
1226 (Some(rm), st, Some(link.js_db_at(rm)))
1227 }
1228 None => (
1229 None,
1230 "not-computed: the loop has no C/N0 threshold under the stated dynamics",
1231 None,
1232 ),
1233 };
1234 let delta_km = r_loop_m.map(|rl| (rl - r_thresh_m) / 1000.0);
1235 let ratio = r_loop_m.map(|rl| rl / r_thresh_m);
1236
1237 let mut cells = Vec::new();
1239 for &cs in &self.spoof_code_slew_chips_per_s {
1240 for &dr in &self.spoof_doppler_rate_hz_per_s {
1241 let c = pull_in_cell(&cfg, self.cn0_high_dbhz, cs, dr);
1242 cells.push(serde_json::json!({
1243 "code_slew_chips_per_s": c.code_slew_chips_per_s,
1244 "code_slew_m_per_s": c.code_slew_chips_per_s * C_M_PER_S / self.chip_rate_hz,
1245 "doppler_rate_hz_per_s": c.doppler_rate_hz_per_s,
1246 "range_acceleration_m_per_s2": c.doppler_rate_hz_per_s * C_M_PER_S / self.freq_hz,
1247 "code_lag_chips": c.code_lag_chips,
1248 "carrier_stress_deg": c.carrier_stress_deg,
1249 "carrier_budget_deg": c.carrier_budget_deg,
1250 "code_follows": c.code_follows,
1251 "carrier_follows": c.carrier_follows,
1252 "pulled_in": c.pulled_in,
1253 }));
1254 }
1255 }
1256 let max_slew_noiseless =
1257 max_code_slew_chips_per_s(cfg.dll_bandwidth_hz, cfg.code_allowance_chips);
1258 let max_slew = max_code_slew_at_cn0_chips_per_s(
1259 cfg.dll_bandwidth_hz,
1260 self.cn0_high_dbhz,
1261 t,
1262 cfg.spacing_chips,
1263 cfg.code_allowance_chips,
1264 );
1265 let max_rate = max_doppler_rate_hz_per_s(
1266 cfg.pll_bandwidth_hz,
1267 self.cn0_high_dbhz,
1268 t,
1269 cfg.carrier_allowance_deg,
1270 );
1271
1272 let units = self.units_block();
1273
1274 let mut json = serde_json::json!({
1275 "kind": "tracking-loop",
1276 "label": "MIXED — VALIDATED loop closed forms, MODELLED loop configuration. The \
1277 carrier and code thermal-jitter expressions, the loop-filter reduction \
1278 sqrt(2*B_n*T) and the first-order ramp lag are cross-checked against the \
1279 engine's own sdr correlator stepped forward on seeded synthetic IF (a \
1280 different route to the same numbers), and the modified Bessel I0 against \
1281 standard tabulated values. MODELLED: the loop bandwidths, integration \
1282 time, correlator spacing, pull-in bandwidth ratio, confirmation dwells, \
1283 jammer power and antenna gains are REPRESENTATIVE BAND FIGURES, not a \
1284 datasheet for any receiver. NOT modelled: oscillator (Allan-deviation) \
1285 and vibration jitter, front-end bandwidth limiting, multipath, AGC \
1286 dynamics, data-bit-transition loss and external aiding; the Costas \
1287 slip time is Viterbi's first-order expression on rho/4 (half-cycle \
1288 slips), an optimistic bound for a second-order loop. The loop-dynamics denial \
1289 radius is reported ALONGSIDE the existing power-ratio radius and never \
1290 in place of it. Not a certified receiver-performance product.",
1291 "loop": {
1292 "pll_bandwidth_hz": cfg.pll_bandwidth_hz,
1293 "dll_bandwidth_hz": cfg.dll_bandwidth_hz,
1294 "predetection_integration_s": cfg.integration_s,
1295 "correlator_spacing_chips": cfg.spacing_chips,
1296 "carrier_allowance_deg": cfg.carrier_allowance_deg,
1297 "code_allowance_chips": cfg.code_allowance_chips,
1298 "pullin_bandwidth_ratio": cfg.pullin_bandwidth_ratio,
1299 "doppler_rate_hz_per_s": self.doppler_rate_hz_per_s,
1300 "code_slew_chips_per_s": self.code_slew_chips_per_s,
1301 "discriminator_slope_chips_per_chip":
1302 early_late_amplitude_discriminator_slope(cfg.spacing_chips),
1303 "threshold_rule": "carrier: 3*sigma_PLL + theta_e <= carrier_allowance_deg (the \
1304 15-degree rule in its three-sigma, 45-degree form); code: \
1305 3*sigma_DLL + ramp lag <= code_allowance_chips, the \
1306 early-late discriminator's linear half-range d/2.",
1307 },
1308 "thresholds": {
1309 "carrier_drop_cn0_dbhz": jopt(th.carrier_drop_cn0_dbhz),
1310 "code_drop_cn0_dbhz": jopt(th.code_drop_cn0_dbhz),
1311 "drop_cn0_dbhz": jopt(th.drop_cn0_dbhz),
1312 "binding_loop": th.binding_loop,
1313 "relock_cn0_dbhz": jopt(th.relock_cn0_dbhz),
1314 "hysteresis_db": jopt(th.hysteresis_db),
1315 "hysteresis_lower_bound_db": hyst_lo,
1316 "hysteresis_upper_bound_db": hyst_hi,
1317 "hysteresis_definition": "the re-lock threshold is the same tracking-threshold \
1318 rule evaluated with the loop bandwidths a receiver \
1319 re-pulls-in at (pullin_bandwidth_ratio times the \
1320 tracking bandwidths). Jitter grows as sqrt(B_n) in the \
1321 thermal-dominated limit and as B_n^(1/4) in the \
1322 squaring-loss-dominated limit, so the width must lie \
1323 between 5*log10(ratio) and 10*log10(ratio) dB. It is \
1324 DERIVED from the bandwidth ratio, not asserted; the \
1325 ratio itself is a MODELLED design choice.",
1326 },
1327 "jitter_table": jitter_rows,
1328 "spoof_pull_in": {
1329 "max_code_slew_chips_per_s": max_slew,
1330 "max_code_slew_m_per_s": max_slew * C_M_PER_S / self.chip_rate_hz,
1331 "max_code_slew_noiseless_chips_per_s": max_slew_noiseless,
1332 "max_doppler_rate_hz_per_s": max_rate,
1333 "max_range_acceleration_m_per_s2": max_rate * C_M_PER_S / self.freq_hz,
1334 "evaluated_at_cn0_dbhz": self.cn0_high_dbhz,
1335 "cells": cells,
1336 "definition": "a spoofer captures by slewing the victim's code phase and carrier \
1337 away from truth. The victim follows only while its first-order DLL \
1338 ramp lag (code_slew / (4*B_dll)) plus 3*sigma_DLL stays inside the \
1339 code allowance AND its second-order PLL dynamic stress \
1340 (360*fdot/omega_n^2, omega_n = B_pll/0.53) plus 3*sigma_PLL stays \
1341 inside the carrier allowance. A spoofer that slews FASTER than \
1342 these limits does not capture the loop — it outruns it. This is \
1343 why loop dynamics does not reduce the capture criterion to a \
1344 power ratio at all.",
1345 },
1346 });
1347
1348 json["denial"] = serde_json::json!({
1349 "criterion_note": "BOTH criteria are reported. The threshold criterion is the \
1350 engine's existing power ratio (J/S at denial_js_threshold_db, \
1351 default 30 dB) and is unchanged. The loop-dynamics criterion is \
1352 the standoff at which the effective C/N0 falls to the drop \
1353 threshold this loop configuration implies. Neither replaces the \
1354 other; denial_radius_delta_km is their signed difference.",
1355 "nominal_cn0_dbhz": link.nominal_cn0_dbhz(),
1356 "q_factor": link.q,
1357 "denial_js_threshold_db": self.denial_js_threshold_db,
1358 "denial_radius_threshold_km": r_thresh_m / 1000.0,
1359 "denial_radius_threshold_status": r_thresh_status,
1360 "denial_cn0_at_threshold_radius_dbhz": link.effective_cn0_dbhz_at(r_thresh_m),
1361 "denial_js_loop_db": jopt(loop_js_db),
1362 "denial_radius_loop_dynamics_km": jopt(r_loop_m.map(|x| x / 1000.0)),
1363 "denial_radius_loop_dynamics_status": r_loop_status,
1364 "denial_radius_delta_km": jopt(delta_km),
1365 "denial_radius_ratio": jopt(ratio),
1366 "denial_js_delta_db": jopt(loop_js_db.map(|j| j - self.denial_js_threshold_db)),
1367 "denial_radius_delta_definition": "denial_radius_delta_km = \
1368 denial_radius_loop_dynamics_km - \
1369 denial_radius_threshold_km. Negative means the \
1370 loop holds lock closer in than the power-ratio \
1371 rule allows, so the power-ratio rule OVERSTATES \
1372 the denied area; positive would mean the opposite.",
1373 "capture_js_threshold_db": self.capture_js_threshold_db,
1374 "capture_radius_threshold_km": r_capture_m / 1000.0,
1375 "capture_radius_threshold_status": r_capture_status,
1376 "capture_radius_loop_dynamics_km": serde_json::Value::Null,
1377 "capture_radius_loop_dynamics_status": "not-applicable: under loop dynamics the \
1378 binding spoof-capture constraint is the code \
1379 and carrier offset RATE the victim's loops \
1380 can follow, not a power ratio, so there is no \
1381 radius to report. See spoof_pull_in.",
1382 });
1383
1384 json["lock_history"] = match history {
1385 Some(h) => serde_json::json!({
1386 "status": history_status,
1387 "cn0_high_dbhz": self.cn0_high_dbhz,
1388 "cn0_low_dbhz": self.cn0_low_dbhz,
1389 "ramp_duration_s": self.ramp_duration_s,
1390 "ramp_rate_db_per_s": if self.ramp_duration_s > 0.0 {
1391 (self.cn0_low_dbhz - self.cn0_high_dbhz) / self.ramp_duration_s
1392 } else { 0.0 },
1393 "detector_steps": h.steps,
1394 "drop_confirm_epochs": self.drop_confirm_epochs,
1395 "relock_confirm_epochs": self.relock_confirm_epochs,
1396 "drop_crossing_s": jnum(h.drop_crossing_s),
1397 "declared_loss_of_lock_s": jnum(h.declared_loss_s),
1398 "time_to_lose_lock_after_crossing_s":
1399 jnum(h.declared_loss_s - h.drop_crossing_s),
1400 "relock_crossing_s": jnum(h.relock_crossing_s),
1401 "declared_relock_s": jnum(h.declared_relock_s),
1402 "unlocked_duration_s": h.unlocked_duration_s,
1403 "definition": "declared_loss_of_lock_s is when the two-threshold detector \
1404 transitions, which is the crossing of the drop threshold plus the \
1405 confirmation dwell. It is a DECLARATION time. The physical phase \
1406 escape time is the separate cycle-slip column of the jitter \
1407 table.",
1408 }),
1409 None => serde_json::json!({ "status": history_status }),
1410 };
1411 json["units"] = units;
1412
1413 let summary = format!(
1414 "tracking-loop: B_pll {:.1} Hz / B_dll {:.1} Hz, T {:.0} ms, d {:.2} chip -> drop {} \
1415 dB-Hz ({} binds), relock {} dB-Hz, hysteresis {} dB | loss of lock declared {} s \
1416 into the ramp | denial radius threshold(J/S {:.0} dB) {:.3} km vs loop dynamics {} \
1417 km, delta {} km | spoof pull-in <= {:.3} chip/s code and {:.2} Hz/s carrier \
1418 (MODELLED loop, VALIDATED closed forms)",
1419 cfg.pll_bandwidth_hz,
1420 cfg.dll_bandwidth_hz,
1421 cfg.integration_s * 1000.0,
1422 cfg.spacing_chips,
1423 opt4(th.drop_cn0_dbhz),
1424 th.binding_loop,
1425 opt4(th.relock_cn0_dbhz),
1426 opt4(th.hysteresis_db),
1427 history.map_or("n/a".to_string(), |h| format!("{:.3}", h.declared_loss_s)),
1428 self.denial_js_threshold_db,
1429 r_thresh_m / 1000.0,
1430 opt4(r_loop_m.map(|x| x / 1000.0)),
1431 opt4(delta_km),
1432 max_slew,
1433 max_rate,
1434 );
1435 let json = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
1436 Ok((json, summary))
1437 }
1438
1439 fn units_block(&self) -> serde_json::Value {
1444 let mut u = serde_json::json!({
1445 "loop.pll_bandwidth_hz": {"unit": "Hz", "provenance": "modelled-input", "note": "one-sided carrier loop noise bandwidth; representative band figure"},
1446 "loop.dll_bandwidth_hz": {"unit": "Hz", "provenance": "modelled-input", "note": "one-sided code loop noise bandwidth; representative band figure"},
1447 "loop.predetection_integration_s": {"unit": "s", "provenance": "modelled-input", "note": "coherent integration per epoch; the default 1 ms is one C/A code period"},
1448 "loop.correlator_spacing_chips": {"unit": "chip", "provenance": "modelled-input"},
1449 "loop.carrier_allowance_deg": {"unit": "deg", "provenance": "input", "note": "the 45 deg three-sigma form of the 15-degree Costas rule"},
1450 "loop.code_allowance_chips": {"unit": "chip", "provenance": "computed", "note": "defaults to half the correlator spacing, the discriminator's linear half-range"},
1451 "loop.pullin_bandwidth_ratio": {"unit": "ratio (dimensionless)", "provenance": "modelled-input", "note": "bandwidth multiplier while re-pulling-in; a design choice, not a physical constant"},
1452 "loop.doppler_rate_hz_per_s": {"unit": "Hz/s", "provenance": "input"},
1453 "loop.code_slew_chips_per_s": {"unit": "chip/s", "provenance": "input"},
1454 "loop.discriminator_slope_chips_per_chip": {"unit": "chip/chip (dimensionless)", "provenance": "computed", "note": "1/(2-d), the ideal-triangle gain of the normalised early-late amplitude discriminator"},
1455 "thresholds.carrier_drop_cn0_dbhz": {"unit": "dB-Hz", "provenance": "computed"},
1456 "thresholds.code_drop_cn0_dbhz": {"unit": "dB-Hz", "provenance": "computed"},
1457 "thresholds.drop_cn0_dbhz": {"unit": "dB-Hz", "provenance": "computed", "note": "the larger of the two; whichever loop gives up first binds"},
1458 "thresholds.relock_cn0_dbhz": {"unit": "dB-Hz", "provenance": "computed"},
1459 "thresholds.hysteresis_db": {"unit": "dB", "provenance": "computed", "note": "relock_cn0_dbhz - drop_cn0_dbhz"},
1460 "thresholds.hysteresis_lower_bound_db": {"unit": "dB", "provenance": "computed", "note": "5*log10(pullin_bandwidth_ratio), the squaring-loss-dominated limit"},
1461 "thresholds.hysteresis_upper_bound_db": {"unit": "dB", "provenance": "computed", "note": "10*log10(pullin_bandwidth_ratio), the thermal-dominated limit"},
1462 "jitter_table[].cn0_dbhz": {"unit": "dB-Hz", "provenance": "input"},
1463 "jitter_table[].pll_jitter_deg": {"unit": "deg", "provenance": "computed", "note": "1 sigma Costas carrier thermal jitter, squaring loss included"},
1464 "jitter_table[].pll_three_sigma_deg": {"unit": "deg", "provenance": "computed"},
1465 "jitter_table[].dll_jitter_chips": {"unit": "chip", "provenance": "computed", "note": "1 sigma non-coherent early-late code thermal jitter"},
1466 "jitter_table[].dll_three_sigma_chips": {"unit": "chip", "provenance": "computed"},
1467 "jitter_table[].log10_mean_time_to_cycle_slip_s": {"unit": "log10(s)", "provenance": "computed", "note": "Viterbi first-order mean time between slips; reported in log10 because it spans hundreds of decades"},
1468 "spoof_pull_in.max_code_slew_chips_per_s": {"unit": "chip/s", "provenance": "computed", "note": "4*B_dll*(code_allowance_chips - 3*sigma_DLL) at evaluated_at_cn0_dbhz; this is the ceiling the cells are decided against"},
1469 "spoof_pull_in.max_code_slew_noiseless_chips_per_s": {"unit": "chip/s", "provenance": "computed", "note": "4*B_dll*code_allowance_chips, the geometric ceiling with the code jitter set aside"},
1470 "spoof_pull_in.max_code_slew_m_per_s": {"unit": "m/s", "provenance": "computed", "note": "the same limit as a pseudorange drag rate at the stated chip rate"},
1471 "spoof_pull_in.max_doppler_rate_hz_per_s": {"unit": "Hz/s", "provenance": "computed"},
1472 "spoof_pull_in.max_range_acceleration_m_per_s2": {"unit": "m/s^2", "provenance": "computed", "note": "the same limit as a line-of-sight acceleration at the stated carrier"},
1473 "spoof_pull_in.evaluated_at_cn0_dbhz": {"unit": "dB-Hz", "provenance": "input"},
1474 "spoof_pull_in.cells[].code_slew_chips_per_s": {"unit": "chip/s", "provenance": "input"},
1475 "spoof_pull_in.cells[].code_slew_m_per_s": {"unit": "m/s", "provenance": "computed"},
1476 "spoof_pull_in.cells[].doppler_rate_hz_per_s": {"unit": "Hz/s", "provenance": "input"},
1477 "spoof_pull_in.cells[].range_acceleration_m_per_s2": {"unit": "m/s^2", "provenance": "computed"},
1478 "spoof_pull_in.cells[].code_lag_chips": {"unit": "chip", "provenance": "computed"},
1479 "spoof_pull_in.cells[].carrier_stress_deg": {"unit": "deg", "provenance": "computed"},
1480 "spoof_pull_in.cells[].carrier_budget_deg": {"unit": "deg", "provenance": "computed", "note": "3*sigma_PLL + dynamic stress"},
1481 });
1482 let denial = serde_json::json!({
1483 "denial.nominal_cn0_dbhz": {"unit": "dB-Hz", "provenance": "computed", "note": "jamming::nominal_cn0_dbhz for the stated signal power, antenna gain and noise temperature"},
1484 "denial.q_factor": {"unit": "ratio (dimensionless)", "provenance": "modelled-input", "note": "spectral-separation coefficient from jamming::q_factor"},
1485 "denial.denial_js_threshold_db": {"unit": "dB", "provenance": "input", "note": "the engine's existing power-ratio denial criterion; reported unchanged"},
1486 "denial.denial_radius_threshold_km": {"unit": "km", "provenance": "computed", "note": "standoff at which J/S reaches denial_js_threshold_db"},
1487 "denial.denial_cn0_at_threshold_radius_dbhz": {"unit": "dB-Hz", "provenance": "computed"},
1488 "denial.denial_js_loop_db": {"unit": "dB", "provenance": "computed", "note": "the J/S the loop-dynamics radius corresponds to"},
1489 "denial.denial_radius_loop_dynamics_km": {"unit": "km", "provenance": "computed", "note": "standoff at which the effective C/N0 falls to thresholds.drop_cn0_dbhz"},
1490 "denial.denial_radius_delta_km": {"unit": "km", "provenance": "computed", "note": "loop-dynamics radius minus threshold radius"},
1491 "denial.denial_radius_ratio": {"unit": "ratio (dimensionless)", "provenance": "computed"},
1492 "denial.denial_js_delta_db": {"unit": "dB", "provenance": "computed"},
1493 "denial.capture_js_threshold_db": {"unit": "dB", "provenance": "input", "note": "the engine's existing power-ratio capture criterion; reported unchanged"},
1494 "denial.capture_radius_threshold_km": {"unit": "km", "provenance": "computed"},
1495 });
1496 let hist = serde_json::json!({
1497 "lock_history.cn0_high_dbhz": {"unit": "dB-Hz", "provenance": "input"},
1498 "lock_history.cn0_low_dbhz": {"unit": "dB-Hz", "provenance": "input"},
1499 "lock_history.ramp_duration_s": {"unit": "s", "provenance": "input", "note": "per leg; the profile ramps down then back up"},
1500 "lock_history.ramp_rate_db_per_s": {"unit": "dB/s", "provenance": "computed"},
1501 "lock_history.detector_steps": {"unit": "count", "provenance": "computed"},
1502 "lock_history.drop_confirm_epochs": {"unit": "count", "provenance": "modelled-input"},
1503 "lock_history.relock_confirm_epochs": {"unit": "count", "provenance": "modelled-input"},
1504 "lock_history.drop_crossing_s": {"unit": "s", "provenance": "computed"},
1505 "lock_history.declared_loss_of_lock_s": {"unit": "s", "provenance": "computed"},
1506 "lock_history.time_to_lose_lock_after_crossing_s": {"unit": "s", "provenance": "computed", "note": "the confirmation dwell, in seconds"},
1507 "lock_history.relock_crossing_s": {"unit": "s", "provenance": "computed"},
1508 "lock_history.declared_relock_s": {"unit": "s", "provenance": "computed"},
1509 "lock_history.unlocked_duration_s": {"unit": "s", "provenance": "computed"},
1510 });
1511 for src in [denial, hist] {
1512 if let (Some(dst), Some(s)) = (u.as_object_mut(), src.as_object()) {
1513 for (k, v) in s {
1514 dst.insert(k.clone(), v.clone());
1515 }
1516 }
1517 }
1518 u
1519 }
1520}
1521
1522fn opt4(v: Option<f64>) -> String {
1523 v.map_or_else(|| "n/a".to_string(), |x| format!("{x:.4}"))
1524}
1525
1526#[cfg(test)]
1527mod tests {
1528 use super::*;
1529 use crate::sdr::{correlate, synth_if, CaCode, CorrParams, CA_CODE_LEN};
1530
1531 #[test]
1536 fn bessel_i0_matches_the_standard_tabulated_values() {
1537 let table = [
1541 (0.0_f64, 1.0_f64),
1542 (0.5, 1.063_483_370_741_323_4),
1543 (1.0, 1.266_065_877_752_008_2),
1544 (2.0, 2.279_585_302_336_067),
1545 (3.0, 4.880_792_585_865_024),
1546 (3.75, 9.118_945_860_844_567),
1547 (5.0, 27.239_871_823_604_442),
1548 (10.0, 2_815.716_628_466_254),
1549 (20.0, 43_558_282.559_553_534),
1550 ];
1551 for (x, want) in table {
1552 let got = bessel_i0(x);
1553 let rel = ((got - want) / want).abs();
1554 assert!(rel < 2.0e-6, "I0({x}) = {got}, want {want} (rel {rel:.3e})");
1555 }
1556 for x in [1.0_f64, 4.0, 10.0, 50.0, 200.0] {
1559 let rel = ((ln_bessel_i0(x) - bessel_i0(x).pln()) / bessel_i0(x).pln()).abs();
1560 assert!(rel < 1.0e-6, "ln I0({x}) disagrees: rel {rel:.3e}");
1561 }
1562 assert!(ln_bessel_i0(5000.0).is_finite());
1563 assert!(
1564 !bessel_i0(5000.0).is_finite(),
1565 "the direct form must overflow — that is why the log form exists"
1566 );
1567 }
1568
1569 fn noise_for_cn0(cn0_dbhz: f64, fs: f64) -> f64 {
1577 let cn0 = 10f64.ppowf(cn0_dbhz / 10.0);
1578 let sigma2 = fs / (2.0 * cn0);
1579 (sigma2 / (2.0 / 3.0)).sqrt()
1580 }
1581
1582 fn std_of(v: &[f64]) -> f64 {
1583 let n = v.len() as f64;
1584 let m: f64 = v.iter().sum::<f64>() / n;
1585 (v.iter().map(|x| (x - m) * (x - m)).sum::<f64>() / n).sqrt()
1586 }
1587
1588 const FS_HZ: f64 = 5.0e6;
1596
1597 #[test]
1598 fn open_loop_carrier_jitter_matches_the_sdr_correlator() {
1599 let code = CaCode::new(10).expect("PRN 10");
1612 let spe = (FS_HZ / 1000.0).round() as usize;
1613 let t = 1e-3;
1614 let n = 900usize;
1615 let measure = |cn0_db: f64| -> f64 {
1616 let noise = noise_for_cn0(cn0_db, FS_HZ);
1617 let mut all: Vec<f64> = Vec::with_capacity(3 * n);
1618 for seed in [9_123_457u64, 55_001, 3_141_593] {
1619 let iq = synth_if(
1620 &code,
1621 FS_HZ,
1622 0.0,
1623 crate::sdr::CA_CHIP_RATE_HZ,
1624 0.0,
1625 1.0,
1626 spe * n,
1627 noise,
1628 seed,
1629 );
1630 for e in 0..n {
1631 let (s, en) = (e * spe, e * spe + spe);
1632 let cp = (crate::sdr::CA_CHIP_RATE_HZ * (s as f64 / FS_HZ))
1633 .rem_euclid(CA_CODE_LEN as f64);
1634 let p = CorrParams {
1635 fs_hz: FS_HZ,
1636 carrier_freq_hz: 0.0,
1637 carrier_phase_rad: 0.0,
1638 code_rate_hz: crate::sdr::CA_CHIP_RATE_HZ,
1639 code_phase_chips: cp,
1640 corr_spacing_chips: 0.5,
1641 };
1642 let c = correlate(&iq[s..en], &code, &p);
1643 all.push((c.prompt.im / c.prompt.re).patan());
1644 }
1645 }
1646 std_of(&all)
1647 };
1648 for cn0_db in [45.0_f64, 40.0, 37.0, 35.0] {
1649 let measured = measure(cn0_db);
1650 let predicted = pll_measurement_jitter_rad(cn0_db, t);
1651 let rel = ((measured - predicted) / predicted).abs();
1652 assert!(
1653 rel < 0.02,
1654 "C/N0 {cn0_db} dB-Hz: correlator measured sigma {measured:.6} rad, closed form \
1655 {predicted:.6} rad (rel {rel:.4})"
1656 );
1657 if cn0_db <= 40.0 {
1660 let no_loss = (1.0 / (2.0 * 10f64.ppowf(cn0_db / 10.0) * t)).sqrt();
1661 let rel_no_loss = ((measured - no_loss) / no_loss).abs();
1662 assert!(
1663 rel_no_loss > 3.0 * rel,
1664 "at {cn0_db} dB-Hz the squaring loss must be clearly visible: with it \
1665 {rel:.4}, without it {rel_no_loss:.4}"
1666 );
1667 }
1668 }
1669 let measured = measure(32.0);
1672 let predicted = pll_measurement_jitter_rad(32.0, t);
1673 assert!(
1674 measured < 0.95 * predicted,
1675 "the discriminator must already be saturating at 32 dB-Hz: measured {measured:.6} \
1676 rad vs closed form {predicted:.6} rad"
1677 );
1678 }
1679
1680 #[test]
1681 fn closed_loop_carrier_jitter_matches_a_stepped_sdr_costas_loop() {
1682 let code = CaCode::new(10).expect("PRN 10");
1688 let spe = (FS_HZ / 1000.0).round() as usize;
1689 let t = 1e-3;
1690 let cn0_db = 45.0_f64;
1691 let noise = noise_for_cn0(cn0_db, FS_HZ);
1692 let n = 4000usize;
1693 let iq = synth_if(
1694 &code,
1695 FS_HZ,
1696 0.0,
1697 crate::sdr::CA_CHIP_RATE_HZ,
1698 0.0,
1699 1.0,
1700 spe * n,
1701 noise,
1702 777_001,
1703 );
1704 let sigma_meas = pll_measurement_jitter_rad(cn0_db, t);
1705 for g in [0.05_f64, 0.1, 0.2] {
1706 let mut ph = 0.0f64;
1707 let mut resid = Vec::with_capacity(n);
1708 for e in 0..n {
1709 let (s, en) = (e * spe, e * spe + spe);
1710 let cp = (crate::sdr::CA_CHIP_RATE_HZ * (s as f64 / FS_HZ))
1711 .rem_euclid(CA_CODE_LEN as f64);
1712 let p = CorrParams {
1713 fs_hz: FS_HZ,
1714 carrier_freq_hz: 0.0,
1715 carrier_phase_rad: ph,
1716 code_rate_hz: crate::sdr::CA_CHIP_RATE_HZ,
1717 code_phase_chips: cp,
1718 corr_spacing_chips: 0.5,
1719 };
1720 let c = correlate(&iq[s..en], &code, &p);
1721 let disc = (c.prompt.im / c.prompt.re).patan();
1722 ph = (ph + g * disc).rem_euclid(std::f64::consts::TAU);
1723 let w = if ph > std::f64::consts::PI {
1724 ph - std::f64::consts::TAU
1725 } else {
1726 ph
1727 };
1728 resid.push(w);
1729 }
1730 let measured = std_of(&resid[500..]);
1731 let discrete = sigma_meas * (g / (2.0 - g)).sqrt();
1732 let rel = ((measured - discrete) / discrete).abs();
1733 assert!(
1734 rel < 0.06,
1735 "g {g}: stepped loop residual {measured:.5} rad vs discrete closed form \
1736 {discrete:.5} rad (rel {rel:.4})"
1737 );
1738 let bn = equivalent_first_order_bandwidth_hz(g, t, 1.0);
1742 let continuous = pll_thermal_jitter_rad(cn0_db, bn, t);
1743 assert!(
1744 ((continuous - discrete) / discrete).abs() < g,
1745 "continuous B_n = g/(4T) must agree with the discrete form to O(g): \
1746 {continuous:.5} vs {discrete:.5} at g {g}"
1747 );
1748 assert!(
1749 ((measured - continuous) / continuous).abs() < 0.10,
1750 "g {g}: stepped loop {measured:.5} rad vs continuous closed form \
1751 {continuous:.5} rad"
1752 );
1753 }
1754 }
1755
1756 #[test]
1761 fn code_ramp_lag_matches_a_stepped_sdr_dll() {
1762 let code = CaCode::new(10).expect("PRN 10");
1769 let d = 0.5_f64;
1770 let g = 0.1_f64;
1771 let t = 1e-3;
1772 let cp0 = 100.0_f64;
1773 let spe = (FS_HZ / 1000.0).round() as usize;
1774 let bn =
1775 equivalent_first_order_bandwidth_hz(g, t, early_late_amplitude_discriminator_slope(d));
1776 let grid_period_epochs = (crate::sdr::CA_CHIP_RATE_HZ / FS_HZ) / (t);
1783 let burn_in = 200usize;
1784 for slew in [1.0_f64, 2.0, 3.0] {
1785 let window = (2.0 * grid_period_epochs / slew).round() as usize;
1786 let n = burn_in + window;
1787 let iq = synth_if(
1788 &code,
1789 FS_HZ,
1790 0.0,
1791 crate::sdr::CA_CHIP_RATE_HZ + slew,
1792 cp0,
1793 1.0,
1794 spe * n,
1795 0.0,
1796 7,
1797 );
1798 let mut cp = cp0;
1799 let mut errs = Vec::with_capacity(n);
1800 for e in 0..n {
1801 let (s, en) = (e * spe, e * spe + spe);
1802 let p = CorrParams {
1803 fs_hz: FS_HZ,
1804 carrier_freq_hz: 0.0,
1805 carrier_phase_rad: 0.0,
1806 code_rate_hz: crate::sdr::CA_CHIP_RATE_HZ,
1807 code_phase_chips: cp,
1808 corr_spacing_chips: d,
1809 };
1810 let c = correlate(&iq[s..en], &code, &p);
1811 let (em, lm) = (c.early.abs(), c.late.abs());
1812 let disc = 0.5 * (em - lm) / (em + lm);
1813 let tt = (e * spe) as f64 / FS_HZ;
1814 let truth = cp0 + (crate::sdr::CA_CHIP_RATE_HZ + slew) * tt;
1815 let dx = cp - truth;
1816 let nn = CA_CODE_LEN as f64;
1817 let m = dx.rem_euclid(nn);
1818 errs.push(if m > nn / 2.0 { m - nn } else { m });
1819 cp = (cp + crate::sdr::CA_CHIP_RATE_HZ * t + g * disc).rem_euclid(nn);
1820 }
1821 let epoch_start_lag: f64 =
1822 errs[burn_in..].iter().sum::<f64>() / (errs.len() - burn_in) as f64;
1823 let mid_epoch_lag = epoch_start_lag - slew * t / 2.0;
1826 let predicted = -dll_ramp_lag_chips(slew, bn);
1827 let rel = ((mid_epoch_lag - predicted) / predicted).abs();
1828 assert!(
1829 rel < 0.005,
1830 "slew {slew} chip/s: stepped DLL mid-epoch lag {mid_epoch_lag:.6} chips vs \
1831 closed form {predicted:.6} chips at B_n {bn:.3} Hz (rel {rel:.4})"
1832 );
1833 assert!(
1836 predicted.abs() < d / 2.0,
1837 "slew {slew} must stay inside the pull-in half-range for this check"
1838 );
1839 }
1840 }
1841
1842 #[test]
1847 fn identity_loop_filter_reduces_measurement_jitter_by_sqrt_two_bt() {
1848 for cn0 in [20.0_f64, 30.0, 45.0] {
1852 for b in [0.5_f64, 2.0, 10.0, 50.0] {
1853 for t in [1e-3_f64, 5e-3, 2e-2] {
1854 let a = pll_thermal_jitter_rad(cn0, b, t);
1855 let e = pll_measurement_jitter_rad(cn0, t) * (2.0 * b * t).sqrt();
1856 assert!((a - e).abs() <= 1e-12 * a.max(e), "PLL: {a} vs {e}");
1857 for d in [0.1_f64, 0.5, 1.0] {
1858 let a = dll_thermal_jitter_chips(cn0, b, t, d);
1859 let e = dll_measurement_jitter_chips(cn0, t, d) * (2.0 * b * t).sqrt();
1860 assert!((a - e).abs() <= 1e-12 * a.max(e), "DLL: {a} vs {e}");
1861 }
1862 }
1863 }
1864 }
1865 }
1866
1867 #[test]
1868 fn hysteresis_lies_between_the_two_bandwidth_scaling_limits() {
1869 for r in [1.5_f64, 2.0, 4.0, 10.0] {
1875 for t in [1e-3_f64, 2e-2] {
1876 for b in [2.0_f64, 10.0, 25.0] {
1877 let cfg = LoopConfig {
1878 pll_bandwidth_hz: b,
1879 dll_bandwidth_hz: 1.0,
1880 integration_s: t,
1881 spacing_chips: 0.5,
1882 carrier_allowance_deg: COSTAS_THRESHOLD_DEG,
1883 code_allowance_chips: 0.25,
1884 pullin_bandwidth_ratio: r,
1885 };
1886 let th = cfg.thresholds(0.0, 0.0);
1887 let h = th.hysteresis_db.expect("a hysteresis width");
1888 let (lo, hi) = (5.0 * r.plog10(), 10.0 * r.plog10());
1889 assert!(
1890 h > lo - 1e-9 && h < hi + 1e-9,
1891 "ratio {r}, B {b}, T {t}: hysteresis {h:.4} dB outside [{lo:.4}, {hi:.4}]"
1892 );
1893 assert!(
1894 th.relock_cn0_dbhz.unwrap() > th.drop_cn0_dbhz.unwrap(),
1895 "re-lock must be strictly harder than holding lock"
1896 );
1897 }
1898 }
1899 }
1900 }
1901
1902 #[test]
1903 fn a_threshold_is_exactly_where_the_stated_rule_is_met() {
1904 let t = 1e-3;
1907 for b in [1.0_f64, 10.0, 30.0] {
1908 let c = carrier_tracking_threshold_cn0_dbhz(b, t, COSTAS_THRESHOLD_DEG, 0.0)
1909 .expect("a carrier threshold");
1910 let three_sigma = 3.0 * pll_thermal_jitter_rad(c, b, t).to_degrees();
1911 assert!(
1912 (three_sigma - COSTAS_THRESHOLD_DEG).abs() < 1e-6,
1913 "B {b}: 3 sigma at the threshold is {three_sigma}, not {COSTAS_THRESHOLD_DEG}"
1914 );
1915 for d in [0.1_f64, 0.5, 1.0] {
1916 let cc = code_tracking_threshold_cn0_dbhz(b, t, d, d / 2.0, 0.0)
1917 .expect("a code threshold");
1918 let three = 3.0 * dll_thermal_jitter_chips(cc, b, t, d);
1919 assert!(
1920 (three - d / 2.0).abs() < 1e-9,
1921 "B {b} d {d}: 3 sigma at the threshold is {three}, not {}",
1922 d / 2.0
1923 );
1924 }
1925 }
1926 assert!(
1929 carrier_tracking_threshold_cn0_dbhz(1.0, 1e-3, COSTAS_THRESHOLD_DEG, 1000.0).is_none(),
1930 "a 1 Hz loop cannot hold 1000 Hz/s at any C/N0"
1931 );
1932 }
1933
1934 #[test]
1935 fn a_wider_loop_needs_more_signal_and_a_longer_dwell_costs_time() {
1936 let t = 1e-3;
1937 let mut prev = f64::NEG_INFINITY;
1938 for b in [1.0_f64, 2.0, 5.0, 10.0, 25.0, 50.0] {
1939 let c = carrier_tracking_threshold_cn0_dbhz(b, t, COSTAS_THRESHOLD_DEG, 0.0).unwrap();
1940 assert!(
1941 c > prev,
1942 "the threshold must rise with bandwidth: {b} Hz -> {c}"
1943 );
1944 prev = c;
1945 }
1946 for dwell in [1usize, 10, 50, 500] {
1948 let mut det = LockDetector::new(25.0, 27.0, dwell, 10);
1949 let h = run_lock_history(&mut det, 45.0, 15.0, 60.0, 1e-3).expect("history");
1950 let lag = h.declared_loss_s - h.drop_crossing_s;
1951 assert!(
1952 (lag - dwell as f64 * 1e-3).abs() < 1.5e-3,
1953 "dwell {dwell}: declared {lag} s after the crossing"
1954 );
1955 }
1956 }
1957
1958 #[test]
1959 fn the_cycle_slip_time_collapses_as_the_jitter_approaches_a_quarter_cycle() {
1960 let (b, t) = (10.0_f64, 1e-3);
1964 let mut prev = f64::INFINITY;
1965 for cn0 in [45.0_f64, 40.0, 35.0, 30.0, 27.0, 25.0, 22.0, 20.0] {
1966 let l = log10_mean_time_to_cycle_slip_s(cn0, b, t);
1967 assert!(l < prev, "slip time must fall with C/N0 at {cn0}");
1968 prev = l;
1969 }
1970 let at_threshold = carrier_tracking_threshold_cn0_dbhz(b, t, COSTAS_THRESHOLD_DEG, 0.0)
1971 .expect("a threshold");
1972 let l = log10_mean_time_to_cycle_slip_s(at_threshold, b, t);
1973 assert!(
1974 (1.0..3.0).contains(&l),
1975 "at the 15-degree design point the Costas slip time is of order a minute, got 10^{l} s"
1976 );
1977 let sigma = pll_thermal_jitter_rad(at_threshold, b, t);
1979 let rho = 1.0 / (sigma * sigma);
1980 let pll_form = 2.0 * std::f64::consts::PI.plog10()
1981 + rho.plog10()
1982 + 2.0 * ln_bessel_i0(rho) / std::f64::consts::LN_10
1983 - (2.0 * b).plog10();
1984 assert!(pll_form - l > 5.0, "{pll_form} vs {l}");
1985 let quarter = log10_mean_time_to_cycle_slip_s(20.0, b, t);
1986 assert!(
1987 quarter < 0.0,
1988 "at 20 dB-Hz the Costas loop should slip within a second, got 10^{quarter}"
1989 );
1990 }
1991
1992 #[test]
1993 fn a_spoofer_that_slews_too_fast_outruns_the_loop() {
1994 let cfg = LoopConfig {
1995 pll_bandwidth_hz: 10.0,
1996 dll_bandwidth_hz: 1.0,
1997 integration_s: 1e-3,
1998 spacing_chips: 0.5,
1999 carrier_allowance_deg: COSTAS_THRESHOLD_DEG,
2000 code_allowance_chips: 0.25,
2001 pullin_bandwidth_ratio: 2.0,
2002 };
2003 let max = max_code_slew_chips_per_s(cfg.dll_bandwidth_hz, cfg.code_allowance_chips);
2004 assert!(
2005 (max - 1.0).abs() < 1e-12,
2006 "4 * 1 Hz * 0.25 chip = 1 chip/s, got {max}"
2007 );
2008 let live = max_code_slew_at_cn0_chips_per_s(
2012 cfg.dll_bandwidth_hz,
2013 45.0,
2014 cfg.integration_s,
2015 cfg.spacing_chips,
2016 cfg.code_allowance_chips,
2017 );
2018 assert!(
2019 live < max && live > 0.9 * max,
2020 "jitter-aware ceiling {live} vs {max}"
2021 );
2022 assert!(pull_in_cell(&cfg, 45.0, live * 0.999, 0.0).code_follows);
2023 assert!(!pull_in_cell(&cfg, 45.0, live * 1.001, 0.0).code_follows);
2024 let mut seen_fail = false;
2027 for k in 1..=20 {
2028 let slew = live * k as f64 / 5.0;
2029 let cell = pull_in_cell(&cfg, 45.0, slew, 0.0);
2030 if !cell.code_follows {
2031 seen_fail = true;
2032 } else {
2033 assert!(!seen_fail, "following must not resume once it has failed");
2034 }
2035 assert!((cell.code_lag_chips - slew / (4.0 * cfg.dll_bandwidth_hz)).abs() < 1e-12);
2036 }
2037 assert!(seen_fail, "a fast enough slew must outrun the loop");
2038 let rate = max_doppler_rate_hz_per_s(10.0, 45.0, 1e-3, COSTAS_THRESHOLD_DEG);
2040 assert!(pull_in_cell(&cfg, 45.0, 0.0, rate * 0.99).carrier_follows);
2041 assert!(!pull_in_cell(&cfg, 45.0, 0.0, rate * 1.01).carrier_follows);
2042 }
2043
2044 #[test]
2045 fn the_dynamic_stress_matches_the_second_order_loop_response() {
2046 for b in [2.0_f64, 10.0, 25.0] {
2051 for fdot in [0.5_f64, 5.0, 50.0] {
2052 let wn = b / SECOND_ORDER_BN_OVER_WN;
2053 let want = 360.0 * fdot / (wn * wn);
2054 let got = pll_dynamic_stress_deg(fdot, b);
2055 assert!(
2056 (got - want).abs() < 1e-12 * want.max(1.0),
2057 "{got} vs {want}"
2058 );
2059 }
2060 }
2061 }
2062
2063 #[test]
2068 fn both_denial_radii_are_reported_and_their_difference_is_named() {
2069 let scn = TrackingLoopScenario::default();
2070 let (json, summary) = scn.run_json().expect("run");
2071 let v: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
2072 let d = &v["denial"];
2073 let thr = d["denial_radius_threshold_km"]
2074 .as_f64()
2075 .expect("threshold radius");
2076 let lp = d["denial_radius_loop_dynamics_km"]
2077 .as_f64()
2078 .expect("loop-dynamics radius");
2079 let delta = d["denial_radius_delta_km"]
2080 .as_f64()
2081 .expect("the named delta");
2082 assert!(thr > 0.0 && lp > 0.0);
2084 assert!(
2086 (delta - (lp - thr)).abs() < 1e-9,
2087 "delta {delta} != {lp} - {thr}"
2088 );
2089 assert!((d["denial_radius_ratio"].as_f64().unwrap() - lp / thr).abs() < 1e-12);
2090 let link = scn.denial_link();
2093 assert!(
2094 (link.js_db_at(thr * 1000.0) - scn.denial_js_threshold_db).abs() < 1e-6,
2095 "the threshold radius must be exactly where J/S = {} dB",
2096 scn.denial_js_threshold_db
2097 );
2098 let drop = v["thresholds"]["drop_cn0_dbhz"].as_f64().unwrap();
2100 assert!((link.effective_cn0_dbhz_at(lp * 1000.0) - drop).abs() < 1e-6);
2101 assert!(d["denial_radius_delta_definition"].is_string());
2103 assert!(d["criterion_note"].is_string());
2104 assert!(summary.contains("denial radius threshold"));
2106 assert!(summary.contains("loop dynamics"));
2107 assert!(summary.contains("delta"));
2108 assert!(d["capture_radius_threshold_km"].as_f64().unwrap() > 0.0);
2111 assert!(d["capture_radius_loop_dynamics_km"].is_null());
2112 assert!(d["capture_radius_loop_dynamics_status"]
2113 .as_str()
2114 .is_some_and(|s| s.starts_with("not-applicable")));
2115 }
2116
2117 #[test]
2118 fn the_loop_radius_moves_with_the_loop_and_not_with_the_power_ratio() {
2119 let base = TrackingLoopScenario::default();
2123 let narrow = TrackingLoopScenario {
2124 pll_bandwidth_hz: 2.0,
2125 ..TrackingLoopScenario::default()
2126 };
2127 let f = |s: &TrackingLoopScenario| {
2128 let (j, _) = s.run_json().unwrap();
2129 let v: serde_json::Value = serde_json::from_str(&j).unwrap();
2130 (
2131 v["denial"]["denial_radius_threshold_km"].as_f64().unwrap(),
2132 v["denial"]["denial_radius_loop_dynamics_km"]
2133 .as_f64()
2134 .unwrap(),
2135 )
2136 };
2137 let (t0, l0) = f(&base);
2138 let (t1, l1) = f(&narrow);
2139 assert!(
2140 (t0 - t1).abs() < 1e-9,
2141 "the power-ratio radius must not move"
2142 );
2143 assert!(
2144 l1 < l0,
2145 "a narrower loop must shrink the loop-dynamics radius: {l1} vs {l0}"
2146 );
2147 }
2148
2149 #[test]
2150 fn every_reported_figure_carries_a_unit_and_a_provenance_class() {
2151 let scn = TrackingLoopScenario::default();
2152 let (json, _) = scn.run_json().expect("run");
2153 let v: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
2154 let units = v["units"].as_object().expect("a units block");
2155 assert!(!units.is_empty());
2156 for (field, meta) in units {
2157 assert!(meta["unit"].is_string(), "{field} has no unit");
2158 assert!(
2159 meta["provenance"].is_string(),
2160 "{field} has no provenance class"
2161 );
2162 let p = meta["provenance"].as_str().unwrap();
2163 assert!(
2164 matches!(p, "input" | "computed" | "modelled-input"),
2165 "{field} has an unknown provenance class {p:?}"
2166 );
2167 }
2168 let described: std::collections::HashSet<&str> = units.keys().map(|k| k.as_str()).collect();
2169 fn walk(
2172 v: &serde_json::Value,
2173 path: &str,
2174 described: &std::collections::HashSet<&str>,
2175 missing: &mut Vec<String>,
2176 ) {
2177 match v {
2178 serde_json::Value::Number(_) => {
2179 if !described.contains(path) {
2180 missing.push(path.to_string());
2181 }
2182 }
2183 serde_json::Value::Object(o) => {
2184 for (k, e) in o {
2185 if path.is_empty() && k == "units" {
2186 continue;
2187 }
2188 let p = if path.is_empty() {
2189 k.clone()
2190 } else {
2191 format!("{path}.{k}")
2192 };
2193 walk(e, &p, described, missing);
2194 }
2195 }
2196 serde_json::Value::Array(a) => {
2197 if let Some(first) = a.first() {
2198 walk(first, &format!("{path}[]"), described, missing);
2199 }
2200 }
2201 _ => {}
2202 }
2203 }
2204 let mut missing = Vec::new();
2205 walk(&v, "", &described, &mut missing);
2206 assert!(
2207 missing.is_empty(),
2208 "numeric fields missing from the units block: {missing:?}"
2209 );
2210 }
2211
2212 #[test]
2213 fn the_units_block_describes_only_fields_that_exist() {
2214 let scn = TrackingLoopScenario::default();
2215 let (json, _) = scn.run_json().expect("run");
2216 let v: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
2217 for field in v["units"].as_object().expect("units").keys() {
2218 let mut cur = &v;
2219 for seg in field.split('.') {
2220 cur = if let Some(name) = seg.strip_suffix("[]") {
2221 let arr = cur
2222 .get(name)
2223 .and_then(|x| x.as_array())
2224 .unwrap_or_else(|| panic!("{field}: {name} is not an array"));
2225 arr.first()
2226 .unwrap_or_else(|| panic!("{field}: {name} is empty"))
2227 } else {
2228 cur.get(seg)
2229 .unwrap_or_else(|| panic!("units names a missing field: {field}"))
2230 };
2231 }
2232 assert!(!cur.is_null(), "units names a null field: {field}");
2233 }
2234 }
2235
2236 #[test]
2237 fn the_run_is_reproducible_and_declares_what_is_modelled() {
2238 let scn = TrackingLoopScenario::default();
2239 let (a, sa) = scn.run_json().expect("run");
2240 let (b, sb) = scn.run_json().expect("run");
2241 assert_eq!(a, b, "the run must be reproducible");
2242 assert_eq!(sa, sb);
2243 let v: serde_json::Value = serde_json::from_str(&a).expect("valid JSON");
2244 assert_eq!(v["kind"], "tracking-loop");
2245 let label = v["label"].as_str().expect("a label");
2246 assert!(label.contains("MODELLED"));
2247 assert!(label.contains("VALIDATED"));
2248 for phrase in [
2250 "Allan-deviation",
2251 "multipath",
2252 "front-end bandwidth",
2253 "half-cycle",
2254 ] {
2255 assert!(
2256 label.contains(phrase),
2257 "the label must name {phrase} as excluded"
2258 );
2259 }
2260 assert!(label.contains("ALONGSIDE"));
2261 }
2262
2263 #[test]
2264 fn the_hysteretic_detector_declares_loss_then_recovery_with_a_gap() {
2265 let scn = TrackingLoopScenario::default();
2266 let (json, _) = scn.run_json().expect("run");
2267 let v: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
2268 let h = &v["lock_history"];
2269 let loss = h["declared_loss_of_lock_s"].as_f64().expect("a loss time");
2270 let relock = h["declared_relock_s"].as_f64().expect("a relock time");
2271 let cross = h["drop_crossing_s"].as_f64().unwrap();
2272 let recross = h["relock_crossing_s"].as_f64().unwrap();
2273 assert!(cross < loss, "the declaration must follow the crossing");
2274 assert!(loss < recross, "the ramp must come back up after the loss");
2275 assert!(recross < relock, "recovery must follow its own crossing");
2276 let drop_cn0 = v["thresholds"]["drop_cn0_dbhz"].as_f64().unwrap();
2279 let relock_cn0 = v["thresholds"]["relock_cn0_dbhz"].as_f64().unwrap();
2280 assert!(relock_cn0 > drop_cn0);
2281 assert!(h["unlocked_duration_s"].as_f64().unwrap() > 0.0);
2282 }
2283
2284 #[test]
2285 fn bad_inputs_are_refused_with_a_message_not_a_panic() {
2286 let cases: &[(&str, TrackingLoopScenario)] = &[
2287 (
2288 "pullin_bandwidth_ratio",
2289 TrackingLoopScenario {
2290 pullin_bandwidth_ratio: 0.5,
2291 ..Default::default()
2292 },
2293 ),
2294 (
2295 "correlator_spacing_chips",
2296 TrackingLoopScenario {
2297 correlator_spacing_chips: 2.5,
2298 ..Default::default()
2299 },
2300 ),
2301 (
2302 "pll_bandwidth_hz",
2303 TrackingLoopScenario {
2304 pll_bandwidth_hz: 0.0,
2305 ..Default::default()
2306 },
2307 ),
2308 (
2309 "cn0_low_dbhz",
2310 TrackingLoopScenario {
2311 cn0_low_dbhz: 60.0,
2312 ..Default::default()
2313 },
2314 ),
2315 (
2316 "jitter_table_cn0_dbhz",
2317 TrackingLoopScenario {
2318 jitter_table_cn0_dbhz: Vec::new(),
2319 ..Default::default()
2320 },
2321 ),
2322 ];
2323 for (name, scn) in cases {
2324 let e = scn.run_json().expect_err("must be refused");
2325 assert!(e.contains(name), "the error must name {name}: {e}");
2326 }
2327 let huge = TrackingLoopScenario {
2330 ramp_duration_s: 1.0e9,
2331 ..Default::default()
2332 };
2333 let e = huge.run_json().expect_err("must be refused");
2334 assert!(e.contains("detector steps"), "{e}");
2335 }
2336
2337 #[test]
2338 fn the_bundled_scenario_file_is_the_default_configuration() {
2339 let src = include_str!("../scenarios/tracking-loop.toml");
2340 let scn: TrackingLoopScenario = toml::from_str(src).expect("the bundled TOML parses");
2341 let (a, _) = scn.run_json().expect("run");
2342 let (b, _) = TrackingLoopScenario::default()
2343 .run_json()
2344 .expect("run the default");
2345 assert_eq!(a, b, "the bundled file must reproduce the default run");
2346 }
2347}