Skip to main content

leodos_protocols/network/isl/
projection.rs

1//! Projects satellite grid positions to geographic coordinates and vice versa.
2//!
3//! Uses Walker Delta geometry to compute each satellite's nadir (ground-track)
4//! position, enabling conversion between geographic AOIs and grid AOIs.
5
6use core::f32::consts::PI;
7
8use heapless::Vec;
9
10use super::geo::{Ecef, GeoAoi, LatLon};
11use super::shell::Shell;
12use super::torus::Point;
13use super::aoi::Aoi;
14
15/// Sidereal day in seconds (Earth's rotation period relative
16/// to the stars).
17const SIDEREAL_DAY_S: f32 = 86_164.1;
18
19/// Projects satellite grid positions to geographic coordinates and vice versa.
20pub struct Projection {
21    shell: Shell,
22}
23
24impl Projection {
25    /// Creates a new projection from a constellation shell.
26    pub fn new(shell: Shell) -> Self {
27        Self { shell }
28    }
29
30    /// Computes the nadir (ground-track) position of a satellite.
31    ///
32    /// For a Walker Delta constellation:
33    /// - RAAN of plane x: `x * 360/N` degrees
34    /// - True anomaly of satellite y: `y * 360/M` degrees
35    /// - Latitude: `arcsin(sin(i) * sin(ν))`
36    /// - Longitude: `Ω + atan2(cos(i) * sin(ν), cos(ν))`
37    pub fn nadir(&self, point: Point) -> LatLon {
38        let num_planes = self.shell.torus.num_sats as f32;
39        let sats_per_plane = self.shell.torus.num_orbs as f32;
40
41        let raan = 2.0 * PI * (point.sat as f32) / num_planes;
42        let true_anomaly = 2.0 * PI * (point.orb as f32) / sats_per_plane;
43
44        let sin_i = libm::sinf(self.shell.inclination_rad);
45        let cos_i = libm::cosf(self.shell.inclination_rad);
46        let sin_v = libm::sinf(true_anomaly);
47        let cos_v = libm::cosf(true_anomaly);
48
49        let lat_rad = libm::asinf(sin_i * sin_v);
50        let lon_rad = raan + libm::atan2f(cos_i * sin_v, cos_v);
51
52        let mut lon_deg = lon_rad * 180.0 / PI;
53        if lon_deg > 180.0 {
54            lon_deg -= 360.0;
55        } else if lon_deg < -180.0 {
56            lon_deg += 360.0;
57        }
58
59        LatLon::new(lat_rad * 180.0 / PI, lon_deg)
60    }
61
62    /// Returns all satellites whose nadir falls within the geographic AOI.
63    pub fn satellites_in_geo_aoi<const N: usize>(&self, geo_aoi: &GeoAoi) -> Vec<Point, N> {
64        let mut result = Vec::new();
65
66        for x in 0..self.shell.torus.num_sats {
67            for y in 0..self.shell.torus.num_orbs {
68                let point = Point::new(y, x);
69                let pos = self.nadir(point);
70                if geo_aoi.contains(pos) && result.push(point).is_err() {
71                    return result;
72                }
73            }
74        }
75
76        result
77    }
78
79    /// Computes the ECEF position of a satellite at a given time.
80    ///
81    /// `time_s` is seconds since epoch (t=0 corresponds to the
82    /// constellation snapshot from `nadir()`). Accounts for
83    /// both orbital motion and Earth rotation.
84    pub fn satellite_ecef(&self, point: Point, time_s: f32) -> Ecef {
85        let num_planes = self.shell.torus.num_sats as f32;
86        let sats_per_plane = self.shell.torus.num_orbs as f32;
87
88        let raan = 2.0 * PI * (point.sat as f32) / num_planes;
89        let base_anomaly = 2.0 * PI * (point.orb as f32) / sats_per_plane;
90        let mean_motion = 2.0 * PI / self.shell.orbital_period_s();
91        let true_anomaly = base_anomaly + mean_motion * time_s;
92
93        let r = self.shell.orbital_radius();
94        let cos_v = libm::cosf(true_anomaly);
95        let sin_v = libm::sinf(true_anomaly);
96        let cos_i = libm::cosf(self.shell.inclination_rad);
97        let sin_i = libm::sinf(self.shell.inclination_rad);
98        let cos_raan = libm::cosf(raan);
99        let sin_raan = libm::sinf(raan);
100
101        // ECI coordinates (perifocal → inertial)
102        let x_eci = r * (cos_raan * cos_v - sin_raan * cos_i * sin_v);
103        let y_eci = r * (sin_raan * cos_v + cos_raan * cos_i * sin_v);
104        let z_eci = r * sin_i * sin_v;
105
106        // ECI → ECEF (rotate by Earth's sidereal angle)
107        let earth_angle = 2.0 * PI * time_s / SIDEREAL_DAY_S;
108        let cos_e = libm::cosf(earth_angle);
109        let sin_e = libm::sinf(earth_angle);
110
111        Ecef {
112            x: cos_e * x_eci + sin_e * y_eci,
113            y: -sin_e * x_eci + cos_e * y_eci,
114            z: z_eci,
115        }
116    }
117
118    /// Finds the satellite with the highest elevation angle
119    /// above `min_elevation_deg` from a ground station.
120    ///
121    /// Returns `None` if no satellite has line-of-sight.
122    pub fn find_gateway(
123        &self,
124        station: LatLon,
125        time_s: f32,
126        min_elevation_deg: f32,
127    ) -> Option<Point> {
128        let mut best: Option<(Point, f32)> = None;
129
130        for s in 0..self.shell.torus.num_sats {
131            for o in 0..self.shell.torus.num_orbs {
132                let p = Point::new(o, s);
133                let sat_pos = self.satellite_ecef(p, time_s);
134                let elev = sat_pos.elevation_from(station);
135                if elev > min_elevation_deg {
136                    if best.map_or(true, |(_, best_el)| elev > best_el) {
137                        best = Some((p, elev));
138                    }
139                }
140            }
141        }
142
143        best.map(|(p, _)| p)
144    }
145
146    /// Converts a geographic AOI to a grid AOI by finding the
147    /// bounding box of all satellites whose nadir falls within
148    /// the geographic region.
149    pub fn geo_to_grid_aoi(&self, geo_aoi: &GeoAoi) -> Option<Aoi> {
150        let mut min_y = u8::MAX;
151        let mut max_y = u8::MIN;
152        let mut min_x = u8::MAX;
153        let mut max_x = u8::MIN;
154        let mut found = false;
155
156        for x in 0..self.shell.torus.num_sats {
157            for y in 0..self.shell.torus.num_orbs {
158                let point = Point::new(y, x);
159                if geo_aoi.contains(self.nadir(point)) {
160                    min_y = min_y.min(y);
161                    max_y = max_y.max(y);
162                    min_x = min_x.min(x);
163                    max_x = max_x.max(x);
164                    found = true;
165                }
166            }
167        }
168
169        if !found {
170            return None;
171        }
172        Some(Aoi::new(Point::new(min_y, min_x), Point::new(max_y, max_x)))
173    }
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179    use crate::network::isl::torus::Torus;
180
181    #[test]
182    fn test_nadir_equator_first_plane() {
183        let torus = Torus::new(20, 72);
184        let shell = Shell::new(torus, 550_000.0, 87.0);
185        let proj = Projection::new(shell);
186
187        let pos = proj.nadir(Point::new(0, 0));
188        assert!((pos.lat_deg).abs() < 0.1, "lat={}", pos.lat_deg);
189        assert!((pos.lon_deg).abs() < 0.1, "lon={}", pos.lon_deg);
190    }
191
192    #[test]
193    fn test_nadir_near_pole() {
194        let torus = Torus::new(20, 72);
195        let shell = Shell::new(torus, 550_000.0, 87.0);
196        let proj = Projection::new(shell);
197
198        // y=5 out of 20 → true anomaly = 90° → near max latitude
199        let pos = proj.nadir(Point::new(5, 0));
200        assert!(pos.lat_deg > 80.0, "lat={}", pos.lat_deg);
201    }
202
203    #[test]
204    fn test_satellites_in_geo_aoi() {
205        let torus = Torus::new(20, 72);
206        let shell = Shell::new(torus, 550_000.0, 87.0);
207        let proj = Projection::new(shell);
208
209        let geo_aoi = GeoAoi::new(LatLon::new(10.0, -5.0), LatLon::new(-10.0, 5.0));
210
211        let covering: Vec<Point, 64> = proj.satellites_in_geo_aoi(&geo_aoi);
212        assert!(!covering.is_empty(), "should find satellites near equator");
213    }
214
215    #[test]
216    fn test_geo_to_grid_aoi() {
217        let torus = Torus::new(20, 72);
218        let shell = Shell::new(torus, 550_000.0, 87.0);
219        let proj = Projection::new(shell);
220
221        let geo_aoi = GeoAoi::new(LatLon::new(10.0, -5.0), LatLon::new(-10.0, 5.0));
222
223        let grid_aoi = proj.geo_to_grid_aoi(&geo_aoi);
224        assert!(grid_aoi.is_some());
225    }
226
227    #[test]
228    fn test_satellite_ecef_at_t0_matches_nadir() {
229        let torus = Torus::new(20, 72);
230        let shell = Shell::new(torus, 550_000.0, 87.0);
231        let proj = Projection::new(shell);
232
233        let p = Point::new(0, 0);
234        let ecef = proj.satellite_ecef(p, 0.0);
235        let nadir = proj.nadir(p);
236        let nadir_ecef = Ecef::from_latlon(nadir);
237
238        // Satellite should be directly above its nadir
239        // (same direction, larger magnitude)
240        let sat_r = libm::sqrtf(ecef.x * ecef.x + ecef.y * ecef.y + ecef.z * ecef.z);
241        let nadir_r = libm::sqrtf(
242            nadir_ecef.x * nadir_ecef.x + nadir_ecef.y * nadir_ecef.y + nadir_ecef.z * nadir_ecef.z,
243        );
244        assert!(
245            sat_r > nadir_r,
246            "sat_r={} should exceed nadir_r={}",
247            sat_r,
248            nadir_r
249        );
250
251        // Direction vectors should be similar (dot product
252        // close to 1)
253        let dot = (ecef.x * nadir_ecef.x + ecef.y * nadir_ecef.y + ecef.z * nadir_ecef.z)
254            / (sat_r * nadir_r);
255        assert!(dot > 0.99, "dot={} — satellite not above nadir", dot);
256    }
257
258    #[test]
259    fn test_satellite_moves_with_time() {
260        let torus = Torus::new(20, 72);
261        let shell = Shell::new(torus, 550_000.0, 87.0);
262        let proj = Projection::new(shell);
263
264        let p = Point::new(0, 0);
265        let pos0 = proj.satellite_ecef(p, 0.0);
266        let pos1 = proj.satellite_ecef(p, 600.0); // 10 min
267
268        let dist = pos0.distance(&pos1);
269        assert!(
270            dist > 100_000.0,
271            "satellite should move significantly in 10 min, dist={}",
272            dist
273        );
274    }
275
276    #[test]
277    fn test_elevation_from_directly_below() {
278        let torus = Torus::new(20, 72);
279        let shell = Shell::new(torus, 550_000.0, 87.0);
280        let proj = Projection::new(shell);
281
282        let p = Point::new(0, 0);
283        let sat = proj.satellite_ecef(p, 0.0);
284        let nadir = proj.nadir(p);
285
286        let elev = sat.elevation_from(nadir);
287        assert!(
288            elev > 85.0,
289            "elevation from nadir should be ~90°, got {}",
290            elev
291        );
292    }
293
294    #[test]
295    fn test_find_gateway_at_t0() {
296        let torus = Torus::new(20, 72);
297        let shell = Shell::new(torus, 550_000.0, 87.0);
298        let proj = Projection::new(shell);
299
300        // Ground station at the equator / prime meridian —
301        // should have a satellite overhead at t=0
302        let station = LatLon::new(0.0, 0.0);
303        let gw = proj.find_gateway(station, 0.0, 5.0);
304        assert!(gw.is_some(), "should find a gateway for equatorial station");
305    }
306
307    #[test]
308    fn test_find_gateway_changes_with_time() {
309        let torus = Torus::new(20, 72);
310        let shell = Shell::new(torus, 550_000.0, 87.0);
311        let proj = Projection::new(shell);
312
313        let station = LatLon::new(0.0, 0.0);
314        let gw0 = proj.find_gateway(station, 0.0, 5.0);
315        // Half an orbital period later (~47 min), the
316        // best satellite should be different
317        let half_period = shell.orbital_period_s() / 2.0;
318        let gw1 = proj.find_gateway(station, half_period, 5.0);
319
320        assert!(gw0 != gw1, "gateway should change: {:?} vs {:?}", gw0, gw1);
321    }
322}