space_dust/
observations.rs

1//! Angular observation calculations for ground-based tracking.
2//!
3//! This module provides:
4//! - **AzEl** - Azimuth/Elevation observations
5//! - **RaDec** - Right Ascension/Declination observations
6//! - **Observations** - Functions for computing observations from observer and target states
7
8use crate::constants::Constants;
9use crate::math::Vector3;
10use crate::state::{ECIState, GeodeticState, StateTransforms};
11use chrono::{DateTime, Utc};
12use std::f64::consts::PI;
13
14// ============================================================================
15// AzEl - Azimuth/Elevation Observation
16// ============================================================================
17
18/// Azimuth and Elevation observation.
19///
20/// Represents an angular observation in the local horizontal coordinate system:
21/// - Azimuth (Az): Angle measured clockwise from true North, in radians [0, 2π)
22/// - Elevation (El): Angle measured above the local horizon, in radians [-π/2, +π/2]
23#[derive(Debug, Clone, Copy)]
24pub struct AzEl {
25    /// UTC epoch of the observation
26    pub epoch: DateTime<Utc>,
27    /// Azimuth in radians [0, 2π), measured clockwise from North
28    pub azimuth: f64,
29    /// Elevation in radians [-π/2, +π/2], measured above horizon
30    pub elevation: f64,
31    /// Slant range in kilometers (optional)
32    pub range: Option<f64>,
33    /// Range rate in km/s (optional)
34    pub range_rate: Option<f64>,
35    /// Azimuth rate in rad/s (optional)
36    pub azimuth_rate: Option<f64>,
37    /// Elevation rate in rad/s (optional)
38    pub elevation_rate: Option<f64>,
39}
40
41impl AzEl {
42    /// Create a new Az/El observation.
43    ///
44    /// # Arguments
45    /// * `epoch` - UTC epoch of the observation
46    /// * `azimuth` - Azimuth in radians [0, 2π), measured clockwise from North
47    /// * `elevation` - Elevation in radians [-π/2, +π/2], measured above horizon
48    pub fn new(epoch: DateTime<Utc>, azimuth: f64, elevation: f64) -> Self {
49        Self {
50            epoch,
51            azimuth: normalize_azimuth(azimuth),
52            elevation,
53            range: None,
54            range_rate: None,
55            azimuth_rate: None,
56            elevation_rate: None,
57        }
58    }
59
60    /// Create Az/El from degrees.
61    pub fn from_degrees(epoch: DateTime<Utc>, az_deg: f64, el_deg: f64) -> Self {
62        Self::new(
63            epoch,
64            az_deg * Constants::DEG_TO_RAD,
65            el_deg * Constants::DEG_TO_RAD,
66        )
67    }
68
69    /// Set the range.
70    pub fn with_range(mut self, range: f64) -> Self {
71        self.range = Some(range);
72        self
73    }
74
75    /// Set the range rate.
76    pub fn with_range_rate(mut self, range_rate: f64) -> Self {
77        self.range_rate = Some(range_rate);
78        self
79    }
80
81    /// Set the azimuth rate.
82    pub fn with_azimuth_rate(mut self, azimuth_rate: f64) -> Self {
83        self.azimuth_rate = Some(azimuth_rate);
84        self
85    }
86
87    /// Set the elevation rate.
88    pub fn with_elevation_rate(mut self, elevation_rate: f64) -> Self {
89        self.elevation_rate = Some(elevation_rate);
90        self
91    }
92
93    /// Get azimuth in degrees.
94    pub fn azimuth_deg(&self) -> f64 {
95        self.azimuth * Constants::RAD_TO_DEG
96    }
97
98    /// Get elevation in degrees.
99    pub fn elevation_deg(&self) -> f64 {
100        self.elevation * Constants::RAD_TO_DEG
101    }
102
103    /// Convert to degrees, returning (azimuth_deg, elevation_deg).
104    pub fn to_degrees(&self) -> (f64, f64) {
105        (self.azimuth_deg(), self.elevation_deg())
106    }
107
108    /// Check if the target is above the horizon.
109    pub fn above_horizon(&self) -> bool {
110        self.elevation > 0.0
111    }
112
113    /// Check if the target is above a minimum elevation.
114    ///
115    /// # Arguments
116    /// * `min_elevation_deg` - Minimum elevation in degrees
117    pub fn above_elevation(&self, min_elevation_deg: f64) -> bool {
118        self.elevation >= min_elevation_deg * Constants::DEG_TO_RAD
119    }
120
121    /// Get compass direction from azimuth.
122    pub fn compass_direction(&self) -> &'static str {
123        let az_deg = self.azimuth_deg();
124        if az_deg < 22.5 || az_deg >= 337.5 {
125            "N"
126        } else if az_deg < 67.5 {
127            "NE"
128        } else if az_deg < 112.5 {
129            "E"
130        } else if az_deg < 157.5 {
131            "SE"
132        } else if az_deg < 202.5 {
133            "S"
134        } else if az_deg < 247.5 {
135            "SW"
136        } else if az_deg < 292.5 {
137            "W"
138        } else {
139            "NW"
140        }
141    }
142
143    /// Format as a human-readable string.
144    pub fn to_string(&self) -> String {
145        format!(
146            "Az: {:.2}° ({}), El: {:.2}°",
147            self.azimuth_deg(),
148            self.compass_direction(),
149            self.elevation_deg()
150        )
151    }
152}
153
154// ============================================================================
155// RaDec - Right Ascension/Declination Observation
156// ============================================================================
157
158/// Right Ascension and Declination observation.
159///
160/// Represents an angular observation in the equatorial coordinate system:
161/// - Right Ascension (RA): Angle measured eastward along the celestial equator
162///   from the vernal equinox, in radians [0, 2π)
163/// - Declination (Dec): Angle measured north (+) or south (-) from the
164///   celestial equator, in radians [-π/2, +π/2]
165#[derive(Debug, Clone, Copy)]
166pub struct RaDec {
167    /// UTC epoch of the observation
168    pub epoch: DateTime<Utc>,
169    /// Right ascension in radians [0, 2π)
170    pub right_ascension: f64,
171    /// Declination in radians [-π/2, +π/2]
172    pub declination: f64,
173    /// Distance in kilometers (optional)
174    pub range: Option<f64>,
175    /// Range rate in km/s (optional)
176    pub range_rate: Option<f64>,
177    /// RA rate in rad/s (optional)
178    pub right_ascension_rate: Option<f64>,
179    /// Dec rate in rad/s (optional)
180    pub declination_rate: Option<f64>,
181}
182
183impl RaDec {
184    /// Create a new RA/Dec observation.
185    ///
186    /// # Arguments
187    /// * `epoch` - UTC epoch of the observation
188    /// * `right_ascension` - Right ascension in radians [0, 2π)
189    /// * `declination` - Declination in radians [-π/2, +π/2]
190    pub fn new(epoch: DateTime<Utc>, right_ascension: f64, declination: f64) -> Self {
191        Self {
192            epoch,
193            right_ascension: normalize_ra(right_ascension),
194            declination,
195            range: None,
196            range_rate: None,
197            right_ascension_rate: None,
198            declination_rate: None,
199        }
200    }
201
202    /// Create RA/Dec from degrees.
203    pub fn from_degrees(epoch: DateTime<Utc>, ra_deg: f64, dec_deg: f64) -> Self {
204        Self::new(
205            epoch,
206            ra_deg * Constants::DEG_TO_RAD,
207            dec_deg * Constants::DEG_TO_RAD,
208        )
209    }
210
211    /// Create RA/Dec from hours/minutes/seconds (RA) and degrees/arcmin/arcsec (Dec).
212    pub fn from_hms_dms(
213        epoch: DateTime<Utc>,
214        ra_h: i32,
215        ra_m: i32,
216        ra_s: f64,
217        dec_d: i32,
218        dec_am: i32,
219        dec_as: f64,
220    ) -> Self {
221        // Convert RA from HMS to radians (1 hour = 15 degrees)
222        let ra_hours = ra_h as f64 + ra_m as f64 / 60.0 + ra_s / 3600.0;
223        let ra_rad = ra_hours * 15.0 * Constants::DEG_TO_RAD;
224
225        // Convert Dec from DMS to radians
226        let sign = if dec_d < 0 { -1.0 } else { 1.0 };
227        let dec_deg = dec_d.abs() as f64 + dec_am as f64 / 60.0 + dec_as / 3600.0;
228        let dec_rad = sign * dec_deg * Constants::DEG_TO_RAD;
229
230        Self::new(epoch, ra_rad, dec_rad)
231    }
232
233    /// Set the range.
234    pub fn with_range(mut self, range: f64) -> Self {
235        self.range = Some(range);
236        self
237    }
238
239    /// Set the range rate.
240    pub fn with_range_rate(mut self, range_rate: f64) -> Self {
241        self.range_rate = Some(range_rate);
242        self
243    }
244
245    /// Set the RA rate.
246    pub fn with_ra_rate(mut self, ra_rate: f64) -> Self {
247        self.right_ascension_rate = Some(ra_rate);
248        self
249    }
250
251    /// Set the Dec rate.
252    pub fn with_dec_rate(mut self, dec_rate: f64) -> Self {
253        self.declination_rate = Some(dec_rate);
254        self
255    }
256
257    /// Get right ascension in degrees.
258    pub fn ra_deg(&self) -> f64 {
259        self.right_ascension * Constants::RAD_TO_DEG
260    }
261
262    /// Get declination in degrees.
263    pub fn dec_deg(&self) -> f64 {
264        self.declination * Constants::RAD_TO_DEG
265    }
266
267    /// Convert to degrees, returning (ra_deg, dec_deg).
268    pub fn to_degrees(&self) -> (f64, f64) {
269        (self.ra_deg(), self.dec_deg())
270    }
271
272    /// Get right ascension in hours.
273    pub fn ra_hours(&self) -> f64 {
274        self.right_ascension * 12.0 / PI
275    }
276
277    /// Convert RA to hours/minutes/seconds string format.
278    pub fn ra_to_hms(&self) -> String {
279        let hours_total = self.ra_hours();
280        let h = hours_total.floor() as i32;
281        let m_total = (hours_total - h as f64) * 60.0;
282        let m = m_total.floor() as i32;
283        let s = (m_total - m as f64) * 60.0;
284        format!("{}h {}m {:.2}s", h, m, s)
285    }
286
287    /// Convert Dec to degrees/arcmin/arcsec string format.
288    pub fn dec_to_dms(&self) -> String {
289        let dec_deg = self.dec_deg();
290        let sign = if dec_deg < 0.0 { "-" } else { "+" };
291        let dec_deg = dec_deg.abs();
292        let d = dec_deg.floor() as i32;
293        let m_total = (dec_deg - d as f64) * 60.0;
294        let m = m_total.floor() as i32;
295        let s = (m_total - m as f64) * 60.0;
296        format!("{}{}° {}' {:.2}\"", sign, d, m, s)
297    }
298
299    /// Format as a human-readable string.
300    pub fn to_string(&self) -> String {
301        format!("RA: {}, Dec: {}", self.ra_to_hms(), self.dec_to_dms())
302    }
303}
304
305// ============================================================================
306// Observations - Observation Calculations
307// ============================================================================
308
309/// Functions for computing angular observations from observer and target states.
310pub struct Observations;
311
312impl Observations {
313    // ========================================================================
314    // RA/Dec Observations
315    // ========================================================================
316
317    /// Compute RA/Dec observation of a target from an observer.
318    ///
319    /// # Arguments
320    /// * `observer` - GeodeticState of the ground observer
321    /// * `target` - ECIState of the target
322    ///
323    /// # Returns
324    /// RaDec observation struct
325    pub fn compute_ra_dec(observer: &GeodeticState, target: &ECIState) -> RaDec {
326        // Get observer position in ECI
327        let observer_eci = observer.to_eci(target.epoch);
328
329        // Compute relative position vector (target - observer)
330        let rel_pos = target.position - observer_eci.position;
331        let rel_vel = target.velocity - observer_eci.velocity;
332
333        let (dx, dy, dz) = (rel_pos.x, rel_pos.y, rel_pos.z);
334        let (_dvx, _dvy, _dvz) = (rel_vel.x, rel_vel.y, rel_vel.z);
335
336        // Compute range
337        let range = rel_pos.magnitude();
338
339        // Compute RA and Dec
340        let mut ra = dy.atan2(dx);
341        if ra < 0.0 {
342            ra += Constants::TWO_PI;
343        }
344        let dec = (dz / range).asin();
345
346        RaDec::new(target.epoch, ra, dec).with_range(range)
347    }
348
349    /// Compute RA/Dec observation with angular rates.
350    pub fn compute_ra_dec_with_rates(observer: &GeodeticState, target: &ECIState) -> RaDec {
351        let observer_eci = observer.to_eci(target.epoch);
352        let rel_pos = target.position - observer_eci.position;
353        let rel_vel = target.velocity - observer_eci.velocity;
354
355        let (dx, dy, dz) = (rel_pos.x, rel_pos.y, rel_pos.z);
356        let (dvx, dvy, dvz) = (rel_vel.x, rel_vel.y, rel_vel.z);
357
358        let range = rel_pos.magnitude();
359
360        let mut ra = dy.atan2(dx);
361        if ra < 0.0 {
362            ra += Constants::TWO_PI;
363        }
364        let dec = (dz / range).asin();
365
366        // Range rate: (r · v) / |r|
367        let range_rate = (dx * dvx + dy * dvy + dz * dvz) / range;
368
369        // RA rate: d/dt[atan2(y, x)] = (x*vy - y*vx) / (x² + y²)
370        let xy_sq = dx * dx + dy * dy;
371        let ra_rate = if xy_sq > 1e-10 {
372            (dx * dvy - dy * dvx) / xy_sq
373        } else {
374            0.0
375        };
376
377        // Dec rate: d/dt[asin(z/r)] = (vz*r - z*dr/dt) / (r² * sqrt(1 - (z/r)²))
378        let cos_dec = dec.cos();
379        let dec_rate = if cos_dec > 1e-10 {
380            (dvz * range - dz * range_rate) / (range * range * cos_dec)
381        } else {
382            0.0
383        };
384
385        RaDec::new(target.epoch, ra, dec)
386            .with_range(range)
387            .with_range_rate(range_rate)
388            .with_ra_rate(ra_rate)
389            .with_dec_rate(dec_rate)
390    }
391
392    /// Compute geocentric RA/Dec directly from ECI position.
393    ///
394    /// This gives the geocentric RA/Dec (as seen from Earth's center),
395    /// not topocentric (as seen from a ground observer).
396    pub fn geocentric_ra_dec(target: &ECIState) -> RaDec {
397        let (x, y, z) = (target.position.x, target.position.y, target.position.z);
398        let range = target.position.magnitude();
399
400        let mut ra = y.atan2(x);
401        if ra < 0.0 {
402            ra += Constants::TWO_PI;
403        }
404        let dec = (z / range).asin();
405
406        RaDec::new(target.epoch, ra, dec).with_range(range)
407    }
408
409    // ========================================================================
410    // Az/El Observations
411    // ========================================================================
412
413    /// Compute Az/El observation of a target from an observer.
414    ///
415    /// # Arguments
416    /// * `observer` - GeodeticState of the ground observer
417    /// * `target` - ECIState of the target
418    ///
419    /// # Returns
420    /// AzEl observation struct
421    pub fn compute_az_el(observer: &GeodeticState, target: &ECIState) -> AzEl {
422        // Get observer ECEF position
423        let observer_ecef = observer.to_ecef(target.epoch);
424
425        // Convert target to ECEF
426        let target_ecef = StateTransforms::eci_to_ecef(target);
427
428        // Compute relative position in ECEF
429        let rel_ecef = target_ecef.position - observer_ecef.position;
430
431        // Transform to SEZ (South-East-Zenith) local coordinates
432        let (south, east, zenith) = ecef_to_sez(&rel_ecef, observer.latitude, observer.longitude);
433
434        // Compute range
435        let range = (south * south + east * east + zenith * zenith).sqrt();
436
437        // Compute Az and El
438        // Azimuth is measured clockwise from North
439        // In SEZ: North = -South, so Az = atan2(East, -South)
440        let mut az = east.atan2(-south);
441        if az < 0.0 {
442            az += Constants::TWO_PI;
443        }
444
445        // Elevation is angle above horizon
446        let el = (zenith / range).asin();
447
448        AzEl::new(target.epoch, az, el).with_range(range)
449    }
450
451    /// Compute Az/El observation with angular rates.
452    pub fn compute_az_el_with_rates(observer: &GeodeticState, target: &ECIState) -> AzEl {
453        let observer_ecef = observer.to_ecef(target.epoch);
454        let target_ecef = StateTransforms::eci_to_ecef(target);
455
456        let rel_ecef = target_ecef.position - observer_ecef.position;
457        let (south, east, zenith) = ecef_to_sez(&rel_ecef, observer.latitude, observer.longitude);
458
459        let range = (south * south + east * east + zenith * zenith).sqrt();
460
461        let mut az = east.atan2(-south);
462        if az < 0.0 {
463            az += Constants::TWO_PI;
464        }
465        let el = (zenith / range).asin();
466
467        // Relative velocity in ECEF and transform to SEZ
468        let rel_vel_ecef = target_ecef.velocity - observer_ecef.velocity;
469        let (ds, de, dz_vel) = ecef_to_sez(&rel_vel_ecef, observer.latitude, observer.longitude);
470
471        // Range rate
472        let range_rate = (south * ds + east * de + zenith * dz_vel) / range;
473
474        // Azimuth rate: d/dt[atan2(E, -S)] = (-S*dE - E*(-dS)) / (S² + E²)
475        let horiz_sq = south * south + east * east;
476        let az_rate = if horiz_sq > 1e-10 {
477            (south * de - east * ds) / horiz_sq
478        } else {
479            0.0
480        };
481
482        // Elevation rate: d/dt[asin(Z/r)]
483        let cos_el = el.cos();
484        let el_rate = if cos_el > 1e-10 {
485            (dz_vel * range - zenith * range_rate) / (range * range * cos_el)
486        } else {
487            0.0
488        };
489
490        AzEl::new(target.epoch, az, el)
491            .with_range(range)
492            .with_range_rate(range_rate)
493            .with_azimuth_rate(az_rate)
494            .with_elevation_rate(el_rate)
495    }
496
497    // ========================================================================
498    // Coordinate Conversions
499    // ========================================================================
500
501    /// Convert RA/Dec to Az/El for a given observer and time.
502    pub fn ra_dec_to_az_el(ra_dec: &RaDec, observer: &GeodeticState) -> AzEl {
503        let lat_rad = observer.latitude_rad();
504        let lst = observer.local_sidereal_time(&ra_dec.epoch);
505
506        // Hour angle = LST - RA
507        let ha = lst - ra_dec.right_ascension;
508
509        let sin_dec = ra_dec.declination.sin();
510        let cos_dec = ra_dec.declination.cos();
511        let sin_lat = lat_rad.sin();
512        let cos_lat = lat_rad.cos();
513        let sin_ha = ha.sin();
514        let cos_ha = ha.cos();
515
516        // Elevation
517        let sin_el = sin_dec * sin_lat + cos_dec * cos_lat * cos_ha;
518        let el = sin_el.asin();
519
520        // Azimuth
521        let cos_el = el.cos();
522        let sin_az = -cos_dec * sin_ha / cos_el;
523        let cos_az = (sin_dec - sin_lat * sin_el) / (cos_lat * cos_el);
524
525        let mut az = sin_az.atan2(cos_az);
526        if az < 0.0 {
527            az += Constants::TWO_PI;
528        }
529
530        let mut az_el = AzEl::new(ra_dec.epoch, az, el);
531        if let Some(range) = ra_dec.range {
532            az_el = az_el.with_range(range);
533        }
534        az_el
535    }
536
537    /// Convert Az/El to RA/Dec for a given observer and time.
538    pub fn az_el_to_ra_dec(az_el: &AzEl, observer: &GeodeticState) -> RaDec {
539        let lat_rad = observer.latitude_rad();
540        let lst = observer.local_sidereal_time(&az_el.epoch);
541
542        let sin_el = az_el.elevation.sin();
543        let cos_el = az_el.elevation.cos();
544        let sin_lat = lat_rad.sin();
545        let cos_lat = lat_rad.cos();
546        let sin_az = az_el.azimuth.sin();
547        let cos_az = az_el.azimuth.cos();
548
549        // Declination
550        let sin_dec = sin_el * sin_lat + cos_el * cos_lat * cos_az;
551        let dec = sin_dec.asin();
552
553        // Hour angle
554        let cos_dec = dec.cos();
555        let sin_ha = -cos_el * sin_az / cos_dec;
556        let cos_ha = (sin_el - sin_lat * sin_dec) / (cos_lat * cos_dec);
557        let ha = sin_ha.atan2(cos_ha);
558
559        // RA = LST - HA
560        let mut ra = lst - ha;
561        ra = ra % Constants::TWO_PI;
562        if ra < 0.0 {
563            ra += Constants::TWO_PI;
564        }
565
566        let mut ra_dec = RaDec::new(az_el.epoch, ra, dec);
567        if let Some(range) = az_el.range {
568            ra_dec = ra_dec.with_range(range);
569        }
570        ra_dec
571    }
572
573    // ========================================================================
574    // Line of Sight Vectors
575    // ========================================================================
576
577    /// Compute unit vector in ECI frame from RA/Dec.
578    pub fn ra_dec_to_eci_direction(ra_dec: &RaDec) -> Vector3 {
579        let cos_dec = ra_dec.declination.cos();
580        Vector3::new(
581            cos_dec * ra_dec.right_ascension.cos(),
582            cos_dec * ra_dec.right_ascension.sin(),
583            ra_dec.declination.sin(),
584        )
585    }
586
587    /// Compute unit vector in local SEZ frame from Az/El.
588    pub fn az_el_to_sez_direction(az_el: &AzEl) -> Vector3 {
589        let cos_el = az_el.elevation.cos();
590        // SEZ: South-East-Zenith
591        // Az measured from North, clockwise
592        Vector3::new(
593            -cos_el * az_el.azimuth.cos(), // South = -North
594            cos_el * az_el.azimuth.sin(),  // East
595            az_el.elevation.sin(),         // Zenith
596        )
597    }
598
599    // ========================================================================
600    // Visibility Checks
601    // ========================================================================
602
603    /// Check if a target is visible from an observer (above horizon).
604    ///
605    /// # Arguments
606    /// * `observer` - Ground observer location
607    /// * `target` - Target state in ECI
608    /// * `min_elevation_deg` - Minimum elevation in degrees (default 0.0)
609    pub fn is_visible(observer: &GeodeticState, target: &ECIState, min_elevation_deg: f64) -> bool {
610        let az_el = Self::compute_az_el(observer, target);
611        az_el.above_elevation(min_elevation_deg)
612    }
613
614    /// Compute the look angles and range from observer to target.
615    ///
616    /// Returns (az_deg, el_deg, range_km).
617    pub fn look_angles(observer: &GeodeticState, target: &ECIState) -> (f64, f64, f64) {
618        let az_el = Self::compute_az_el(observer, target);
619        (
620            az_el.azimuth_deg(),
621            az_el.elevation_deg(),
622            az_el.range.unwrap_or(0.0),
623        )
624    }
625}
626
627// ============================================================================
628// Helper Functions
629// ============================================================================
630
631/// Normalize azimuth to [0, 2π) range.
632fn normalize_azimuth(az: f64) -> f64 {
633    let mut normalized = az % Constants::TWO_PI;
634    if normalized < 0.0 {
635        normalized += Constants::TWO_PI;
636    }
637    normalized
638}
639
640/// Normalize right ascension to [0, 2π) range.
641fn normalize_ra(ra: f64) -> f64 {
642    let mut normalized = ra % Constants::TWO_PI;
643    if normalized < 0.0 {
644        normalized += Constants::TWO_PI;
645    }
646    normalized
647}
648
649/// Transform ECEF vector to SEZ (South-East-Zenith) local frame.
650fn ecef_to_sez(ecef: &Vector3, lat_deg: f64, lon_deg: f64) -> (f64, f64, f64) {
651    let lat_rad = lat_deg * Constants::DEG_TO_RAD;
652    let lon_rad = lon_deg * Constants::DEG_TO_RAD;
653
654    let sin_lat = lat_rad.sin();
655    let cos_lat = lat_rad.cos();
656    let sin_lon = lon_rad.sin();
657    let cos_lon = lon_rad.cos();
658
659    let (x, y, z) = (ecef.x, ecef.y, ecef.z);
660
661    // Rotation matrix ECEF -> SEZ
662    let south = sin_lat * cos_lon * x + sin_lat * sin_lon * y - cos_lat * z;
663    let east = -sin_lon * x + cos_lon * y;
664    let zenith = cos_lat * cos_lon * x + cos_lat * sin_lon * y + sin_lat * z;
665
666    (south, east, zenith)
667}
668
669/// Transform SEZ vector to ECEF.
670#[allow(dead_code)]
671fn sez_to_ecef(south: f64, east: f64, zenith: f64, lat_deg: f64, lon_deg: f64) -> Vector3 {
672    let lat_rad = lat_deg * Constants::DEG_TO_RAD;
673    let lon_rad = lon_deg * Constants::DEG_TO_RAD;
674
675    let sin_lat = lat_rad.sin();
676    let cos_lat = lat_rad.cos();
677    let sin_lon = lon_rad.sin();
678    let cos_lon = lon_rad.cos();
679
680    // Inverse rotation matrix SEZ -> ECEF
681    let x = sin_lat * cos_lon * south - sin_lon * east + cos_lat * cos_lon * zenith;
682    let y = sin_lat * sin_lon * south + cos_lon * east + cos_lat * sin_lon * zenith;
683    let z = -cos_lat * south + sin_lat * zenith;
684
685    Vector3::new(x, y, z)
686}
687
688#[cfg(test)]
689mod tests {
690    use super::*;
691    use chrono::TimeZone;
692
693    const EPSILON: f64 = 1e-6;
694
695    fn approx_eq(a: f64, b: f64, eps: f64) -> bool {
696        (a - b).abs() < eps
697    }
698
699    #[test]
700    fn test_az_el_creation() {
701        let epoch = Utc.with_ymd_and_hms(2024, 1, 1, 12, 0, 0).unwrap();
702        let az_el = AzEl::from_degrees(epoch, 90.0, 45.0);
703
704        assert!(approx_eq(az_el.azimuth_deg(), 90.0, EPSILON));
705        assert!(approx_eq(az_el.elevation_deg(), 45.0, EPSILON));
706    }
707
708    #[test]
709    fn test_az_el_above_horizon() {
710        let epoch = Utc.with_ymd_and_hms(2024, 1, 1, 12, 0, 0).unwrap();
711
712        let above = AzEl::from_degrees(epoch, 180.0, 10.0);
713        let below = AzEl::from_degrees(epoch, 180.0, -10.0);
714
715        assert!(above.above_horizon());
716        assert!(!below.above_horizon());
717    }
718
719    #[test]
720    fn test_az_el_compass_direction() {
721        let epoch = Utc.with_ymd_and_hms(2024, 1, 1, 12, 0, 0).unwrap();
722
723        assert_eq!(
724            AzEl::from_degrees(epoch, 0.0, 45.0).compass_direction(),
725            "N"
726        );
727        assert_eq!(
728            AzEl::from_degrees(epoch, 45.0, 45.0).compass_direction(),
729            "NE"
730        );
731        assert_eq!(
732            AzEl::from_degrees(epoch, 90.0, 45.0).compass_direction(),
733            "E"
734        );
735        assert_eq!(
736            AzEl::from_degrees(epoch, 135.0, 45.0).compass_direction(),
737            "SE"
738        );
739        assert_eq!(
740            AzEl::from_degrees(epoch, 180.0, 45.0).compass_direction(),
741            "S"
742        );
743        assert_eq!(
744            AzEl::from_degrees(epoch, 225.0, 45.0).compass_direction(),
745            "SW"
746        );
747        assert_eq!(
748            AzEl::from_degrees(epoch, 270.0, 45.0).compass_direction(),
749            "W"
750        );
751        assert_eq!(
752            AzEl::from_degrees(epoch, 315.0, 45.0).compass_direction(),
753            "NW"
754        );
755    }
756
757    #[test]
758    fn test_ra_dec_creation() {
759        let epoch = Utc.with_ymd_and_hms(2024, 1, 1, 12, 0, 0).unwrap();
760        let ra_dec = RaDec::from_degrees(epoch, 180.0, 45.0);
761
762        assert!(approx_eq(ra_dec.ra_deg(), 180.0, EPSILON));
763        assert!(approx_eq(ra_dec.dec_deg(), 45.0, EPSILON));
764    }
765
766    #[test]
767    fn test_ra_dec_hms() {
768        let epoch = Utc.with_ymd_and_hms(2024, 1, 1, 12, 0, 0).unwrap();
769        // 6 hours RA = 90 degrees
770        let ra_dec = RaDec::from_hms_dms(epoch, 6, 0, 0.0, 45, 0, 0.0);
771
772        assert!(approx_eq(ra_dec.ra_deg(), 90.0, 0.01));
773        assert!(approx_eq(ra_dec.dec_deg(), 45.0, 0.01));
774        assert!(approx_eq(ra_dec.ra_hours(), 6.0, 0.01));
775    }
776
777    #[test]
778    fn test_compute_az_el() {
779        let epoch = Utc.with_ymd_and_hms(2024, 1, 1, 12, 0, 0).unwrap();
780
781        // Observer at mid-latitudes
782        let observer = GeodeticState::new(40.0, -105.0, 1.6);
783
784        // Target in a high orbit position that should be visible
785        // Put satellite at a position that's definitely above the horizon
786        let target_pos = Vector3::new(20000.0, 20000.0, 15000.0);
787        let target = ECIState::new(epoch, target_pos, Vector3::zero());
788
789        let az_el = Observations::compute_az_el(&observer, &target);
790
791        // Range should be positive
792        assert!(az_el.range.unwrap() > 0.0);
793
794        // Azimuth should be in valid range
795        assert!(az_el.azimuth >= 0.0 && az_el.azimuth < Constants::TWO_PI);
796    }
797
798    #[test]
799    fn test_is_visible() {
800        let epoch = Utc.with_ymd_and_hms(2024, 1, 1, 12, 0, 0).unwrap();
801
802        // Observer in Denver
803        let observer = GeodeticState::new(39.7392, -104.9903, 1.6);
804
805        // Target in orbit
806        let target_pos = Vector3::new(7000.0, 1000.0, 500.0);
807        let target_vel = Vector3::new(-1.0, 7.0, 0.5);
808        let target = ECIState::new(epoch, target_pos, target_vel);
809
810        // Just check that the function runs without error
811        let _visible = Observations::is_visible(&observer, &target, 0.0);
812    }
813
814    #[test]
815    fn test_look_angles() {
816        let epoch = Utc.with_ymd_and_hms(2024, 1, 1, 12, 0, 0).unwrap();
817
818        let observer = GeodeticState::new(40.0, -105.0, 1.6);
819        let target_pos = Vector3::new(7000.0, 0.0, 3000.0);
820        let target = ECIState::new(epoch, target_pos, Vector3::zero());
821
822        let (az, _el, range) = Observations::look_angles(&observer, &target);
823
824        // Azimuth should be in valid range
825        assert!(az >= 0.0 && az < 360.0);
826        // Range should be positive
827        assert!(range > 0.0);
828    }
829
830    #[test]
831    fn test_normalize_azimuth() {
832        assert!(approx_eq(normalize_azimuth(0.0), 0.0, EPSILON));
833        assert!(approx_eq(
834            normalize_azimuth(Constants::TWO_PI),
835            0.0,
836            EPSILON
837        ));
838        assert!(approx_eq(
839            normalize_azimuth(-PI / 2.0),
840            3.0 * PI / 2.0,
841            EPSILON
842        ));
843    }
844
845    #[test]
846    fn test_ecef_to_sez() {
847        // At equator, prime meridian, a point directly above should be in zenith direction
848        let ecef = Vector3::new(1000.0, 0.0, 0.0);
849        let (south, east, zenith) = ecef_to_sez(&ecef, 0.0, 0.0);
850
851        // For a point on the X-axis at equator/prime meridian, it should be mostly zenith
852        assert!(zenith > south.abs());
853        assert!(zenith > east.abs());
854    }
855}