Skip to main content

nyx_space/od/noise/
link_specific.rs

1/*
2    Nyx, blazing fast astrodynamics
3    Copyright (C) 2018-onwards Christopher Rabotin <christopher.rabotin@gmail.com>
4
5    This program is free software: you can redistribute it and/or modify
6    it under the terms of the GNU Affero General Public License as published
7    by the Free Software Foundation, either version 3 of the License, or
8    (at your option) any later version.
9
10    This program is distributed in the hope that it will be useful,
11    but WITHOUT ANY WARRANTY; without even the implied warranty of
12    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13    GNU Affero General Public License for more details.
14
15    You should have received a copy of the GNU Affero General Public License
16    along with this program.  If not, see <https://www.gnu.org/licenses/>.
17*/
18
19use super::{StochasticNoise, WhiteNoise};
20use anise::constants::SPEED_OF_LIGHT_KM_S;
21use hifitime::Duration;
22use serde::{Deserialize, Serialize};
23use std::f64::consts::TAU;
24
25#[cfg(feature = "python")]
26use pyo3::prelude::*;
27
28/// Signal power to noise density (S/N0) for stochastic modeling of ranging observables.
29///
30/// IMPORTANT: S/N0 governs the thermal noise of delay-locked loops (DLL) tracking
31/// the modulated ranging code or tone. Deep space architectures rely on phase modulation
32/// with a residual carrier. The total transmitted power is allocated fractionally among the
33/// main carrier wave, the telemetry subcarrier, and the ranging code, dictated by the modulation index.
34///
35/// Because the power available for ranging is strictly a subset of the total carrier power,
36/// S/N0 <= C/N0. Applying C/N0 to ranging observables artificially suppresses the modeled thermal
37/// noise, yielding an overly optimistic covariance bound that ignores spacecraft power division.
38#[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)]
39#[cfg_attr(feature = "python", pyclass(from_py_object))]
40pub enum SN0 {
41    /// 65 dB-Hz
42    Strong(),
43    /// 50 dB-Hz
44    Average(),
45    /// 40 dB-Hz
46    Poor(),
47    /// Manual value provided in dB-Hz, converted to Hertz automatically
48    ManualDbHz(f64),
49}
50
51impl SN0 {
52    /// Note that this returns the data in Hertz not dB-Hz
53    pub(crate) fn value_hz(self) -> f64 {
54        match self {
55            Self::Strong() => 10.0_f64.powf(6.5),
56            Self::Average() => 10.0_f64.powi(5),
57            Self::Poor() => 10.0_f64.powi(4),
58            Self::ManualDbHz(value) => 10.0_f64.powf(value / 10.0),
59        }
60    }
61}
62
63impl Default for SN0 {
64    fn default() -> Self {
65        Self::Average()
66    }
67}
68
69/// Carrier power to noise density (C/N0) for stochastic modeling of Doppler observables.
70///
71/// IMPORTANT: C/N0 governs the thermal noise of phase-locked loops (PLL) tracking
72/// the primary unmodulated carrier wave to measure frequency shift (velocity). It represents
73/// the total power of the carrier signal over the noise spectral density.
74///
75/// Applying S/N0 to Doppler observables artificially inflates modeled velocity noise,
76/// as it fails to account for the unmodulated carrier power explicitly reserved for
77/// phase tracking.
78#[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)]
79#[cfg_attr(feature = "python", pyclass(from_py_object))]
80pub enum CN0 {
81    /// 70 dB-Hz
82    Strong(),
83    /// 55 dB-Hz
84    Average(),
85    /// 45 dB-Hz
86    Poor(),
87    /// Manual value provided in dB-Hz, converted to Hertz automatically
88    ManualDbHz(f64),
89}
90
91impl Default for CN0 {
92    fn default() -> Self {
93        CN0::Average()
94    }
95}
96
97impl CN0 {
98    /// Note that this returns the data in Hertz not dB-Hz
99    pub(crate) fn value_hz(self) -> f64 {
100        match self {
101            Self::Strong() => 10.0_f64.powi(7),
102            Self::Average() => 10.0_f64.powf(5.5),
103            Self::Poor() => 10.0_f64.powf(4.5),
104            Self::ManualDbHz(value) => 10.0_f64.powf(value / 10.0),
105        }
106    }
107}
108
109/// Carrier frequency helper enum, typical values.
110#[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)]
111#[cfg_attr(feature = "python", pyclass(from_py_object))]
112pub enum CarrierFreq {
113    /// 2.2 GHz
114    SBand(),
115    /// 8.4 GHz
116    XBand(),
117    /// 32 Ghz
118    KaBand(),
119    ManualHz(f64),
120}
121
122impl CarrierFreq {
123    pub(crate) fn value_hz(self) -> f64 {
124        match self {
125            Self::SBand() => 2.2e9,
126            Self::XBand() => 8.4e9,
127            Self::KaBand() => 32e9,
128            Self::ManualHz(value) => value,
129        }
130    }
131}
132
133/// An enum helper with typical chip rates.
134#[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)]
135#[cfg_attr(feature = "python", pyclass(from_py_object))]
136pub enum ChipRate {
137    /// 1 kchip/s -- basically emergency ranging
138    Lowest(),
139    /// 100 kchip/s -- could be used for weaker links
140    Low(),
141    /// 1 Mchip/s -- typical of xGEO/cislunar missions
142    StandardT4B(),
143    /// 10 Mchip/s -- high-precision scientific missions (e.g. gravity modeling)
144    High(),
145    /// 25 Mchip/s -- highly specialized missions
146    VeryHigh(),
147    /// Provide your own chip rate depending on the ground station configuration
148    ManualHz(f64),
149}
150
151impl Default for ChipRate {
152    fn default() -> Self {
153        Self::StandardT4B()
154    }
155}
156
157impl ChipRate {
158    pub(crate) fn value_chip_s(self) -> f64 {
159        match self {
160            Self::Lowest() => 1e3,
161            Self::Low() => 1e5,
162            Self::StandardT4B() => 1e6,
163            Self::High() => 1e7,
164            Self::VeryHigh() => 2.5e7,
165            Self::ManualHz(value) => value,
166        }
167    }
168}
169
170impl StochasticNoise {
171    /// Constructs a high precision zero-mean range noise model (accounting for clock error and thermal error) from
172    /// the Allan deviation of the clock, integration time, chip rate (depends on the ranging code), and
173    /// signal-power-to-noise-density ratio (S/N₀).
174    ///
175    /// NOTE: The Allan Deviation should be provided given the integration time. For example, if the integration time
176    /// is one second, the Allan Deviation should be the deviation over one second.
177    ///
178    /// IMPORTANT: These do NOT include atmospheric noises, which add up to ~10 cm one-sigma.
179    pub fn from_hardware_range_km(
180        allan_deviation: f64,
181        integration_time: Duration,
182        chip_rate: ChipRate,
183        s_n0: SN0,
184    ) -> Self {
185        // Compute the thermal noise.
186        let sigma_thermal_km =
187            SPEED_OF_LIGHT_KM_S / (TAU * chip_rate.value_chip_s() * (2.0 * s_n0.value_hz()).sqrt());
188        // Compute the clock noise.
189        let sigma_clock_km =
190            (SPEED_OF_LIGHT_KM_S * allan_deviation * integration_time.to_seconds())
191                / (3.0_f64.sqrt());
192
193        Self {
194            white_noise: Some(WhiteNoise::constant_white_noise(
195                (sigma_clock_km.powi(2) + sigma_thermal_km.powi(2)).sqrt(),
196            )),
197            bias: None,
198        }
199    }
200
201    pub fn from_hardware_doppler_km_s(
202        allan_deviation: f64,
203        integration_time: Duration,
204        carrier: CarrierFreq,
205        c_n0: CN0,
206    ) -> Self {
207        // Compute the thermal noise
208        let sigma_thermal_km_s = SPEED_OF_LIGHT_KM_S
209            / (TAU
210                * carrier.value_hz()
211                * (2.0 * c_n0.value_hz() * integration_time.to_seconds()).sqrt());
212
213        // Compute the clock noise.
214        let sigma_clock_km_s = SPEED_OF_LIGHT_KM_S * allan_deviation;
215
216        Self {
217            white_noise: Some(WhiteNoise::constant_white_noise(
218                (sigma_clock_km_s.powi(2) + sigma_thermal_km_s.powi(2)).sqrt(),
219            )),
220            bias: None,
221        }
222    }
223}
224
225#[cfg(test)]
226mod link_noise {
227    use super::{CN0, CarrierFreq, ChipRate, SN0, StochasticNoise};
228    use hifitime::Unit;
229    #[test]
230    fn nasa_dsac() {
231        // The DSAC has an Allan Dev of 1e-14 over one day.
232        // Gemini claims that such a good clock likely has the same deviation over 60 seconds (hitting the flicker floor).
233        // But worst case scenario, its AD is the square root of the ratio of one day over 1 minute, or 38 times worse.
234
235        for (case_num, allan_dev) in [1e-14, 3.8e-13].iter().copied().enumerate() {
236            println!("AD = {allan_dev:e}");
237
238            let range_dsac_no_flicker = StochasticNoise::from_hardware_range_km(
239                allan_dev,
240                Unit::Minute * 1,
241                ChipRate::StandardT4B(),
242                SN0::Average(),
243            );
244
245            let range_sigma_m = range_dsac_no_flicker.white_noise.unwrap().sigma * 1e3;
246
247            println!("range sigma = {range_sigma_m:.3e} m",);
248
249            assert!(range_sigma_m.abs() < 1.1e-1);
250
251            let doppler_dsac_no_flicker = StochasticNoise::from_hardware_doppler_km_s(
252                allan_dev,
253                Unit::Minute * 1,
254                CarrierFreq::XBand(),
255                CN0::Average(),
256            );
257
258            let doppler_sigma_m_s = doppler_dsac_no_flicker.white_noise.unwrap().sigma * 1e3;
259
260            println!("doppler sigma = {doppler_sigma_m_s:.3e} m/s");
261
262            match case_num {
263                0 => assert!(doppler_sigma_m_s < 3.2e-6),
264                1 => assert!(doppler_sigma_m_s < 1.2e-4),
265                _ => unreachable!(),
266            };
267        }
268    }
269}