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}