1use crate::constants::Constants;
9use crate::math::Vector3;
10use crate::state::{ECIState, GeodeticState, StateTransforms};
11use chrono::{DateTime, Utc};
12use std::f64::consts::PI;
13
14#[derive(Debug, Clone, Copy)]
24pub struct AzEl {
25 pub epoch: DateTime<Utc>,
27 pub azimuth: f64,
29 pub elevation: f64,
31 pub range: Option<f64>,
33 pub range_rate: Option<f64>,
35 pub azimuth_rate: Option<f64>,
37 pub elevation_rate: Option<f64>,
39}
40
41impl AzEl {
42 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 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 pub fn with_range(mut self, range: f64) -> Self {
71 self.range = Some(range);
72 self
73 }
74
75 pub fn with_range_rate(mut self, range_rate: f64) -> Self {
77 self.range_rate = Some(range_rate);
78 self
79 }
80
81 pub fn with_azimuth_rate(mut self, azimuth_rate: f64) -> Self {
83 self.azimuth_rate = Some(azimuth_rate);
84 self
85 }
86
87 pub fn with_elevation_rate(mut self, elevation_rate: f64) -> Self {
89 self.elevation_rate = Some(elevation_rate);
90 self
91 }
92
93 pub fn azimuth_deg(&self) -> f64 {
95 self.azimuth * Constants::RAD_TO_DEG
96 }
97
98 pub fn elevation_deg(&self) -> f64 {
100 self.elevation * Constants::RAD_TO_DEG
101 }
102
103 pub fn to_degrees(&self) -> (f64, f64) {
105 (self.azimuth_deg(), self.elevation_deg())
106 }
107
108 pub fn above_horizon(&self) -> bool {
110 self.elevation > 0.0
111 }
112
113 pub fn above_elevation(&self, min_elevation_deg: f64) -> bool {
118 self.elevation >= min_elevation_deg * Constants::DEG_TO_RAD
119 }
120
121 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 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#[derive(Debug, Clone, Copy)]
166pub struct RaDec {
167 pub epoch: DateTime<Utc>,
169 pub right_ascension: f64,
171 pub declination: f64,
173 pub range: Option<f64>,
175 pub range_rate: Option<f64>,
177 pub right_ascension_rate: Option<f64>,
179 pub declination_rate: Option<f64>,
181}
182
183impl RaDec {
184 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 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 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 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 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 pub fn with_range(mut self, range: f64) -> Self {
235 self.range = Some(range);
236 self
237 }
238
239 pub fn with_range_rate(mut self, range_rate: f64) -> Self {
241 self.range_rate = Some(range_rate);
242 self
243 }
244
245 pub fn with_ra_rate(mut self, ra_rate: f64) -> Self {
247 self.right_ascension_rate = Some(ra_rate);
248 self
249 }
250
251 pub fn with_dec_rate(mut self, dec_rate: f64) -> Self {
253 self.declination_rate = Some(dec_rate);
254 self
255 }
256
257 pub fn ra_deg(&self) -> f64 {
259 self.right_ascension * Constants::RAD_TO_DEG
260 }
261
262 pub fn dec_deg(&self) -> f64 {
264 self.declination * Constants::RAD_TO_DEG
265 }
266
267 pub fn to_degrees(&self) -> (f64, f64) {
269 (self.ra_deg(), self.dec_deg())
270 }
271
272 pub fn ra_hours(&self) -> f64 {
274 self.right_ascension * 12.0 / PI
275 }
276
277 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 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 pub fn to_string(&self) -> String {
301 format!("RA: {}, Dec: {}", self.ra_to_hms(), self.dec_to_dms())
302 }
303}
304
305pub struct Observations;
311
312impl Observations {
313 pub fn compute_ra_dec(observer: &GeodeticState, target: &ECIState) -> RaDec {
326 let observer_eci = observer.to_eci(target.epoch);
328
329 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 let range = rel_pos.magnitude();
338
339 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 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 let range_rate = (dx * dvx + dy * dvy + dz * dvz) / range;
368
369 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 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 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 pub fn compute_az_el(observer: &GeodeticState, target: &ECIState) -> AzEl {
422 let observer_ecef = observer.to_ecef(target.epoch);
424
425 let target_ecef = StateTransforms::eci_to_ecef(target);
427
428 let rel_ecef = target_ecef.position - observer_ecef.position;
430
431 let (south, east, zenith) = ecef_to_sez(&rel_ecef, observer.latitude, observer.longitude);
433
434 let range = (south * south + east * east + zenith * zenith).sqrt();
436
437 let mut az = east.atan2(-south);
441 if az < 0.0 {
442 az += Constants::TWO_PI;
443 }
444
445 let el = (zenith / range).asin();
447
448 AzEl::new(target.epoch, az, el).with_range(range)
449 }
450
451 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 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 let range_rate = (south * ds + east * de + zenith * dz_vel) / range;
473
474 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 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 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 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 let sin_el = sin_dec * sin_lat + cos_dec * cos_lat * cos_ha;
518 let el = sin_el.asin();
519
520 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 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 let sin_dec = sin_el * sin_lat + cos_el * cos_lat * cos_az;
551 let dec = sin_dec.asin();
552
553 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 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 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 pub fn az_el_to_sez_direction(az_el: &AzEl) -> Vector3 {
589 let cos_el = az_el.elevation.cos();
590 Vector3::new(
593 -cos_el * az_el.azimuth.cos(), cos_el * az_el.azimuth.sin(), az_el.elevation.sin(), )
597 }
598
599 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 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
627fn 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
640fn 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
649fn 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 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#[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 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 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 let observer = GeodeticState::new(40.0, -105.0, 1.6);
783
784 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 assert!(az_el.range.unwrap() > 0.0);
793
794 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 let observer = GeodeticState::new(39.7392, -104.9903, 1.6);
804
805 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 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 assert!(az >= 0.0 && az < 360.0);
826 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 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 assert!(zenith > south.abs());
853 assert!(zenith > east.abs());
854 }
855}