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