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}