Skip to main content

sidereon_core/astro/
coverage.rs

1//! One-epoch satellite/station coverage grid.
2//!
3//! The public helpers here build row-major `[satellite][station]` products by
4//! wrapping the scalar look-angle kernel. Every cell equals the corresponding
5//! per-pair [`crate::astro::passes::look_angle_arc`] result.
6
7use crate::astro::passes::{look_angle_arc, GroundStation, LookAngle, LookAngleError, UtcInstant};
8use crate::astro::sgp4::Satellite;
9
10/// Row-major look-angle grid indexed as `[satellite][station]`.
11pub type LookAngleGrid = Vec<Vec<Result<LookAngle, LookAngleError>>>;
12
13/// Compute topocentric look angles for all satellite/station pairs at one epoch.
14///
15/// Each cell is produced by calling [`look_angle_arc`] for exactly that
16/// satellite/station pair with a one-element epoch slice, so the cell is
17/// element-wise identical to the scalar kernel.
18pub fn look_angles_batch(
19    satellites: &[Satellite],
20    stations: &[GroundStation],
21    datetime: UtcInstant,
22) -> LookAngleGrid {
23    satellites
24        .iter()
25        .map(|satellite| {
26            stations
27                .iter()
28                .map(|&station| {
29                    look_angle_arc(satellite, station, std::slice::from_ref(&datetime))
30                        .map(|arc| arc[0])
31                })
32                .collect()
33        })
34        .collect()
35}
36
37/// Return true for every successful look angle at or above `min_elevation_deg`.
38///
39/// Error cells are treated as not visible.
40pub fn visible_mask(
41    grid: &[Vec<Result<LookAngle, LookAngleError>>],
42    min_elevation_deg: f64,
43) -> Vec<Vec<bool>> {
44    grid.iter()
45        .map(|row| {
46            row.iter()
47                .map(|cell| matches!(cell, Ok(look) if look.elevation_deg >= min_elevation_deg))
48                .collect()
49        })
50        .collect()
51}
52
53/// Count visible satellites per station for a look-angle grid.
54pub fn access_counts(
55    grid: &[Vec<Result<LookAngle, LookAngleError>>],
56    min_elevation_deg: f64,
57) -> Vec<usize> {
58    let Some(first_row) = grid.first() else {
59        return Vec::new();
60    };
61    let mut counts = vec![0; first_row.len()];
62
63    for row in grid {
64        for (count, cell) in counts.iter_mut().zip(row) {
65            if matches!(cell, Ok(look) if look.elevation_deg >= min_elevation_deg) {
66                *count += 1;
67            }
68        }
69    }
70
71    counts
72}
73
74/// Return the maximum successful elevation per station.
75///
76/// Error cells are ignored. A station with no successful cells returns `None`.
77pub fn max_elevation(grid: &[Vec<Result<LookAngle, LookAngleError>>]) -> Vec<Option<f64>> {
78    let Some(first_row) = grid.first() else {
79        return Vec::new();
80    };
81    let mut elevations: Vec<Option<f64>> = vec![None; first_row.len()];
82
83    for row in grid {
84        for (elevation, cell) in elevations.iter_mut().zip(row) {
85            if let Ok(look) = cell {
86                *elevation = Some(match *elevation {
87                    Some(current) => current.max(look.elevation_deg),
88                    None => look.elevation_deg,
89                });
90            }
91        }
92    }
93
94    elevations
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100
101    const ISS_L1: &str = "1 25544U 98067A   24001.50000000  .00016717  00000-0  10270-3 0  9009";
102    const ISS_L2: &str = "2 25544  51.6400 208.8657 0002644 250.3037 109.7782 15.49560812999990";
103
104    #[test]
105    fn look_angles_batch_equals_scalar_per_pair() {
106        let sats = satellites();
107        let stations = stations();
108        let datetime = datetime();
109
110        let grid = look_angles_batch(&sats, &stations, datetime);
111
112        assert_eq!(grid.len(), sats.len());
113        for (sat_idx, row) in grid.iter().enumerate() {
114            assert_eq!(row.len(), stations.len());
115            for (station_idx, cell) in row.iter().enumerate() {
116                let expected = look_angle_arc(&sats[sat_idx], stations[station_idx], &[datetime])
117                    .map(|arc| arc[0]);
118                assert_look_angle_result_bits_eq(cell, &expected);
119            }
120        }
121    }
122
123    #[test]
124    fn visible_mask_matches_threshold() {
125        let mut grid = sample_grid();
126        grid[0][0] = Err(LookAngleError::InvalidInput {
127            field: "test",
128            reason: "forced error",
129        });
130
131        for min_elevation_deg in [0.0, 80.0] {
132            let mask = visible_mask(&grid, min_elevation_deg);
133
134            assert_eq!(mask.len(), grid.len());
135            for (mask_row, grid_row) in mask.iter().zip(&grid) {
136                assert_eq!(mask_row.len(), grid_row.len());
137                for (visible, cell) in mask_row.iter().zip(grid_row) {
138                    let expected =
139                        matches!(cell, Ok(look) if look.elevation_deg >= min_elevation_deg);
140                    assert_eq!(*visible, expected);
141                }
142            }
143        }
144    }
145
146    #[test]
147    fn access_counts_sums_mask() {
148        let mut grid = sample_grid();
149        grid[0][0] = Err(LookAngleError::InvalidInput {
150            field: "test",
151            reason: "forced error",
152        });
153        let min_elevation_deg = 0.0;
154
155        let mask = visible_mask(&grid, min_elevation_deg);
156        let counts = access_counts(&grid, min_elevation_deg);
157
158        assert_eq!(counts.len(), grid[0].len());
159        for station_idx in 0..counts.len() {
160            let expected = mask.iter().filter(|row| row[station_idx]).count();
161            assert_eq!(counts[station_idx], expected);
162        }
163    }
164
165    #[test]
166    fn max_elevation_reduces_columns() {
167        let mut grid = sample_grid();
168        grid[0][0] = Err(LookAngleError::InvalidInput {
169            field: "test",
170            reason: "forced error",
171        });
172
173        let reduced = max_elevation(&grid);
174
175        assert_eq!(reduced.len(), grid[0].len());
176        for station_idx in 0..reduced.len() {
177            let mut expected = None;
178            for row in &grid {
179                if let Ok(look) = &row[station_idx] {
180                    expected = Some(match expected {
181                        Some(current) => f64::max(current, look.elevation_deg),
182                        None => look.elevation_deg,
183                    });
184                }
185            }
186            assert_optional_f64_bits_eq(reduced[station_idx], expected);
187        }
188    }
189
190    fn sample_grid() -> LookAngleGrid {
191        let sats = satellites();
192        let stations = stations();
193        look_angles_batch(&sats, &stations, datetime())
194    }
195
196    fn satellites() -> Vec<Satellite> {
197        vec![
198            Satellite::from_tle(ISS_L1, ISS_L2).expect("ISS TLE parses"),
199            Satellite::from_tle(ISS_L1, ISS_L2).expect("ISS TLE parses"),
200        ]
201    }
202
203    fn stations() -> Vec<GroundStation> {
204        vec![
205            GroundStation {
206                latitude_deg: 51.5,
207                longitude_deg: -0.1,
208                altitude_m: 11.0,
209            },
210            GroundStation {
211                latitude_deg: 40.7,
212                longitude_deg: -74.0,
213                altitude_m: 10.0,
214            },
215        ]
216    }
217
218    fn datetime() -> UtcInstant {
219        UtcInstant::from_utc(2024, 1, 1, 12, 0, 0, 0).unwrap()
220    }
221
222    fn assert_look_angle_result_bits_eq(
223        actual: &Result<LookAngle, LookAngleError>,
224        expected: &Result<LookAngle, LookAngleError>,
225    ) {
226        match (actual, expected) {
227            (Ok(actual), Ok(expected)) => {
228                assert_eq!(actual.azimuth_deg.to_bits(), expected.azimuth_deg.to_bits());
229                assert_eq!(
230                    actual.elevation_deg.to_bits(),
231                    expected.elevation_deg.to_bits()
232                );
233                assert_eq!(actual.range_km.to_bits(), expected.range_km.to_bits());
234            }
235            (Err(actual), Err(expected)) => assert_eq!(actual, expected),
236            _ => panic!("actual {actual:?} did not match expected {expected:?}"),
237        }
238    }
239
240    fn assert_optional_f64_bits_eq(actual: Option<f64>, expected: Option<f64>) {
241        match (actual, expected) {
242            (Some(actual), Some(expected)) => assert_eq!(actual.to_bits(), expected.to_bits()),
243            (None, None) => {}
244            _ => panic!("actual {actual:?} did not match expected {expected:?}"),
245        }
246    }
247}