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