Skip to main content

nyx_space/od/ground_station/
mod.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 anise::astro::{Aberration, AzElRange, Location};
20use anise::errors::{AlmanacError, AlmanacResult};
21use anise::frames::FrameUid;
22use anise::prelude::{Almanac, Frame, Orbit};
23use indexmap::{IndexMap, IndexSet};
24use snafu::ensure;
25
26use super::msr::MeasurementType;
27use super::noise::{GaussMarkov, StochasticNoise};
28use super::{ODAlmanacSnafu, ODError, ODTrajSnafu, TrackingDevice};
29use crate::od::NoiseNotConfiguredSnafu;
30use crate::time::Epoch;
31use rand_pcg::Pcg64Mcg;
32use serde::{Deserialize, Serialize};
33use std::fmt::{self, Debug};
34
35mod asn1;
36pub mod builtin;
37mod doppler_config;
38pub mod trk_device;
39
40pub use doppler_config::DopplerConfig;
41
42#[cfg(feature = "python")]
43use pyo3::exceptions::PyValueError;
44#[cfg(feature = "python")]
45use pyo3::prelude::*;
46#[cfg(feature = "python")]
47use pyo3::types::{PyBytes, PyType};
48#[cfg(feature = "python")]
49mod python;
50
51#[cfg(feature = "python")]
52use der::{Decode, Encode};
53/// GroundStation defines a one-way or two-way ranging and doppler station. Set the doppler config for two-way.
54///
55/// :type name: str
56/// :type location: Location
57/// :type stochastic_noises: dict[MeasurementType, StochasticNoise]
58/// :type doppler_config: DopplerConfig | None
59/// :type light_time_correction: bool | None
60/// :type timestamp_noise_s: StochasticNoise | None
61/// :type obstruction_body: FrameUid | None
62/// :type relativistic_corrections: bool
63#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
64#[cfg_attr(feature = "python", pyclass(from_py_object))]
65pub struct GroundStation {
66    pub name: String,
67    pub location: Location,
68    pub measurement_types: IndexSet<MeasurementType>,
69    /// Doppler tracking loop settings (required if tracking Doppler)
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub doppler_config: Option<DopplerConfig>,
72    /// If light-time correction is enabled, then Range and Doppler are assumed coherent Two-Way; Az/El is OneWay.
73    pub light_time_correction: bool,
74    /// Noise on the timestamp of the measurement
75    pub timestamp_noise_s: Option<StochasticNoise>,
76    pub stochastic_noises: Option<IndexMap<MeasurementType, StochasticNoise>>,
77    /// Body that obstructs the line of sight (e.g. Moon if tracking a lunar spacecraft from Earth)
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub obstructing_body: Option<FrameUid>,
80    /// Relativistic corrections (e.g. Shapiro delay)
81    #[serde(default)]
82    pub relativistic_corrections: bool,
83}
84
85#[cfg_attr(feature = "python", pymethods)]
86impl GroundStation {
87    /// Computes the azimuth and elevation of the provided object seen from this ground station, both in degrees.
88    /// This is a shortcut to almanac.azimuth_elevation_range_sez.
89    ///
90    /// :type rx: Orbit
91    /// :type obstructing_body: Frame | None
92    /// :type almanac: Almanac
93    /// :rtype: AzElRange
94    pub fn azimuth_elevation_of(
95        &self,
96        rx: Orbit,
97        obstructing_body: Option<Frame>,
98        almanac: &Almanac,
99    ) -> AlmanacResult<AzElRange> {
100        let ab_corr = if self.light_time_correction {
101            Aberration::LT
102        } else {
103            Aberration::NONE
104        };
105        almanac.azimuth_elevation_range_sez(
106            rx,
107            self.to_orbit(rx.epoch, almanac)?,
108            obstructing_body,
109            ab_corr,
110        )
111    }
112
113    /// Return this ground station as an orbit in its current frame
114    ///
115    /// :type epoch: Epoch
116    /// :type almanac: Almanac
117    /// :rtype: Orbit
118    pub fn to_orbit(&self, epoch: Epoch, almanac: &Almanac) -> AlmanacResult<Orbit> {
119        Orbit::try_latlongalt(
120            self.location.latitude_deg,
121            self.location.longitude_deg,
122            self.location.height_km,
123            epoch,
124            almanac.frame_info(self.location.frame).map_err(|source| {
125                AlmanacError::GenericError {
126                    err: source.to_string(),
127                }
128            })?,
129        )
130        .map_err(|source| AlmanacError::AlmanacPhysics {
131            action: "building ground station location",
132            source: Box::new(source),
133        })
134    }
135}
136
137impl GroundStation {
138    /// Initializes a point on the surface of a celestial object.
139    /// This is meant for analysis, not for spacecraft navigation.
140    pub fn from_point(
141        name: String,
142        latitude_deg: f64,
143        longitude_deg: f64,
144        height_km: f64,
145        frame: Frame,
146    ) -> Self {
147        Self {
148            name,
149            location: Location {
150                latitude_deg,
151                longitude_deg,
152                height_km,
153                frame: frame.into(),
154                terrain_mask: vec![],
155                terrain_mask_ignored: true,
156            },
157            measurement_types: IndexSet::new(),
158            doppler_config: None,
159            light_time_correction: false,
160            timestamp_noise_s: None,
161            stochastic_noises: None,
162            obstructing_body: None,
163            relativistic_corrections: false,
164        }
165    }
166
167    /// Returns a copy of this ground station with the new measurement type added (or replaced)
168    pub fn with_msr_type(mut self, msr_type: MeasurementType, noise: StochasticNoise) -> Self {
169        if self.stochastic_noises.is_none() {
170            self.stochastic_noises = Some(IndexMap::new());
171        }
172
173        self.stochastic_noises
174            .as_mut()
175            .unwrap()
176            .insert(msr_type, noise);
177
178        self.measurement_types.insert(msr_type);
179
180        self
181    }
182
183    /// Returns a copy of this ground station without the provided measurement type (if defined, else no error)
184    pub fn without_msr_type(mut self, msr_type: MeasurementType) -> Self {
185        if let Some(noises) = self.stochastic_noises.as_mut() {
186            noises.swap_remove(&msr_type);
187        }
188
189        self.measurement_types.swap_remove(&msr_type);
190
191        self
192    }
193
194    pub fn with_doppler_config(mut self, doppler_config: Option<DopplerConfig>) -> Self {
195        self.doppler_config = doppler_config;
196
197        self
198    }
199
200    pub fn with_obstruction_body(mut self, obstruction_body: Option<FrameUid>) -> Self {
201        self.obstructing_body = obstruction_body;
202
203        self
204    }
205
206    pub fn with_relativistic_corrections(mut self, relativistic_corrections: bool) -> Self {
207        self.relativistic_corrections = relativistic_corrections;
208
209        self
210    }
211
212    /// Returns a copy of this ground station with the measurement type noises' constant bias set to the provided value.
213    pub fn with_msr_bias_constant(
214        mut self,
215        msr_type: MeasurementType,
216        bias_constant: f64,
217    ) -> Result<Self, ODError> {
218        if self.stochastic_noises.is_none() {
219            self.stochastic_noises = Some(IndexMap::new());
220        }
221
222        let stochastics = self.stochastic_noises.as_mut().unwrap();
223
224        let this_noise = stochastics
225            .get_mut(&msr_type)
226            .ok_or(ODError::NoiseNotConfigured {
227                kind: format!("{msr_type:?}"),
228            })
229            .unwrap();
230
231        if this_noise.bias.is_none() {
232            this_noise.bias = Some(GaussMarkov::ZERO);
233        }
234
235        this_noise.bias.unwrap().constant = Some(bias_constant);
236
237        Ok(self)
238    }
239
240    /// Returns the noises for all measurement types configured for this ground station at the provided epoch, timestamp noise is the first entry.
241    fn noises(&mut self, epoch: Epoch, rng: Option<&mut Pcg64Mcg>) -> Result<Vec<f64>, ODError> {
242        let mut noises = vec![0.0; self.measurement_types.len() + 1];
243
244        if let Some(rng) = rng {
245            ensure!(
246                self.stochastic_noises.is_some(),
247                NoiseNotConfiguredSnafu {
248                    kind: "ground station stochastics".to_string(),
249                }
250            );
251            // Add the timestamp noise first
252
253            if let Some(mut timestamp_noise) = self.timestamp_noise_s {
254                noises[0] = timestamp_noise.sample(epoch, rng);
255            }
256
257            let stochastics = self.stochastic_noises.as_mut().unwrap();
258
259            for (ii, msr_type) in self.measurement_types.iter().enumerate() {
260                noises[ii + 1] = stochastics
261                    .get_mut(msr_type)
262                    .ok_or(ODError::NoiseNotConfigured {
263                        kind: format!("{msr_type:?}"),
264                    })?
265                    .sample(epoch, rng);
266            }
267        }
268
269        Ok(noises)
270    }
271
272    pub(crate) fn available_data(&self) -> u8 {
273        let mut bits: u8 = 0;
274
275        if self.doppler_config.is_some() {
276            bits |= 1 << 0;
277        }
278        if self.timestamp_noise_s.is_some() {
279            bits |= 1 << 1;
280        }
281        if self.stochastic_noises.is_some() {
282            bits |= 1 << 2;
283        }
284        if self.obstructing_body.is_some() {
285            bits |= 1 << 3;
286        }
287        bits
288    }
289}
290
291#[cfg(feature = "python")]
292#[cfg_attr(feature = "python", pymethods)]
293impl GroundStation {
294    /// Decodes an ASN.1 DER encoded byte array into a GroundStation object.
295    ///
296    /// :type data: bytes
297    /// :rtype: GroundStation
298    #[classmethod]
299    pub fn from_asn1(_cls: &Bound<'_, PyType>, data: &[u8]) -> PyResult<Self> {
300        match Self::from_der(data) {
301            Ok(obj) => Ok(obj),
302            Err(e) => Err(PyValueError::new_err(format!("ASN.1 decoding error: {e}"))),
303        }
304    }
305
306    /// Encodes this GroundStation object into an ASN.1 DER encoded byte array.
307    ///
308    /// :rtype: bytes
309    pub fn to_asn1<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyBytes>> {
310        let mut buf = Vec::new();
311        match self.encode_to_vec(&mut buf) {
312            Ok(_) => Ok(PyBytes::new(py, &buf)),
313            Err(e) => Err(PyValueError::new_err(format!("ASN.1 encoding error: {e}"))),
314        }
315    }
316}
317
318impl Default for GroundStation {
319    fn default() -> Self {
320        let mut measurement_types = IndexSet::new();
321        measurement_types.insert(MeasurementType::Range);
322        measurement_types.insert(MeasurementType::Doppler);
323        Self {
324            name: "UNDEFINED".to_string(),
325            measurement_types,
326            location: Location::default(),
327            doppler_config: None,
328            light_time_correction: false,
329            timestamp_noise_s: None,
330            stochastic_noises: None,
331            obstructing_body: None,
332            relativistic_corrections: false,
333        }
334    }
335}
336
337impl fmt::Display for GroundStation {
338    // Prints the Keplerian orbital elements with units
339    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
340        write!(f, "{} ({})", self.name, self.location)
341    }
342}
343
344#[cfg(test)]
345mod gs_ut {
346
347    use anise::astro::{Location, TerrainMask};
348    use anise::constants::frames::IAU_EARTH_FRAME;
349    use indexmap::{IndexMap, IndexSet};
350
351    use crate::io::ConfigRepr;
352    use crate::od::prelude::*;
353
354    #[ignore = "github cache is a pain, works locally"]
355    #[test]
356    fn test_load_single() {
357        use std::env;
358        use std::path::PathBuf;
359
360        use hifitime::TimeUnits;
361
362        let test_data: PathBuf = [
363            env!("CARGO_MANIFEST_DIR"),
364            "../data",
365            "03_tests",
366            "config",
367            "one_ground_station.yaml",
368        ]
369        .iter()
370        .collect();
371
372        assert!(test_data.exists(), "Could not find the test data");
373
374        let gs = GroundStation::load(test_data).unwrap();
375
376        dbg!(&gs);
377
378        let mut measurement_types = IndexSet::new();
379        measurement_types.insert(MeasurementType::Range);
380        measurement_types.insert(MeasurementType::Doppler);
381
382        let mut stochastics = IndexMap::new();
383        stochastics.insert(
384            MeasurementType::Range,
385            StochasticNoise {
386                bias: Some(GaussMarkov::new(1.days(), 5e-3).unwrap()),
387                ..Default::default()
388            },
389        );
390        stochastics.insert(
391            MeasurementType::Doppler,
392            StochasticNoise {
393                bias: Some(GaussMarkov::new(1.days(), 5e-5).unwrap()),
394                ..Default::default()
395            },
396        );
397
398        let expected_gs = GroundStation {
399            name: "Demo ground station".to_string(),
400            location: Location {
401                latitude_deg: 2.3522,
402                longitude_deg: 48.8566,
403                height_km: 0.4,
404                frame: IAU_EARTH_FRAME.into(),
405                terrain_mask: TerrainMask::from_flat_terrain(5.0),
406                terrain_mask_ignored: false,
407            },
408            measurement_types,
409            stochastic_noises: Some(stochastics),
410
411            light_time_correction: false,
412            timestamp_noise_s: None,
413            doppler_config: Some(DopplerConfig::default()),
414            obstructing_body: None,
415            relativistic_corrections: false,
416        };
417
418        println!("{}", serde_yml::to_string(&expected_gs).unwrap());
419
420        assert_eq!(expected_gs, gs);
421    }
422
423    #[ignore = "github cache is a pain, works locally"]
424    #[test]
425    fn test_load_many() {
426        use hifitime::TimeUnits;
427        use std::env;
428        use std::path::PathBuf;
429
430        let test_file: PathBuf = [
431            env!("CARGO_MANIFEST_DIR"),
432            "../data",
433            "03_tests",
434            "config",
435            "many_ground_stations.yaml",
436        ]
437        .iter()
438        .collect();
439
440        let stations = GroundStation::load_many(test_file).unwrap();
441
442        dbg!(&stations);
443
444        let mut measurement_types = IndexSet::new();
445        measurement_types.insert(MeasurementType::Range);
446        measurement_types.insert(MeasurementType::Doppler);
447
448        let mut stochastics = IndexMap::new();
449        stochastics.insert(
450            MeasurementType::Range,
451            StochasticNoise {
452                bias: Some(GaussMarkov::new(1.days(), 5e-3).unwrap()),
453                ..Default::default()
454            },
455        );
456        stochastics.insert(
457            MeasurementType::Doppler,
458            StochasticNoise {
459                bias: Some(GaussMarkov::new(1.days(), 5e-5).unwrap()),
460                ..Default::default()
461            },
462        );
463
464        let expected = vec![
465            GroundStation {
466                name: "Demo ground station".to_string(),
467                location: Location {
468                    latitude_deg: 2.3522,
469                    longitude_deg: 48.8566,
470                    height_km: 0.4,
471                    frame: IAU_EARTH_FRAME.into(),
472                    terrain_mask: TerrainMask::from_flat_terrain(5.0),
473                    terrain_mask_ignored: false,
474                },
475                measurement_types: measurement_types.clone(),
476                stochastic_noises: Some(stochastics.clone()),
477                light_time_correction: false,
478                timestamp_noise_s: None,
479                doppler_config: None,
480                obstructing_body: None,
481                relativistic_corrections: false,
482            },
483            GroundStation {
484                name: "Canberra".to_string(),
485                location: Location {
486                    latitude_deg: -35.398333,
487                    longitude_deg: 148.981944,
488                    height_km: 0.691750,
489                    frame: IAU_EARTH_FRAME.into(),
490                    terrain_mask: TerrainMask::from_flat_terrain(5.0),
491                    terrain_mask_ignored: false,
492                },
493                measurement_types,
494                stochastic_noises: Some(stochastics),
495                light_time_correction: false,
496                timestamp_noise_s: None,
497                doppler_config: None,
498                obstructing_body: None,
499                relativistic_corrections: false,
500            },
501        ];
502
503        assert_eq!(expected, stations);
504
505        // Serialize back
506        let reser = serde_yml::to_string(&expected).unwrap();
507        dbg!(reser);
508    }
509}