anise/almanac/
aer.rs

1/*
2 * ANISE Toolkit
3 * Copyright (C) 2021-onward Christopher Rabotin <christopher.rabotin@gmail.com> et al. (cf. AUTHORS.md)
4 * This Source Code Form is subject to the terms of the Mozilla Public
5 * License, v. 2.0. If a copy of the MPL was not distributed with this
6 * file, You can obtain one at https://mozilla.org/MPL/2.0/.
7 *
8 * Documentation: https://nyxspace.com/
9 */
10
11use crate::{
12    astro::{Aberration, AzElRange},
13    constants::SPEED_OF_LIGHT_KM_S,
14    ephemerides::{EphemerisError, EphemerisPhysicsSnafu},
15    errors::{AlmanacError, EphemerisSnafu, OrientationSnafu, PhysicsError},
16    frames::Frame,
17    math::angles::{between_0_360, between_pm_180},
18    prelude::Orbit,
19    structure::location::Location,
20};
21
22use super::Almanac;
23use crate::errors::AlmanacResult;
24
25use hifitime::TimeUnits;
26use log::warn;
27
28use snafu::ResultExt;
29
30impl Almanac {
31    /// Computes the azimuth (in degrees), elevation (in degrees), and range (in kilometers) of the
32    /// receiver state (`rx`) seen from the transmitter state (`tx`), once converted into the SEZ frame of the transmitter.
33    ///
34    /// # Warning
35    /// The obstructing body _should_ be a tri-axial ellipsoid body, e.g. IAU_MOON_FRAME.
36    ///
37    /// # Algorithm
38    /// 1. If any obstructing_bodies are provided, ensure that none of these are obstructing the line of sight between the receiver and transmitter.
39    /// 2. Compute the SEZ (South East Zenith) frame of the transmitter.
40    /// 3. Rotate the receiver position vector into the transmitter SEZ frame.
41    /// 4. Rotate the transmitter position vector into that same SEZ frame.
42    /// 5. Compute the range as the norm of the difference between these two position vectors.
43    /// 6. Compute the elevation, and ensure it is between +/- 180 degrees.
44    /// 7. Compute the azimuth with a quadrant check, and ensure it is between 0 and 360 degrees.
45    pub fn azimuth_elevation_range_sez(
46        &self,
47        rx: Orbit,
48        tx: Orbit,
49        obstructing_body: Option<Frame>,
50        ab_corr: Option<Aberration>,
51    ) -> AlmanacResult<AzElRange> {
52        if tx.epoch != rx.epoch {
53            return Err(AlmanacError::Ephemeris {
54                action: "",
55                source: Box::new(EphemerisError::EphemerisPhysics {
56                    action: "computing AER",
57                    source: PhysicsError::EpochMismatch {
58                        action: "computing AER",
59                        epoch1: tx.epoch,
60                        epoch2: rx.epoch,
61                    },
62                }),
63            });
64        }
65
66        let mut obstructed_by = None;
67        if let Some(obstructing_body) = obstructing_body {
68            if self.line_of_sight_obstructed(tx, rx, obstructing_body, ab_corr)? {
69                obstructed_by = Some(obstructing_body);
70            }
71        }
72
73        // Compute the SEZ DCM
74        // SEZ DCM is topo to fixed
75        let sez_dcm = tx
76            .dcm_from_topocentric_to_body_fixed(-1)
77            .context(EphemerisPhysicsSnafu { action: "" })
78            .context(EphemerisSnafu {
79                action: "computing SEZ DCM for AER",
80            })?;
81
82        let tx_sez = (sez_dcm.transpose() * tx)
83            .context(EphemerisPhysicsSnafu { action: "" })
84            .context(EphemerisSnafu {
85                action: "transforming transmitter to SEZ",
86            })?;
87
88        // Convert the receiver into the body fixed transmitter frame.
89        let rx_in_tx_frame = self.transform_to(rx, tx.frame, ab_corr)?;
90        // Convert into SEZ frame
91        let rx_sez = (sez_dcm.transpose() * rx_in_tx_frame)
92            .context(EphemerisPhysicsSnafu { action: "" })
93            .context(EphemerisSnafu {
94                action: "transforming received to SEZ",
95            })?;
96
97        // Convert receiver into the transmitter frame
98        let rx_in_tx_frame = self.transform_to(rx, tx.frame, ab_corr)?;
99
100        // Compute the range ρ in the SEZ frame for az/el
101        let rho_sez = rx_sez.radius_km - tx_sez.radius_km;
102        // And in the body-fixed transmitter frame for range and range-rate.
103        // While the norms of these vectors are identical, we need the exact vectors themselves for the range rate calculation.
104        let rho_tx_frame = rx_in_tx_frame.radius_km - tx.radius_km;
105
106        // Compute the range-rate \dot ρ. Note that rx_in_tx_frame is already the relative velocity of rx wrt tx!
107        let range_rate_km_s = rho_tx_frame.dot(&rx_in_tx_frame.velocity_km_s) / rho_tx_frame.norm();
108
109        // Finally, compute the elevation (math is the same as declination)
110        // Source: Vallado, section 4.4.3
111        // Only the sine is needed as per Vallado, and the formula is the same as the declination
112        // because we're in the SEZ frame.
113        let elevation_deg = between_pm_180((rho_sez.z / rho_sez.norm()).asin().to_degrees());
114        if (elevation_deg - 90.0).abs() < 1e-6 {
115            warn!("object nearly overhead (el = {elevation_deg:.6} deg), azimuth may be incorrect");
116        }
117        // For the elevation, we need to perform a quadrant check because it's measured from 0 to 360 degrees.
118        let azimuth_deg = between_0_360((rho_sez.y.atan2(-rho_sez.x)).to_degrees());
119
120        Ok(AzElRange {
121            epoch: tx.epoch,
122            azimuth_deg,
123            elevation_deg,
124            range_km: rho_sez.norm(),
125            range_rate_km_s,
126            obstructed_by,
127            light_time: (rho_sez.norm() / SPEED_OF_LIGHT_KM_S).seconds(),
128        })
129    }
130
131    /// Computes the azimuth (in degrees), elevation (in degrees), and range (in kilometers) of the
132    /// receiver state (`rx`) seen from the location ID (as transmitter state, once converted into the SEZ frame of the transmitter.
133    /// Refer to [azimuth_elevation_range_sez] for algorithm details.
134    pub fn azimuth_elevation_range_sez_from_location_id(
135        &self,
136        rx: Orbit,
137        location_id: i32,
138        obstructing_body: Option<Frame>,
139        ab_corr: Option<Aberration>,
140    ) -> AlmanacResult<AzElRange> {
141        match self.location_data.get_by_id(location_id) {
142            Ok(location) => self.azimuth_elevation_range_sez_from_location(
143                rx,
144                location,
145                obstructing_body,
146                ab_corr,
147            ),
148
149            Err(source) => Err(AlmanacError::TLDataSet {
150                action: "AER for location",
151                source,
152            }),
153        }
154    }
155
156    /// Computes the azimuth (in degrees), elevation (in degrees), and range (in kilometers) of the
157    /// receiver state (`rx`) seen from the location ID (as transmitter state, once converted into the SEZ frame of the transmitter.
158    /// Refer to [azimuth_elevation_range_sez] for algorithm details.
159    pub fn azimuth_elevation_range_sez_from_location_name(
160        &self,
161        rx: Orbit,
162        location_name: &str,
163        obstructing_body: Option<Frame>,
164        ab_corr: Option<Aberration>,
165    ) -> AlmanacResult<AzElRange> {
166        match self.location_data.get_by_name(location_name) {
167            Ok(location) => self.azimuth_elevation_range_sez_from_location(
168                rx,
169                location,
170                obstructing_body,
171                ab_corr,
172            ),
173
174            Err(source) => Err(AlmanacError::TLDataSet {
175                action: "AER for location",
176                source,
177            }),
178        }
179    }
180
181    /// Computes the azimuth (in degrees), elevation (in degrees), and range (in kilometers) of the
182    /// receiver state (`rx`) seen from the provided location (as transmitter state, once converted into the SEZ frame of the transmitter.
183    /// Refer to [azimuth_elevation_range_sez] for algorithm details.
184    /// Location terrain masks are always applied, i.e. if the terrain masks the object, all data is set to f64::NAN, unless specified otherwise in the Location.
185    pub fn azimuth_elevation_range_sez_from_location(
186        &self,
187        rx: Orbit,
188        location: Location,
189        obstructing_body: Option<Frame>,
190        ab_corr: Option<Aberration>,
191    ) -> AlmanacResult<AzElRange> {
192        let epoch = rx.epoch;
193        // If loading the frame data fails, stop here because the flatenning ratio must be defined.
194        let from_frame =
195            self.frame_info(location.frame)
196                .map_err(|e| AlmanacError::GenericError {
197                    err: format!("{e} when fetching {} frame data", location.frame),
198                })?;
199        let omega = self
200            .angular_velocity_wtr_j2000_rad_s(from_frame, epoch)
201            .context(OrientationSnafu {
202                action: "AER computation from location ID",
203            })?;
204        // Build the state of this orbit
205        match Orbit::try_latlongalt_omega(
206            location.latitude_deg,
207            location.longitude_deg,
208            location.height_km,
209            omega,
210            epoch,
211            from_frame,
212        ) {
213            Ok(tx) => self
214                .azimuth_elevation_range_sez(rx, tx, obstructing_body, ab_corr)
215                .map(|mut aer| {
216                    // Apply elevation mask
217                    if location.elevation_mask_at_azimuth_deg(aer.azimuth_deg) >= aer.elevation_deg
218                    {
219                        // Specify that it's obstructed, and set all values to NaN.
220                        aer.obstructed_by = Some(from_frame);
221                        if !location.terrain_mask_ignored {
222                            aer.range_km = f64::NAN;
223                            aer.range_rate_km_s = f64::NAN;
224                            aer.azimuth_deg = f64::NAN;
225                            aer.elevation_deg = f64::NAN;
226                        }
227                    }
228                    // Return the mutated aer
229                    aer
230                }),
231            Err(source) => Err(AlmanacError::Ephemeris {
232                action: "AER from location: could not build transmitter state",
233                source: Box::new(EphemerisError::EphemerisPhysics {
234                    action: "try_latlongalt_omega",
235                    source,
236                }),
237            }),
238        }
239    }
240}
241
242#[cfg(test)]
243mod ut_aer {
244    use core::str::FromStr;
245    use std::path::Path;
246
247    use hifitime::Unit;
248
249    use crate::astro::orbit::Orbit;
250    use crate::astro::AzElRange;
251    use crate::constants::frames::{EARTH_ITRF93, EARTH_J2000, IAU_EARTH_FRAME};
252    use crate::constants::usual_planetary_constants::MEAN_EARTH_ANGULAR_VELOCITY_DEG_S;
253    use crate::math::cartesian::CartesianState;
254    use crate::prelude::{Almanac, Epoch};
255    use crate::structure::location::{Location, TerrainMask};
256    use crate::structure::LocationDataSet;
257
258    #[test]
259    fn verif_edge_case() {
260        let almanac = Almanac::new("../data/pck08.pca").unwrap();
261        let itrf93 = almanac.frame_info(EARTH_ITRF93).unwrap();
262
263        // Data from another test case
264        let latitude_deg = -7.906_635_7;
265        let longitude_deg = 345.5975;
266        let height_km = 56.0e-3;
267        let epoch = Epoch::from_gregorian_utc_at_midnight(2024, 1, 14);
268
269        let ground_station = Orbit::try_latlongalt(
270            latitude_deg,
271            longitude_deg,
272            height_km,
273            MEAN_EARTH_ANGULAR_VELOCITY_DEG_S,
274            epoch,
275            itrf93,
276        )
277        .unwrap();
278
279        let aer = almanac
280            .azimuth_elevation_range_sez(ground_station, ground_station, None, None)
281            .unwrap();
282
283        assert!(!aer.is_valid());
284    }
285
286    /// Test comes from Nyx v 2.0.0-beta where we propagate a trajectory in GMAT and in Nyx and check that we match the measurement data.
287    /// This test MUST be change to a validation instead of a verification.
288    /// At the moment, the test checks that the range values are _similar_ to those generated by Nyx _before_ it was updated to use ANISE.
289    #[cfg(feature = "metaload")]
290    #[test]
291    fn gmat_verif() {
292        use crate::prelude::MetaAlmanac;
293        // Build the Madrid DSN gound station
294        let latitude_deg = 40.427_222;
295        let longitude_deg = 4.250_556;
296        let height_km = 0.834_939;
297
298        let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../data/aer_regression.dhall");
299        let almanac = MetaAlmanac::new(path.to_str().unwrap())
300            .unwrap()
301            .process(false)
302            .unwrap();
303
304        let iau_earth = almanac.frame_info(IAU_EARTH_FRAME).unwrap();
305        let eme2k = almanac.frame_info(EARTH_J2000).unwrap();
306
307        // Now iterate the trajectory to generate the measurements.
308        let gmat_ranges_km = [
309            9.145_755_787_575_61e4,
310            9.996_505_560_799_869e4,
311            1.073_229_118_411_670_2e5,
312            1.145_516_751_191_464_7e5,
313            1.265_739_190_638_930_7e5,
314        ];
315
316        let regression_data = [
317            AzElRange {
318                epoch: Epoch::from_str("2023-11-16T13:35:30.231999909 UTC").unwrap(),
319                azimuth_deg: 133.59998745846255,
320                elevation_deg: 7.23756749931629,
321                range_km: 91457.2680164461,
322                range_rate_km_s: 2.198785823156608,
323                obstructed_by: None,
324                light_time: 305068608 * Unit::Nanosecond,
325            },
326            AzElRange {
327                epoch: Epoch::from_str("2023-11-16T14:41:30.231999930 UTC").unwrap(),
328                azimuth_deg: 145.20134040829316,
329                elevation_deg: 15.541883052027405,
330                range_km: 99963.52694785153,
331                range_rate_km_s: 2.1050771837046436,
332                obstructed_by: None,
333                light_time: 333442434 * Unit::Nanosecond,
334            },
335            AzElRange {
336                epoch: Epoch::from_str("2023-11-16T15:40:30.231999839 UTC").unwrap(),
337                azimuth_deg: 157.35605910179052,
338                elevation_deg: 21.262025972059224,
339                range_km: 107320.26696466877,
340                range_rate_km_s: 2.0559576546712433,
341                obstructed_by: None,
342                light_time: 357981877 * Unit::Nanosecond,
343            },
344            AzElRange {
345                epoch: Epoch::from_str("2023-11-16T16:39:30.232000062 UTC").unwrap(),
346                azimuth_deg: 171.0253271744456,
347                elevation_deg: 24.777800273900453,
348                range_km: 114548.0748997545,
349                range_rate_km_s: 2.0308909733778924,
350                obstructed_by: None,
351                light_time: 382091249 * Unit::Nanosecond,
352            },
353            AzElRange {
354                epoch: Epoch::from_str("2023-11-16T18:18:30.231999937 UTC").unwrap(),
355                azimuth_deg: 195.44253883914308,
356                elevation_deg: 24.63526601848747,
357                range_km: 126569.46572408297,
358                range_rate_km_s: 2.021336308601692,
359                obstructed_by: None,
360                light_time: 422190293 * Unit::Nanosecond,
361            },
362        ];
363
364        let states = [
365            CartesianState::new(
366                58643.769881020,
367                -61696.430010747,
368                -36178.742480219,
369                2.148654262,
370                -1.202488371,
371                -0.714016096,
372                Epoch::from_str("2023-11-16T13:35:30.231999909 UTC").unwrap(),
373                eme2k,
374            ),
375            CartesianState::new(
376                66932.786922851,
377                -66232.181345574,
378                -38873.607459037,
379                2.040554622,
380                -1.092315772,
381                -0.649375769,
382                Epoch::from_str("2023-11-16T14:41:30.231999930 UTC").unwrap(),
383                eme2k,
384            ),
385            CartesianState::new(
386                74004.678508956,
387                -69951.392953800,
388                -41085.743778595,
389                1.956605843,
390                -1.011238479,
391                -0.601766262,
392                Epoch::from_str("2023-11-16T15:40:30.231999839 UTC").unwrap(),
393                eme2k,
394            ),
395            CartesianState::new(
396                80796.571971532,
397                -73405.942333285,
398                -43142.412981359,
399                1.882014733,
400                -0.942231959,
401                -0.561216138,
402                Epoch::from_str("2023-11-16T16:39:30.232000062 UTC").unwrap(),
403                eme2k,
404            ),
405            CartesianState::new(
406                91643.443331668,
407                -78707.208988294,
408                -46302.221669744,
409                1.773134524,
410                -0.846263432,
411                -0.504774983,
412                Epoch::from_str("2023-11-16T18:18:30.231999937 UTC").unwrap(),
413                eme2k,
414            ),
415        ];
416
417        for (sno, state) in states.iter().copied().enumerate() {
418            // Rebuild the ground station at this new epoch
419            let madrid = Orbit::try_latlongalt(
420                latitude_deg,
421                longitude_deg,
422                height_km,
423                MEAN_EARTH_ANGULAR_VELOCITY_DEG_S,
424                state.epoch,
425                iau_earth,
426            )
427            .unwrap();
428
429            let aer = almanac
430                .azimuth_elevation_range_sez(state, madrid, None, None)
431                .unwrap();
432
433            if sno == 0 {
434                assert_eq!(
435                    format!("{aer}"),
436                    format!(
437                        "{}: az.: 133.599987 deg    el.: 7.237567 deg    range: 91457.268016 km    range-rate: 2.198786 km/s    obstruction: none",
438                        state.epoch
439                    )
440                );
441            }
442
443            let expect = gmat_ranges_km[sno];
444
445            // The verification test was generated years ago using different data than in this test.
446            // However, it's been validated in real-world cislunar operations, the best kind of validation.
447            // Let's confirm that the data is not garbage compared to GMAT...
448            assert!((aer.range_km - expect).abs() < 5.0);
449            // ... and assert a regression check too
450            assert_eq!(aer, regression_data[sno], "{sno} differ");
451        }
452
453        // Ensure that if the state are in another frame, the results are (nearly) identical.
454
455        let states = states.map(|state| almanac.transform_to(state, EARTH_ITRF93, None).unwrap());
456
457        for (sno, state) in states.iter().copied().enumerate() {
458            // Rebuild the ground station at this new epoch
459            let madrid = Orbit::try_latlongalt(
460                latitude_deg,
461                longitude_deg,
462                height_km,
463                MEAN_EARTH_ANGULAR_VELOCITY_DEG_S,
464                state.epoch,
465                iau_earth,
466            )
467            .unwrap();
468
469            let aer = almanac
470                .azimuth_elevation_range_sez(state, madrid, None, None)
471                .unwrap();
472
473            if sno == 0 {
474                assert_eq!(
475                    format!("{aer}"),
476                    format!(
477                        "{}: az.: 133.599987 deg    el.: 7.237567 deg    range: 91457.268016 km    range-rate: 2.198786 km/s    obstruction: none",
478                        state.epoch
479                    )
480                );
481            }
482
483            let expect = gmat_ranges_km[sno];
484
485            // The verification test was generated years ago using different data than in this test.
486            // However, it's been validated in real-world cislunar operations, the best kind of validation.
487            // Let's confirm that the data is not garbage compared to GMAT...
488            assert!((aer.range_km - expect).abs() < 5.0);
489            // ... and assert a regression check too, with some small error for the transformation
490            assert!(
491                (aer.range_km - regression_data[sno].range_km).abs() < 1e-10,
492                "{sno}"
493            );
494            assert!(
495                (aer.range_rate_km_s - regression_data[sno].range_rate_km_s).abs() < 1e-10,
496                "{sno}"
497            );
498            assert!(
499                (aer.elevation_deg - regression_data[sno].elevation_deg).abs() < 1e-10,
500                "{sno}"
501            );
502            assert!(
503                (aer.azimuth_deg - regression_data[sno].azimuth_deg).abs() < 1e-10,
504                "{sno}"
505            );
506        }
507    }
508
509    /// Rebuild the GMAT Verif test using a location data type directly.
510    ///
511    /// For reference, the `gmat_verif` test below returns these values
512    ///
513    /// [anise/src/almanac/aer.rs:583:21] aer.range_km - expect = -0.28985930999624543
514    /// [anise/src/almanac/aer.rs:583:21] aer.range_km - expect = -1.528660147159826
515    /// [anise/src/almanac/aer.rs:583:21] aer.range_km - expect = -2.6448764982487774
516    /// [anise/src/almanac/aer.rs:583:21] aer.range_km - expect = -3.600219391970313
517    /// [anise/src/almanac/aer.rs:583:21] aer.range_km - expect = -4.453339810104808
518    #[cfg(feature = "metaload")]
519    #[test]
520    fn gmat_verif_location() {
521        use crate::prelude::MetaAlmanac;
522        // Build the new location
523        let dsn_madrid = Location {
524            latitude_deg: 40.427_222,
525            longitude_deg: 4.250_556,
526            height_km: 0.834_939,
527            frame: EARTH_ITRF93.into(),
528            // Create a fake elevation mask to check that functionality
529            terrain_mask: vec![
530                TerrainMask {
531                    azimuth_deg: 0.0,
532                    elevation_mask_deg: 0.0,
533                },
534                TerrainMask {
535                    azimuth_deg: 130.0,
536                    elevation_mask_deg: 8.0,
537                },
538                TerrainMask {
539                    azimuth_deg: 140.0,
540                    elevation_mask_deg: 0.0,
541                },
542            ],
543            // Ignore terrain mask for the test
544            terrain_mask_ignored: true,
545        };
546
547        // Build a dataset with this single location
548        let mut loc_data = LocationDataSet::default();
549        loc_data
550            .push(dsn_madrid, Some(123), Some("DSN Madrid"))
551            .unwrap();
552
553        let path = Path::new(env!("CARGO_MANIFEST_DIR"));
554        let mut almanac =
555            MetaAlmanac::new(path.join("../data/aer_regression.dhall").to_str().unwrap())
556                .unwrap()
557                .process(false)
558                .unwrap()
559                .load("../data/pck08.pca")
560                .unwrap();
561        almanac.location_data = loc_data;
562
563        let eme2k = almanac.frame_info(EARTH_J2000).unwrap();
564        // Data from another test case
565        // Now iterate the trajectory to generate the measurements.
566        let gmat_ranges_km = [
567            9.145_755_787_575_61e4,
568            9.996_505_560_799_869e4,
569            1.073_229_118_411_670_2e5,
570            1.145_516_751_191_464_7e5,
571            1.265_739_190_638_930_7e5,
572        ];
573
574        let states = [
575            CartesianState::new(
576                58643.769881020,
577                -61696.430010747,
578                -36178.742480219,
579                2.148654262,
580                -1.202488371,
581                -0.714016096,
582                Epoch::from_str("2023-11-16T13:35:30.231999909 UTC").unwrap(),
583                eme2k,
584            ),
585            CartesianState::new(
586                66932.786922851,
587                -66232.181345574,
588                -38873.607459037,
589                2.040554622,
590                -1.092315772,
591                -0.649375769,
592                Epoch::from_str("2023-11-16T14:41:30.231999930 UTC").unwrap(),
593                eme2k,
594            ),
595            CartesianState::new(
596                74004.678508956,
597                -69951.392953800,
598                -41085.743778595,
599                1.956605843,
600                -1.011238479,
601                -0.601766262,
602                Epoch::from_str("2023-11-16T15:40:30.231999839 UTC").unwrap(),
603                eme2k,
604            ),
605            CartesianState::new(
606                80796.571971532,
607                -73405.942333285,
608                -43142.412981359,
609                1.882014733,
610                -0.942231959,
611                -0.561216138,
612                Epoch::from_str("2023-11-16T16:39:30.232000062 UTC").unwrap(),
613                eme2k,
614            ),
615            CartesianState::new(
616                91643.443331668,
617                -78707.208988294,
618                -46302.221669744,
619                1.773134524,
620                -0.846263432,
621                -0.504774983,
622                Epoch::from_str("2023-11-16T18:18:30.231999937 UTC").unwrap(),
623                eme2k,
624            ),
625        ];
626
627        for (sno, state) in states.iter().copied().enumerate() {
628            let aer_from_name = almanac
629                .azimuth_elevation_range_sez_from_location_name(state, "DSN Madrid", None, None)
630                .unwrap();
631
632            // IMPORTANT: We're getting much larger errors here but much less deviation than in the `gmat_verif` case.
633            // Here, the first four errors are -5 km +/- 0.7 (and the last case is -2.6 km). In the other test, we vary
634            // from 0.3 km to 5 km.
635            // This indicates that the higher precision rotation is better, but that the data source used in that test is different.
636            let expect = gmat_ranges_km[sno];
637            assert!(dbg!(aer_from_name.range_km - expect).abs() < 5.1);
638
639            // Check that we can fetch with the ID as well.
640            let aer_from_id = almanac
641                .azimuth_elevation_range_sez_from_location_id(state, 123, None, None)
642                .unwrap();
643
644            assert_eq!(aer_from_id, aer_from_name);
645
646            if sno == 0 {
647                assert!(aer_from_id.is_obstructed(), "terrain should be in the way");
648            }
649        }
650    }
651}