kshana 0.37.0

Open, reproducible PNT-resilience simulator with quantum-sensor performance models
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
// SPDX-License-Identifier: AGPL-3.0-only
//! **A multi-channel software tracking bank for any [`SpreadingCode`](crate::iq::SpreadingCode)
//! on any [`IqSource`](crate::iq::IqSource).**
//!
//! Each channel runs early, prompt and late correlators against a replica generated
//! sample by sample from [`SpreadingCode::value_at`](crate::iq::SpreadingCode::value_at), a
//! carrier loop (PLL of order 1, 2 or 3, FLL of order 1 or 2, or an FLL-assisted PLL) and
//! a code loop (DLL of order 1 or 2, optionally carrier-aided), with loop filters designed
//! from a noise bandwidth and the integration time ([`filter`]), the discriminators of
//! [`discrim`], lock detectors, two C/N0 estimators and histogram bit synchronisation
//! ([`cn0`]).
//!
//! * **NCOs.** The code NCO carries the replica's code phase as a real number of chips and
//!   advances it by `code_rate/fs` every sample; an integration (one code period) starts
//!   at the first sample whose phase has wrapped, and the fractional phase left over is
//!   carried into the next period, so the replica is sampled at its exact phase rather than
//!   a phase rounded to the epoch boundary (no zero-order-hold staircase). The carrier NCO
//!   likewise carries its phase in cycles; within a period the local oscillator is rotated
//!   by a fixed phasor and re-anchored to the exact phase at every period start.
//! * **Integration.** The loops close every `coherent_periods` code periods. On a
//!   data-modulated signal the channel integrates one period at a time until the bit edge
//!   is found, then aligns its integrations to the bit edges.
//! * **Outputs** per loop update ([`EpochOutput`]): E/P/L, the three discriminator outputs,
//!   the loop states (carrier Doppler, carrier phase, code rate, code phase and the time the
//!   replica's code period started), the phase lock indicator and lock flags, both C/N0
//!   estimates, the bit edge and decoded bits.
//! * **Replay.** [`replay`] runs one [`IqSource`](crate::iq::IqSource) once through a bank holding every
//!   (channel, loop configuration) pair and returns the results per configuration, so many
//!   loop designs can be compared on one recording.
//! * **Streaming, lock states and designs.** [`TrackSession`] runs the same channels in
//!   bounded memory, handing every update to an [`sink::EpochSink`] as it happens
//!   (CSV/JSON-Lines/binary `kshana.track-epoch/1` writers and a reader, running
//!   [`sink::Summary`] metrics), under the [`lock`] state machine: pull-in, locked, lost,
//!   re-acquisition, with a ±1/(2T) false-lock check. [`design`] reads loop designs from
//!   `kshana.loop-design/1` TOML (`docs/design/LOOP-DESIGN-TOML.md`).
//!
//! Honest label: MODELLED. The loop designs, jitter, steady-state error and C/N0
//! estimators are checked against closed forms (Kaplan & Hegarty; Van Dierendonck;
//! Pauluzzi & Beaulieu) by seeded simulation in `tests/iq_receiver.rs`; no comparison with
//! an external receiver's output is claimed here. No front-end filter or quantisation is
//! modelled (that is `iq::frontend`), the discriminator scalings assume the ideal BPSK
//! triangle, and there is no vector tracking, multipath-mitigating correlator or
//! navigation-message decoding beyond bit signs.

pub mod boc;
pub mod cn0;
pub mod design;
pub mod discrim;
pub mod filter;

mod bank;
mod channel;
pub mod lock;
pub mod sink;

pub use bank::{replay, ReplayResult, TrackingBank};
pub use boc::{BocKind, BocTracking, BumpJump, BumpJumpConfig, BumpJumpEvent};
pub use channel::{Channel, ChannelInit, EpochOutput, MAX_EXTRA_TAPS, MAX_TAP_OFFSET_CHIPS};
pub use lock::{LockEvent, LockState, SessionChannel, TrackSession};

use self::cn0::BitSyncConfig;
use self::discrim::{DllDiscriminator, FllDiscriminator, PllDiscriminator};
use self::filter::LoopFilter;
use super::Cf64;
use std::f64::consts::TAU;

/// Samples per chip, when a stream sampled at `fs_hz` is **commensurate** with a code of
/// `chip_rate_hz`: the ratio lies within 1e-6 (relative) of a multiple of 1/2. The samples then
/// fall on the same few chip phases in every chip, the early and late correlators see a
/// staircase instead of the correlation triangle, and the code loop's discriminator has flat
/// steps (a dead zone). At exactly 2 samples per chip with 0.5-chip spacing the S-curve is a
/// single step and the DLL dithers bang-bang (≈ 0.09 chip, ≈ 26 m, RMS code error on GPS L1
/// C/A at 45 dB-Hz against ≈ 0.004 chip at an incommensurate rate); 2.5, 3 and 5 samples per
/// chip are milder but biased. Evidence: `docs/design/evidence/dll-jitter/`. Real front ends
/// avoid such rates; a recording made at one should not be used to judge code-loop
/// performance. Returns `None` for an incommensurate rate.
pub fn commensurate_samples_per_chip(fs_hz: f64, chip_rate_hz: f64) -> Option<f64> {
    if !(fs_hz > 0.0 && chip_rate_hz > 0.0) {
        return None;
    }
    let r = fs_hz / chip_rate_hz;
    let nearest = (2.0 * r).round() / 2.0;
    (nearest > 0.0 && (r - nearest).abs() <= 1e-6 * r).then_some(r)
}

/// When the FLL path of an FLL-assisted PLL feeds the loop.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum FllAssist {
    /// Only during pull-in: the FLL pulls the carrier in, hands over to the PLL once the
    /// channel is phase-locked, and comes back when phase lock is lost (with hysteresis,
    /// see [`FllGate`]). Left on, a 10 Hz FLL path injects enough frequency noise to break
    /// phase lock below about 38 dB-Hz (`docs/design/evidence/carrier-lock/`).
    PullIn(FllGate),
    /// On at every update.
    Always,
}

impl Default for FllAssist {
    fn default() -> Self {
        FllAssist::PullIn(FllGate::default())
    }
}

/// The hand-over between the FLL and the PLL of [`FllAssist::PullIn`]. The FLL path is
/// switched off once the smoothed PLI has stayed at or above `off_pli` for `dwell_s`, and
/// back on once it has stayed below `on_pli` for `dwell_s`. `on_pli < off_pli` and the
/// dwell keep the gate from chattering around one threshold.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct FllGate {
    /// Smoothed PLI at or above which the FLL hands over to the PLL.
    pub off_pli: f64,
    /// Smoothed PLI below which the FLL comes back.
    pub on_pli: f64,
    /// How long (s) either condition must hold.
    pub dwell_s: f64,
}

impl Default for FllGate {
    fn default() -> Self {
        Self {
            off_pli: 0.8,
            on_pli: 0.6,
            dwell_s: 0.1,
        }
    }
}

/// The carrier loop of a channel.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum CarrierLoop {
    /// Phase-locked loop of `order` 1, 2 or 3 with noise bandwidth `bn_hz`.
    Pll {
        /// Loop order (1, 2 or 3).
        order: u8,
        /// Noise bandwidth (Hz).
        bn_hz: f64,
    },
    /// Frequency-locked loop of `order` 1 or 2 with noise bandwidth `bn_hz`.
    Fll {
        /// Loop order (1 or 2).
        order: u8,
        /// Noise bandwidth (Hz).
        bn_hz: f64,
    },
    /// FLL-assisted PLL (Ward): a PLL with an FLL feeding its integrators. Ward's pairings
    /// are a second-order PLL with a first-order FLL and a third-order PLL with a
    /// second-order FLL.
    FllAssistedPll {
        /// PLL order (1, 2 or 3).
        pll_order: u8,
        /// PLL noise bandwidth (Hz).
        pll_bn_hz: f64,
        /// FLL order (1 or 2).
        fll_order: u8,
        /// FLL noise bandwidth (Hz).
        fll_bn_hz: f64,
    },
}

impl CarrierLoop {
    /// The loop filter this design calls for.
    pub fn filter(&self) -> Result<LoopFilter, String> {
        match *self {
            CarrierLoop::Pll { order, bn_hz } => LoopFilter::pll(order, bn_hz),
            CarrierLoop::Fll { order, bn_hz } => LoopFilter::fll(order, bn_hz),
            CarrierLoop::FllAssistedPll {
                pll_order,
                pll_bn_hz,
                fll_order,
                fll_bn_hz,
            } => LoopFilter::new(Some((pll_order, pll_bn_hz)), Some((fll_order, fll_bn_hz))),
        }
    }

    /// Whether the design has a phase path.
    pub fn has_pll(&self) -> bool {
        !matches!(self, CarrierLoop::Fll { .. })
    }

    /// Whether the design has a frequency path.
    pub fn has_fll(&self) -> bool {
        !matches!(self, CarrierLoop::Pll { .. })
    }
}

/// The loop design of one channel. One recording can be replayed through many of these.
#[derive(Clone, Debug, PartialEq)]
pub struct LoopConfig {
    /// A label carried into the results.
    pub label: String,
    /// Code periods per loop update once bit-synchronised (1 before, on a data signal).
    /// On a data signal it must divide the bit length.
    pub coherent_periods: usize,
    /// Early-late correlator spacing `d` (chips); the early and late replicas sit `d/2`
    /// either side of the prompt.
    pub spacing_chips: f64,
    /// Extra correlator taps: offsets (chips) relative to the prompt, a positive offset
    /// being early. Each is correlated alongside E/P/L and returned in
    /// `EpochOutput::extra`; the loops never use them.
    /// Empty by default.
    pub extra_taps_chips: Vec<f64>,
    /// Code discriminator.
    pub dll: DllDiscriminator,
    /// Code loop order (1 or 2).
    pub dll_order: u8,
    /// Code loop noise bandwidth (Hz).
    pub dll_bn_hz: f64,
    /// Whether the code rate follows the carrier loop's Doppler, scaled by
    /// `chip_rate/carrier_hz` (carrier aiding). Without aiding the code rate keeps the
    /// initial Doppler's code rate plus the code loop's correction.
    pub carrier_aiding: bool,
    /// Carrier loop design.
    pub carrier: CarrierLoop,
    /// Carrier-phase discriminator (a Costas form for a data-modulated signal).
    pub pll_discriminator: PllDiscriminator,
    /// Carrier-frequency discriminator.
    pub fll_discriminator: FllDiscriminator,
    /// When the FLL path of an FLL-assisted PLL is used (ignored by the other kinds).
    pub fll_assist: FllAssist,
    /// Smoothed phase lock indicator above which phase lock is declared.
    pub pli_threshold: f64,
    /// NWPR C/N0 (dB-Hz) at or above which code lock is declared.
    pub code_lock_cn0_dbhz: f64,
    /// Number of windows `M` each C/N0 estimate averages (sliding).
    pub cn0_windows: usize,
    /// Prompts per window on a data-free signal (a data signal uses the bit length).
    pub cn0_window_periods: usize,
    /// Bit synchroniser settings.
    pub bit_sync: BitSyncConfig,
    /// Opt-in BOC tracking ([`boc`]): the BOC(1,1) DLL scaling and, optionally, bump-jump
    /// ambiguity resolution on two extra very-early/very-late correlators. `None` (the
    /// default) is the plain BPSK-normalised channel.
    pub boc: Option<BocTracking>,
    /// Opt-in cap on the FLL pull-in of [`FllAssist::PullIn`] (s): once the FLL path has been
    /// on this long without handing over, it hands over to the PLL whatever the PLI, and until
    /// the PLI next reaches the gate's `off_pli` it comes back only after the PLI has held
    /// below `on_pli` for this long (not the gate's dwell). `None` (the default) leaves the
    /// PLI-only hand-over (`docs/design/evidence/fll-handover-floor/`).
    pub fll_pull_in_max_s: Option<f64>,
}

impl Default for LoopConfig {
    /// A GPS-L1-C/A-like design: 1-period integration, 0.5-chip spacing, carrier-aided
    /// first-order 2 Hz early-minus-late-power DLL, second-order 15 Hz Costas PLL assisted
    /// by a first-order 10 Hz FLL during pull-in, PLI threshold 0.8, code lock at 26 dB-Hz, C/N0 over 50
    /// windows.
    fn default() -> Self {
        Self {
            label: "default".into(),
            coherent_periods: 1,
            spacing_chips: 0.5,
            extra_taps_chips: Vec::new(),
            dll: DllDiscriminator::EarlyMinusLatePower,
            dll_order: 1,
            dll_bn_hz: 2.0,
            carrier_aiding: true,
            carrier: CarrierLoop::FllAssistedPll {
                pll_order: 2,
                pll_bn_hz: 15.0,
                fll_order: 1,
                fll_bn_hz: 10.0,
            },
            pll_discriminator: PllDiscriminator::CostasAtan,
            fll_discriminator: FllDiscriminator::Atan2,
            fll_assist: FllAssist::default(),
            pli_threshold: 0.8,
            code_lock_cn0_dbhz: 26.0,
            cn0_windows: 50,
            cn0_window_periods: 20,
            bit_sync: BitSyncConfig::default(),
            boc: None,
            fll_pull_in_max_s: None,
        }
    }
}

/// The three discriminator outputs of one loop update.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct Discriminators {
    /// Carrier-phase error (rad); 0 for an FLL-only loop.
    pub pll_rad: f64,
    /// Carrier-frequency error (Hz); 0 on the first update.
    pub fll_hz: f64,
    /// Code-phase error (chips).
    pub dll_chips: f64,
}

/// The loop filters and discriminators of one channel, apart from its correlators: give it
/// E/P/L and the integration time and it returns the next NCO settings. The channel uses
/// it; it is public so loop designs can be studied on synthetic correlator outputs.
#[derive(Clone, Debug, PartialEq)]
pub struct LoopCore {
    cfg: LoopConfig,
    carrier: LoopFilter,
    code: LoopFilter,
    chip_rate_hz: f64,
    carrier_hz: f64,
    init_doppler_hz: f64,
    doppler_hz: f64,
    code_rate_hz: f64,
    prev_prompt: Option<Cf64>,
    fll_enabled: bool,
}

impl LoopCore {
    /// A loop core for a code of `chip_rate_hz` on a carrier of `carrier_hz`, starting at
    /// Doppler `init_doppler_hz`.
    pub fn new(
        cfg: &LoopConfig,
        chip_rate_hz: f64,
        carrier_hz: f64,
        init_doppler_hz: f64,
    ) -> Result<Self, String> {
        if !(cfg.spacing_chips > 0.0 && cfg.spacing_chips <= 2.0) {
            return Err(format!(
                "early-late spacing must lie in (0, 2] chips (got {})",
                cfg.spacing_chips
            ));
        }
        if cfg.dll_order == 0 || cfg.dll_order > 2 {
            return Err(format!("DLL order must be 1 or 2 (got {})", cfg.dll_order));
        }
        if !(chip_rate_hz > 0.0 && carrier_hz > 0.0) {
            return Err("chip rate and carrier frequency must be positive".into());
        }
        if let Some(b) = &cfg.boc {
            b.validate(cfg.spacing_chips)?;
        }
        let mut s = Self {
            cfg: cfg.clone(),
            carrier: cfg.carrier.filter()?,
            code: LoopFilter::pll(cfg.dll_order, cfg.dll_bn_hz)?,
            chip_rate_hz,
            carrier_hz,
            init_doppler_hz,
            doppler_hz: init_doppler_hz,
            code_rate_hz: 0.0,
            prev_prompt: None,
            fll_enabled: true,
        };
        s.code_rate_hz = s.base_code_rate();
        Ok(s)
    }

    fn base_code_rate(&self) -> f64 {
        let d = if self.cfg.carrier_aiding {
            self.doppler_hz
        } else {
            self.init_doppler_hz
        };
        self.chip_rate_hz * (1.0 + d / self.carrier_hz)
    }

    /// One loop update on early, prompt and late correlations integrated over `t_s`.
    pub fn update(&mut self, e: Cf64, p: Cf64, l: Cf64, t_s: f64) -> Discriminators {
        let pll_rad = if self.cfg.carrier.has_pll() {
            self.cfg.pll_discriminator.discriminate(p)
        } else {
            0.0
        };
        let fll_hz = match self.prev_prompt {
            Some(prev) => self.cfg.fll_discriminator.discriminate(prev, p, t_s),
            None => 0.0,
        };
        let fe = if self.cfg.carrier.has_fll() && self.fll_enabled {
            TAU * fll_hz
        } else {
            0.0
        };
        let w = self.carrier.update(pll_rad, fe, t_s);
        self.doppler_hz = self.init_doppler_hz + w / TAU;
        let mut dll_chips = self.cfg.dll.discriminate(e, p, l, self.cfg.spacing_chips);
        if let Some(b) = &self.cfg.boc {
            match b.kind {
                BocKind::SineBoc11 => {
                    dll_chips *= boc::boc11_dll_scale(self.cfg.dll, self.cfg.spacing_chips)
                }
            }
        }
        let corr = self.code.update(dll_chips, 0.0, t_s);
        self.code_rate_hz = self.base_code_rate() + corr;
        self.prev_prompt = Some(p);
        Discriminators {
            pll_rad,
            fll_hz,
            dll_chips,
        }
    }

    /// Switch the FLL path of an FLL-assisted PLL on or off (the channel does this under
    /// [`FllAssist::PullIn`]). A core that is never told keeps it on. An FLL-only loop
    /// ignores it.
    pub fn set_fll_enabled(&mut self, on: bool) {
        self.fll_enabled = on || !self.cfg.carrier.has_pll();
    }

    /// Whether the FLL path feeds the loop.
    pub fn fll_enabled(&self) -> bool {
        self.cfg.carrier.has_fll() && self.fll_enabled
    }

    /// Carrier Doppler the NCO is set to (Hz, relative to the intermediate frequency).
    pub fn doppler_hz(&self) -> f64 {
        self.doppler_hz
    }

    /// Code rate the NCO is set to (chips/s).
    pub fn code_rate_hz(&self) -> f64 {
        self.code_rate_hz
    }

    /// The configuration.
    pub fn config(&self) -> &LoopConfig {
        &self.cfg
    }
}