1use crate::astro::passes::{look_angle_arc, GroundStation, LookAngle, LookAngleError, UtcInstant};
8use crate::astro::sgp4::Satellite;
9
10pub type LookAngleGrid = Vec<Vec<Result<LookAngle, LookAngleError>>>;
12
13pub 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
37pub 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
53pub 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
74pub 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}