Skip to main content

nyx_space/od/simulator/
arc.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::almanac::Almanac;
20use anise::analysis::AnalysisError;
21use anise::structure::LocationDataSet;
22use hifitime::{Duration, Epoch, TimeSeries, TimeUnits};
23use log::{info, warn};
24use num::integer::gcd;
25use rand::SeedableRng;
26use rand::rngs::SysRng;
27use rand_pcg::Pcg64Mcg;
28
29use crate::Spacecraft;
30use crate::State;
31use crate::io::ConfigError;
32use crate::md::trajectory::Interpolatable;
33use crate::od::GroundStation;
34use crate::od::msr::TrackingDataArc;
35use crate::od::prelude::Strand;
36use crate::od::simulator::Cadence;
37use crate::{linalg::DefaultAllocator, md::prelude::Traj};
38use crate::{linalg::allocator::Allocator, od::TrackingDevice};
39use std::collections::BTreeMap;
40use std::fmt::Display;
41use std::marker::PhantomData;
42
43use super::{Handoff, TrkConfig};
44
45#[derive(Clone)]
46pub struct TrackingArcSim<MsrIn, D>
47where
48    D: TrackingDevice<MsrIn>,
49    MsrIn: State,
50    MsrIn: Interpolatable,
51    DefaultAllocator: Allocator<<MsrIn as State>::Size>
52        + Allocator<<MsrIn as State>::Size, <MsrIn as State>::Size>
53        + Allocator<<MsrIn as State>::VecLength>,
54{
55    /// Map of devices from their names.
56    pub devices: BTreeMap<String, D>,
57    /// Receiver trajectory
58    pub trajectory: Traj<MsrIn>,
59    /// Configuration of each device
60    pub configs: BTreeMap<String, TrkConfig>,
61    /// Random number generator used for this tracking arc, ensures repeatability
62    rng: Pcg64Mcg,
63    /// Greatest common denominator time series that allows this arc to meet all of the conditions.
64    time_series: TimeSeries,
65    _msr_in: PhantomData<MsrIn>,
66}
67
68impl<MsrIn, D> TrackingArcSim<MsrIn, D>
69where
70    D: TrackingDevice<MsrIn>,
71    MsrIn: State,
72    MsrIn: Interpolatable,
73    DefaultAllocator: Allocator<<MsrIn as State>::Size>
74        + Allocator<<MsrIn as State>::Size, <MsrIn as State>::Size>
75        + Allocator<<MsrIn as State>::VecLength>,
76{
77    /// Constructs a deterministic tracking simulator initialized with a specific random number generator.
78    ///
79    /// Evaluates the configuration of all provided devices, filtering out invalid definitions via sanity checks.
80    /// Computes the greatest common denominator (GCD) of all configured sampling rates to construct a unified,
81    /// high-fidelity time series encompassing the entire trajectory span. This ensures that interpolation artifacts
82    /// are minimized when extracting state vectors at non-uniform station sampling intervals.
83    pub fn with_rng(
84        devices: BTreeMap<String, D>,
85        trajectory: Traj<MsrIn>,
86        configs: BTreeMap<String, TrkConfig>,
87        rng: Pcg64Mcg,
88    ) -> Result<Self, ConfigError> {
89        // Check that each device has an associated configurations.
90        // We don't care if there are more configurations than chosen devices.
91        let mut sampling_rates_ns = Vec::with_capacity(devices.len());
92        for name in devices.keys() {
93            if let Some(cfg) = configs.get(name) {
94                if let Err(e) = cfg.sanity_check() {
95                    warn!("Ignoring device {name}: {e}");
96                    continue;
97                }
98                sampling_rates_ns.push(cfg.sampling.truncated_nanoseconds());
99            } else {
100                warn!("Ignoring device {name}: no associated tracking configuration",);
101                continue;
102            }
103        }
104
105        if sampling_rates_ns.is_empty() {
106            return Err(ConfigError::InvalidConfig {
107                msg: "None of the devices are properly configured".to_string(),
108            });
109        }
110
111        let common_sampling_rate_ns = sampling_rates_ns
112            .iter()
113            .fold(sampling_rates_ns[0], |a, &b| gcd(a, b));
114
115        // The overall time series is the one going from the start to the end of the trajectory with the smallest time step
116        // of all the tracking configurations.
117        let time_series = TimeSeries::inclusive(
118            trajectory.first().epoch(),
119            trajectory.last().epoch(),
120            Duration::from_truncated_nanoseconds(common_sampling_rate_ns),
121        );
122
123        let me = Self {
124            devices,
125            trajectory,
126            configs,
127            rng,
128            time_series,
129            _msr_in: PhantomData,
130        };
131
132        info!("{me}");
133
134        Ok(me)
135    }
136
137    /// Build a new tracking arc simulator using the provided seed to initialize the random number generator.
138    pub fn with_seed(
139        devices: BTreeMap<String, D>,
140        trajectory: Traj<MsrIn>,
141        configs: BTreeMap<String, TrkConfig>,
142        seed: u64,
143    ) -> Result<Self, ConfigError> {
144        let rng = Pcg64Mcg::new(seed as u128);
145
146        Self::with_rng(devices, trajectory, configs, rng)
147    }
148
149    /// Build a new tracking arc simulator using the system entropy to seed the random number generator.
150    pub fn new(
151        devices: BTreeMap<String, D>,
152        trajectory: Traj<MsrIn>,
153        configs: BTreeMap<String, TrkConfig>,
154    ) -> Result<Self, ConfigError> {
155        let rng = Pcg64Mcg::try_from_rng(&mut SysRng).unwrap();
156
157        Self::with_rng(devices, trajectory, configs, rng)
158    }
159
160    /// Generates measurements for the tracking arc using the defined strands
161    ///
162    /// # Warning
163    /// This function will return an error if any of the devices defines as a scheduler.
164    /// You must create the schedule first using `build_schedule` first.
165    ///
166    /// # Notes
167    /// Although mutable, this function may be called several times to generate different measurements.
168    ///
169    /// # Algorithm
170    /// For each tracking device, and for each strand within that device, sample the trajectory at the sample
171    /// rate of the tracking device, adding a measurement whenever the spacecraft is visible.
172    /// Build the measurements as a vector, ordered chronologically.
173    ///
174    pub fn generate_measurements(
175        &mut self,
176        almanac: &Almanac,
177    ) -> Result<TrackingDataArc, ConfigError> {
178        let mut measurements = Vec::new();
179
180        for (name, device) in self.devices.iter_mut() {
181            if let Some(cfg) = self.configs.get(name) {
182                if cfg.scheduler.is_some() {
183                    if cfg.strands.is_none() {
184                        return Err(ConfigError::InvalidConfig {
185                            msg: format!(
186                                "schedule for {name} must be built before generating measurements"
187                            ),
188                        });
189                    } else {
190                        warn!(
191                            "scheduler for {name} is ignored, using the defined tracking strands instead"
192                        )
193                    }
194                }
195
196                let init_msr_count = measurements.len();
197                let tick = Epoch::now().unwrap();
198
199                match cfg.strands.as_ref() {
200                    Some(strands) => {
201                        // Strands are defined at this point
202                        'strands: for (ii, strand) in strands.iter().enumerate() {
203                            // Build the time series for this strand, sampling at the correct rate
204                            for epoch in
205                                TimeSeries::inclusive(strand.start, strand.end, cfg.sampling)
206                            {
207                                match device.measure(
208                                    epoch,
209                                    &self.trajectory,
210                                    Some(&mut self.rng),
211                                    almanac,
212                                ) {
213                                    Ok(msr_opt) => {
214                                        if let Some(msr) = msr_opt {
215                                            measurements.push(msr);
216                                        }
217                                    }
218                                    Err(e) => {
219                                        if epoch != strand.end {
220                                            warn!(
221                                                "Skipping the remaining strand #{ii} ending on {}: {e}",
222                                                strand.end
223                                            );
224                                        }
225                                        continue 'strands;
226                                    }
227                                }
228                            }
229                        }
230
231                        info!(
232                            "Simulated {} measurements for {name} for {} tracking strands in {}",
233                            measurements.len() - init_msr_count,
234                            strands.len(),
235                            (Epoch::now().unwrap() - tick).round(1.0_f64.milliseconds())
236                        );
237                    }
238                    None => {
239                        warn!("No tracking strands defined for {name}, skipping");
240                    }
241                }
242            }
243        }
244
245        // Build the tracking arc.
246        let mut trk_data = TrackingDataArc {
247            measurements,
248            source: None,
249            moduli: None,
250            force_reject: false,
251        };
252
253        trk_data.sort();
254
255        Ok(trk_data)
256    }
257}
258
259impl<MsrIn, D> Display for TrackingArcSim<MsrIn, D>
260where
261    D: TrackingDevice<MsrIn>,
262    MsrIn: Interpolatable,
263    DefaultAllocator: Allocator<<MsrIn as State>::Size>
264        + Allocator<<MsrIn as State>::Size, <MsrIn as State>::Size>
265        + Allocator<<MsrIn as State>::VecLength>,
266{
267    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
268        write!(
269            f,
270            "Tracking Arc Simulator on {} with devices {:?} over {}",
271            self.trajectory,
272            self.devices.keys(),
273            self.time_series
274        )
275    }
276}
277
278// Literally the same as above, but can't make it generic =(
279impl TrackingArcSim<Spacecraft, GroundStation> {
280    /// Builds the schedule provided the config. Requires the tracker to be a ground station.
281    ///
282    /// # Algorithm
283    ///
284    /// 1. For each tracking device:
285    /// 2. Find when the vehicle trajectory has an elevation greater or equal to zero, and use that as the first start of the first tracking arc for this station
286    /// 3. Find when the vehicle trajectory has an elevation less than zero (i.e. disappears below the horizon), after that initial epoch
287    /// 4. Repeat 2, 3 until the end of the trajectory
288    /// 5. Build each of these as "tracking strands" for this tracking device.
289    /// 6. Organize all of the built tracking strands chronologically.
290    /// 7. Iterate through all of the strands:
291    ///    7.a. if that tracker is marked as `Greedy` and it ends after the start of the next strand, change the start date of the next strand.
292    ///    7.b. if that tracker is marked as `Eager` and it ends after the start of the next strand, change the end date of the current strand.
293    pub fn generate_schedule(
294        &self,
295        almanac: &Almanac,
296    ) -> Result<BTreeMap<String, TrkConfig>, AnalysisError> {
297        // Build a Location Dataset so we can use ANISE's visibility finder.
298        let mut loc_dataset = LocationDataSet::default();
299        let mut loc_ids = Vec::with_capacity(self.devices.len());
300        for (dno, (name, device)) in self.devices.iter().enumerate() {
301            let loc_id = dno as i32 + 1_000;
302            loc_dataset
303                .push(device.location.clone(), Some(loc_id), Some(name))
304                .map_err(|e| AnalysisError::GenericAnalysisError {
305                    err: format!("{e}"),
306                })?;
307            loc_ids.push((name.clone(), loc_id));
308        }
309
310        // Deep clone of the Almanac so we can insert the locations of these ground stations.
311        let almanac = (*almanac).clone();
312        let almanac = almanac.with_location_data(loc_dataset);
313
314        let mut built_cfg = self.configs.clone();
315
316        let traj = &self.trajectory;
317
318        for (name, loc_id) in &loc_ids {
319            if let Some(cfg) = self.configs.get(name)
320                && let Some(scheduler) = cfg.scheduler
321            {
322                info!("Building schedule for {name}");
323                if scheduler.handoff == Handoff::Overlap {
324                    warn!("Overlap unsupported for {name} on identical epochs.");
325                }
326                built_cfg.get_mut(name).unwrap().scheduler = None;
327                built_cfg.get_mut(name).unwrap().strands = Some(Vec::new());
328
329                let visibilty_arcs = almanac.report_visibility_arcs(
330                    traj,
331                    *loc_id,
332                    traj.first().epoch(),
333                    traj.last().epoch(),
334                    cfg.sampling,
335                    None,
336                )?;
337
338                for arc in visibilty_arcs {
339                    let strand_start = arc.rise.orbit.epoch;
340                    let strand_end = arc.fall.orbit.epoch;
341
342                    if strand_end - strand_start < cfg.sampling * i64::from(scheduler.min_samples) {
343                        warn!(
344                            "Dropped [{strand_start}, {strand_end}]: < {} samples @ {}",
345                            scheduler.min_samples, cfg.sampling
346                        );
347                        continue;
348                    }
349
350                    let mut strand_range = Strand {
351                        start: strand_start,
352                        end: strand_end,
353                    };
354
355                    // If there is an alignment, apply it
356                    if let Some(alignment) = scheduler.sample_alignment {
357                        strand_range.start = strand_range.start.round(alignment);
358                        strand_range.end = strand_range.end.round(alignment);
359                    }
360
361                    if let Cadence::Intermittent { on, off } = scheduler.cadence {
362                        // Check that the next start time is within the allocated time
363                        if let Some(prev_strand) = built_cfg[name].strands.as_ref().unwrap().last()
364                            && prev_strand.end + off > strand_range.start
365                        {
366                            // We're turning on the tracking sooner than the schedule allows, so let's fix that.
367                            strand_range.start = prev_strand.end + off;
368                            // Check that we didn't eat into the whole tracking opportunity
369                            if strand_range.start > strand_end {
370                                // Lost this whole opportunity.
371                                warn!(
372                                    "Dropped {name} [{strand_start}, {strand_end}]: cadence {:?}",
373                                    scheduler.cadence
374                                );
375                                continue;
376                            }
377                        }
378                        // Check that we aren't tracking for longer than configured
379                        if strand_range.end - strand_range.start > on {
380                            strand_range.end = strand_range.start + on;
381                        }
382                    }
383
384                    // We've found when the spacecraft is below the horizon, so this is a new strand.
385                    built_cfg
386                        .get_mut(name)
387                        .unwrap()
388                        .strands
389                        .as_mut()
390                        .unwrap()
391                        .push(strand_range);
392                }
393
394                info!(
395                    "Built {} tracking strands for {name}",
396                    built_cfg[name].strands.as_ref().unwrap().len()
397                );
398            }
399        }
400        // Build all of the strands, remembering which tracker they come from.
401        let mut cfg_as_vec = Vec::new();
402        for (name, cfg) in &built_cfg {
403            if let Some(strands) = &cfg.strands {
404                for (ii, strand) in strands.iter().enumerate() {
405                    cfg_as_vec.push((name.clone(), ii, *strand));
406                }
407            }
408        }
409        // Iterate through the strands by chronological order. Cannot use maps because we change types.
410        cfg_as_vec.sort_by_key(|(_, _, strand)| strand.start);
411        for (ii, (this_name, this_pos, this_strand)) in
412            cfg_as_vec.iter().take(cfg_as_vec.len() - 1).enumerate()
413        {
414            // Grab the config
415            if let Some(config) = self.configs[this_name].scheduler.as_ref() {
416                // Grab the next strand, chronologically
417                if let Some((next_name, next_pos, next_strand)) = cfg_as_vec.get(ii + 1) {
418                    if config.handoff == Handoff::Greedy && this_strand.end >= next_strand.start {
419                        // Modify the built configurations to change the start time of the next strand because the current one is greedy.
420                        let next_config = built_cfg.get_mut(next_name).unwrap();
421                        let new_start = this_strand.end + next_config.sampling;
422                        next_config.strands.as_mut().unwrap()[*next_pos].start = new_start;
423                        info!("Greedy handoff for {this_name}: {next_name} delayed to {new_start}");
424                    } else if config.handoff == Handoff::Eager
425                        && this_strand.end >= next_strand.start
426                    {
427                        let this_config = built_cfg.get_mut(this_name).unwrap();
428                        let new_end = next_strand.start - this_config.sampling;
429                        this_config.strands.as_mut().unwrap()[*this_pos].end = new_end;
430                        info!("Eager handoff for {this_name}: {this_name} terminated at {new_end}");
431                    }
432                } else {
433                    // Reached the end
434                    break;
435                }
436            }
437        }
438
439        Ok(built_cfg)
440    }
441
442    /// Sets the schedule to that built in `build_schedule`
443    pub fn build_schedule(&mut self, almanac: &Almanac) -> Result<(), AnalysisError> {
444        self.configs = self.generate_schedule(almanac)?;
445
446        Ok(())
447    }
448}