1use crate::{CoordResult, Distance};
2use celestial_core::constants::{HALF_PI, TWOPI};
3use celestial_core::{Angle, Location};
4use celestial_time::TT;
5
6const EARTH_RADIUS_AU: f64 = 4.2635e-5; #[cfg(feature = "serde")]
9use serde::{Deserialize, Serialize};
10
11#[derive(Debug, Clone, PartialEq)]
12#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
13pub struct TopocentricPosition {
14 azimuth: Angle,
15 elevation: Angle,
16 observer: Location,
17 epoch: TT,
18 distance: Option<Distance>,
19}
20
21#[derive(Debug, Clone, PartialEq)]
22#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
23pub struct HourAnglePosition {
24 hour_angle: Angle,
25 declination: Angle,
26 observer: Location,
27 epoch: TT,
28 distance: Option<Distance>,
29}
30
31impl TopocentricPosition {
32 pub fn new(
33 azimuth: Angle,
34 elevation: Angle,
35 observer: Location,
36 epoch: TT,
37 ) -> CoordResult<Self> {
38 let azimuth = azimuth.validate_longitude(true)?;
39 let elevation = elevation.validate_latitude()?;
40
41 Ok(Self {
42 azimuth,
43 elevation,
44 observer,
45 epoch,
46 distance: None,
47 })
48 }
49
50 pub fn with_distance(
51 azimuth: Angle,
52 elevation: Angle,
53 observer: Location,
54 epoch: TT,
55 distance: Distance,
56 ) -> CoordResult<Self> {
57 let mut pos = Self::new(azimuth, elevation, observer, epoch)?;
58 pos.distance = Some(distance);
59 Ok(pos)
60 }
61
62 pub fn from_degrees(
63 az_deg: f64,
64 el_deg: f64,
65 observer: Location,
66 epoch: TT,
67 ) -> CoordResult<Self> {
68 Self::new(
69 Angle::from_degrees(az_deg),
70 Angle::from_degrees(el_deg),
71 observer,
72 epoch,
73 )
74 }
75
76 pub fn azimuth(&self) -> Angle {
77 self.azimuth
78 }
79
80 pub fn elevation(&self) -> Angle {
81 self.elevation
82 }
83
84 pub fn observer(&self) -> &Location {
85 &self.observer
86 }
87
88 pub fn epoch(&self) -> TT {
89 self.epoch
90 }
91
92 pub fn distance(&self) -> Option<Distance> {
93 self.distance
94 }
95
96 pub fn set_distance(&mut self, distance: Distance) {
97 self.distance = Some(distance);
98 }
99
100 pub fn zenith_angle(&self) -> Angle {
101 Angle::HALF_PI - self.elevation
102 }
103
104 pub fn air_mass(&self) -> f64 {
105 self.air_mass_rozenberg()
106 }
107
108 pub fn air_mass_rozenberg(&self) -> f64 {
109 let zenith = self.zenith_angle();
110 if zenith.degrees() >= 90.0 {
111 return 40.0;
112 }
113 let cos_z = libm::cos(zenith.radians());
114 let term = cos_z + 0.025 * libm::exp(-11.0 * cos_z);
115 1.0 / term
116 }
117
118 pub fn air_mass_pickering(&self) -> f64 {
133 let h = self.elevation.degrees();
134 if h <= -2.0 {
135 return f64::INFINITY;
136 }
137 let h_term = 244.0 / (165.0 + 47.0 * h.abs().powf(1.1));
138 let sin_arg = h + h_term;
139 1.0 / libm::sin(sin_arg * celestial_core::constants::DEG_TO_RAD)
140 }
141
142 pub fn air_mass_kasten_young(&self) -> f64 {
143 let zenith_deg = self.zenith_angle().degrees();
144 if zenith_deg >= 90.0 {
145 return 38.0;
146 }
147 let cos_z = libm::cos(self.zenith_angle().radians());
148 let term = (96.07995 - zenith_deg).powf(-1.6364);
149 1.0 / (cos_z + 0.50572 * term)
150 }
151
152 pub fn atmospheric_refraction(
153 &self,
154 pressure_hpa: f64,
155 temp_celsius: f64,
156 rel_humidity: f64,
157 wavelength_um: f64,
158 ) -> Angle {
159 let (refa, refb) =
160 self.refraction_coefficients(pressure_hpa, temp_celsius, rel_humidity, wavelength_um);
161
162 let z_obs = self.zenith_angle().radians();
164 let tan_z = libm::tan(z_obs);
165 let refraction_rad = refa * tan_z + refb * tan_z.powi(3);
166
167 Angle::from_radians(refraction_rad)
168 }
169
170 fn refraction_coefficients(
171 &self,
172 mut pressure_hpa: f64,
173 mut temp_celsius: f64,
174 mut rel_humidity: f64,
175 wavelength_um: f64,
176 ) -> (f64, f64) {
177 pressure_hpa = pressure_hpa.clamp(0.0, 10000.0);
178 temp_celsius = temp_celsius.clamp(-150.0, 200.0);
179 rel_humidity = rel_humidity.clamp(0.0, 1.0);
180
181 if pressure_hpa <= 0.0 {
183 return (0.0, 0.0);
184 }
185
186 let is_optical = (0.0..100.0).contains(&wavelength_um);
188
189 let temp_kelvin = temp_celsius + 273.15;
191
192 let pw = if pressure_hpa > 0.0 {
193 let ps = 10.0_f64
195 .powf((0.7859 + 0.03477 * temp_celsius) / (1.0 + 0.00412 * temp_celsius))
196 * (1.0 + pressure_hpa * (4.5e-6 + 6e-10 * temp_celsius * temp_celsius));
197 rel_humidity * ps / (1.0 - (1.0 - rel_humidity) * ps / pressure_hpa)
199 } else {
200 0.0
201 };
202
203 if is_optical {
204 let wl_sq = wavelength_um * wavelength_um;
206 let gamma = ((77.53484e-6 + (4.39108e-7 + 3.666e-9 / wl_sq) / wl_sq) * pressure_hpa
207 - 11.2684e-6 * pw)
208 / temp_kelvin;
209
210 let beta = 4.4474e-6 * temp_kelvin;
212
213 let refa = gamma * (1.0 - beta);
215 let refb = -gamma * (beta - gamma / 2.0);
216
217 (refa, refb)
218 } else {
219 let gamma = (77.6890e-6 * pressure_hpa - (6.3938e-6 - 0.375463 / temp_kelvin) * pw)
221 / temp_kelvin;
222
223 let mut beta = 4.4474e-6 * temp_kelvin;
225 beta -= 0.0074 * pw * beta;
226
227 let refa = gamma * (1.0 - beta);
229 let refb = -gamma * (beta - gamma / 2.0);
230
231 (refa, refb)
232 }
233 }
234
235 pub fn with_refraction(
236 &self,
237 pressure_hpa: f64,
238 temp_celsius: f64,
239 rel_humidity: f64,
240 wavelength_um: f64,
241 ) -> Self {
242 let refraction =
243 self.atmospheric_refraction(pressure_hpa, temp_celsius, rel_humidity, wavelength_um);
244
245 let apparent_elevation = self.elevation + refraction;
247
248 Self {
249 azimuth: self.azimuth,
250 elevation: apparent_elevation,
251 observer: self.observer,
252 epoch: self.epoch,
253 distance: self.distance,
254 }
255 }
256
257 pub fn without_refraction(
258 &self,
259 pressure_hpa: f64,
260 temp_celsius: f64,
261 rel_humidity: f64,
262 wavelength_um: f64,
263 ) -> Self {
264 let refraction =
265 self.atmospheric_refraction(pressure_hpa, temp_celsius, rel_humidity, wavelength_um);
266
267 let true_elevation = self.elevation - refraction;
269
270 Self {
271 azimuth: self.azimuth,
272 elevation: true_elevation,
273 observer: self.observer,
274 epoch: self.epoch,
275 distance: self.distance,
276 }
277 }
278
279 pub fn diurnal_parallax(&self) -> Option<Angle> {
280 self.distance.map(|d| {
281 let distance_au = d.au();
283 let zenith_angle = self.zenith_angle();
284
285 let ratio = EARTH_RADIUS_AU / distance_au;
287 let parallax_rad = if ratio < 1.0 {
288 libm::asin(ratio * zenith_angle.sin())
289 } else {
290 HALF_PI
292 };
293
294 Angle::from_radians(parallax_rad)
295 })
296 }
297
298 pub fn horizontal_parallax(&self) -> Option<Angle> {
299 self.distance.map(|d| {
300 let distance_au = d.au();
301 let ratio = EARTH_RADIUS_AU / distance_au;
302
303 if ratio < 1.0 {
304 Angle::from_radians(libm::asin(ratio))
305 } else {
306 Angle::from_radians(HALF_PI)
307 }
308 })
309 }
310
311 pub fn with_diurnal_parallax(&self) -> Self {
312 if let Some(parallax) = self.diurnal_parallax() {
313 let corrected_elevation = self.elevation - parallax;
315
316 Self {
317 azimuth: self.azimuth,
318 elevation: corrected_elevation,
319 observer: self.observer,
320 epoch: self.epoch,
321 distance: self.distance,
322 }
323 } else {
324 self.clone()
325 }
326 }
327
328 pub fn without_diurnal_parallax(&self) -> Self {
329 if let Some(parallax) = self.diurnal_parallax() {
330 let geocentric_elevation = self.elevation + parallax;
332
333 Self {
334 azimuth: self.azimuth,
335 elevation: geocentric_elevation,
336 observer: self.observer,
337 epoch: self.epoch,
338 distance: self.distance,
339 }
340 } else {
341 self.clone()
342 }
343 }
344
345 pub fn is_above_horizon(&self) -> bool {
346 self.elevation.degrees() > 0.0
347 }
348
349 pub fn is_near_zenith(&self) -> bool {
350 self.elevation.degrees() > 89.0
351 }
352
353 pub fn is_near_horizon(&self) -> bool {
354 self.elevation.degrees() < 10.0 && self.is_above_horizon()
355 }
356
357 pub fn cardinal_direction(&self) -> &'static str {
358 let az_deg = self.azimuth.degrees();
359 if !(22.5..337.5).contains(&az_deg) {
360 "N"
361 } else if az_deg < 67.5 {
362 "NE"
363 } else if az_deg < 112.5 {
364 "E"
365 } else if az_deg < 157.5 {
366 "SE"
367 } else if az_deg < 202.5 {
368 "S"
369 } else if az_deg < 247.5 {
370 "SW"
371 } else if az_deg < 292.5 {
372 "W"
373 } else {
374 "NW"
375 }
376 }
377
378 pub fn parallactic_angle(&self, hour_angle: Angle, declination: Angle) -> Angle {
379 let lat = self.observer.latitude_angle();
380 let (sin_ha, cos_ha) = hour_angle.sin_cos();
381 let (sin_lat, cos_lat) = lat.sin_cos();
382 let (sin_dec, cos_dec) = declination.sin_cos();
383
384 let numerator = sin_ha;
385 let denominator = cos_dec * sin_lat - sin_dec * cos_lat * cos_ha;
386
387 Angle::from_radians(libm::atan2(numerator, denominator))
388 }
389
390 pub fn to_hour_angle(&self) -> CoordResult<HourAnglePosition> {
391 let (sin_az, cos_az) = self.azimuth.sin_cos();
392 let (sin_el, cos_el) = self.elevation.sin_cos();
393 let (sin_lat, cos_lat) = self.observer.latitude_angle().sin_cos();
394
395 let sin_dec = sin_el * sin_lat + cos_el * cos_lat * cos_az;
396 let dec = libm::asin(sin_dec);
397
398 let cos_dec = libm::cos(dec);
399
400 let cos_ha = if cos_dec.abs() < 1e-10 {
401 0.0
402 } else {
403 (sin_el - sin_dec * sin_lat) / (cos_dec * cos_lat)
404 };
405
406 let sin_ha = if cos_dec.abs() < 1e-10 {
407 0.0
408 } else {
409 -sin_az * cos_el / cos_dec
410 };
411
412 let ha = libm::atan2(sin_ha, cos_ha);
413
414 let mut ha_pos = HourAnglePosition::new(
415 Angle::from_radians(ha),
416 Angle::from_radians(dec),
417 self.observer,
418 self.epoch,
419 )?;
420
421 if let Some(distance) = self.distance {
422 ha_pos.set_distance(distance);
423 }
424
425 Ok(ha_pos)
426 }
427
428 pub fn to_cirs(&self, delta_t: f64) -> CoordResult<crate::frames::CIRSPosition> {
429 let ha = self.to_hour_angle()?;
430 ha.to_cirs(delta_t)
431 }
432}
433
434impl HourAnglePosition {
435 pub fn new(
436 hour_angle: Angle,
437 declination: Angle,
438 observer: Location,
439 epoch: TT,
440 ) -> CoordResult<Self> {
441 let hour_angle = hour_angle.wrapped(); let declination = declination.validate_declination(true)?; Ok(Self {
445 hour_angle,
446 declination,
447 observer,
448 epoch,
449 distance: None,
450 })
451 }
452
453 pub fn with_distance(
454 hour_angle: Angle,
455 declination: Angle,
456 observer: Location,
457 epoch: TT,
458 distance: Distance,
459 ) -> CoordResult<Self> {
460 let mut pos = Self::new(hour_angle, declination, observer, epoch)?;
461 pos.distance = Some(distance);
462 Ok(pos)
463 }
464
465 pub fn hour_angle(&self) -> Angle {
466 self.hour_angle
467 }
468
469 pub fn declination(&self) -> Angle {
470 self.declination
471 }
472
473 pub fn observer(&self) -> &Location {
474 &self.observer
475 }
476
477 pub fn epoch(&self) -> TT {
478 self.epoch
479 }
480
481 pub fn distance(&self) -> Option<Distance> {
482 self.distance
483 }
484
485 pub fn set_distance(&mut self, distance: Distance) {
486 self.distance = Some(distance);
487 }
488
489 pub fn to_topocentric(&self) -> CoordResult<TopocentricPosition> {
490 let (sin_ha, cos_ha) = self.hour_angle.sin_cos(); let (sin_dec, cos_dec) = self.declination.sin_cos(); let (sin_lat, cos_lat) = self.observer.latitude_angle().sin_cos(); let x = -cos_ha * cos_dec * sin_lat + sin_dec * cos_lat; let y = -sin_ha * cos_dec; let z = cos_ha * cos_dec * cos_lat + sin_dec * sin_lat; let r = libm::sqrt(x * x + y * y); let raw_azimuth = if r != 0.0 { libm::atan2(y, x) } else { 0.0 }; let azimuth = if raw_azimuth < 0.0 {
507 raw_azimuth + TWOPI
509 } else {
510 raw_azimuth
511 };
512
513 let elevation = libm::atan2(z, r); let mut topo = TopocentricPosition::new(
516 Angle::from_radians(azimuth),
517 Angle::from_radians(elevation),
518 self.observer,
519 self.epoch,
520 )?;
521
522 if let Some(distance) = self.distance {
523 topo.set_distance(distance);
524 }
525
526 Ok(topo)
527 }
528
529 pub fn is_circumpolar(&self) -> bool {
530 let lat = self.observer.latitude_angle();
531 self.declination.radians() > (Angle::HALF_PI - lat).radians()
532 }
533
534 pub fn never_rises(&self) -> bool {
535 let lat = self.observer.latitude_angle();
536 self.declination.radians() < -(Angle::HALF_PI - lat).radians()
537 }
538
539 pub fn to_cirs(&self, delta_t: f64) -> CoordResult<crate::frames::CIRSPosition> {
540 use celestial_time::scales::conversions::ToUT1WithDeltaT;
541 use celestial_time::sidereal::GAST;
542
543 let ut1 = self.epoch.to_ut1_with_delta_t(delta_t)?;
544 let gast = GAST::from_ut1_and_tt(&ut1, &self.epoch)?;
545 let last = gast.to_last(&self.observer);
546
547 let ra_rad = last.radians() - self.hour_angle.radians();
548 let ra = celestial_core::angle::wrap_0_2pi(ra_rad);
549
550 let mut cirs = crate::frames::CIRSPosition::new(
551 Angle::from_radians(ra),
552 self.declination,
553 self.epoch,
554 )?;
555
556 if let Some(distance) = self.distance {
557 cirs.set_distance(distance);
558 }
559
560 Ok(cirs)
561 }
562}
563
564impl std::fmt::Display for TopocentricPosition {
569 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
570 write!(
571 f,
572 "Topocentric(Az={:.2}° {}, El={:.2}°",
573 self.azimuth.degrees(),
574 self.cardinal_direction(),
575 self.elevation.degrees()
576 )?;
577
578 if let Some(distance) = self.distance {
579 write!(f, ", d={}", distance)?;
580 }
581
582 write!(f, ")")
583 }
584}
585
586impl std::fmt::Display for HourAnglePosition {
587 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
588 write!(
589 f,
590 "HourAngle(HA={:.4}h, Dec={:.4}°",
591 self.hour_angle.hours(),
592 self.declination.degrees()
593 )?;
594
595 if let Some(distance) = self.distance {
596 write!(f, ", d={}", distance)?;
597 }
598
599 write!(f, ")")
600 }
601}
602
603#[cfg(test)]
604mod tests {
605 use super::*;
606
607 fn test_observer() -> Location {
608 Location::from_degrees(19.8283, -155.4783, 4145.0).unwrap()
610 }
611
612 #[test]
613 fn test_topocentric_creation() {
614 let observer = test_observer();
615 let epoch = TT::j2000();
616
617 let topo = TopocentricPosition::from_degrees(180.0, 45.0, observer, epoch).unwrap();
618 assert!((topo.azimuth().degrees() - 180.0).abs() < 1e-12);
619 assert!((topo.elevation().degrees() - 45.0).abs() < 1e-12);
620 assert_eq!(
621 topo.observer().latitude_degrees(),
622 observer.latitude_degrees()
623 );
624 assert_eq!(topo.epoch(), epoch);
625 }
626
627 #[test]
628 fn test_topocentric_validation() {
629 let observer = test_observer();
630 let epoch = TT::j2000();
631
632 assert!(TopocentricPosition::from_degrees(0.0, 0.0, observer, epoch).is_ok());
634 assert!(TopocentricPosition::from_degrees(359.999, 89.999, observer, epoch).is_ok());
635
636 let topo = TopocentricPosition::from_degrees(380.0, 45.0, observer, epoch).unwrap();
638 assert!((topo.azimuth().degrees() - 20.0).abs() < 1e-12);
639
640 assert!(TopocentricPosition::from_degrees(0.0, 95.0, observer, epoch).is_err());
642 }
643
644 #[test]
645 fn test_zenith_and_air_mass() {
646 let observer = test_observer();
647 let epoch = TT::j2000();
648
649 let zenith = TopocentricPosition::from_degrees(0.0, 90.0, observer, epoch).unwrap();
651 assert!((zenith.zenith_angle().degrees() - 0.0).abs() < 1e-12);
652 assert!((zenith.air_mass() - 1.0).abs() < 0.001);
653
654 let high = TopocentricPosition::from_degrees(0.0, 60.0, observer, epoch).unwrap();
656 assert!((high.zenith_angle().degrees() - 30.0).abs() < 1e-12);
657 assert!((high.air_mass() - 1.154).abs() < 0.01);
659
660 let horizon = TopocentricPosition::from_degrees(0.0, 0.0, observer, epoch).unwrap();
662 assert!((horizon.air_mass() - 40.0).abs() < 0.1);
663
664 let below = TopocentricPosition::from_degrees(0.0, -10.0, observer, epoch).unwrap();
666 assert_eq!(below.air_mass(), 40.0);
667 }
668
669 #[test]
670 fn test_position_classification() {
671 let observer = test_observer();
672 let epoch = TT::j2000();
673
674 let above = TopocentricPosition::from_degrees(0.0, 45.0, observer, epoch).unwrap();
675 assert!(above.is_above_horizon());
676 assert!(!above.is_near_zenith());
677 assert!(!above.is_near_horizon());
678
679 let below = TopocentricPosition::from_degrees(0.0, -5.0, observer, epoch).unwrap();
680 assert!(!below.is_above_horizon());
681
682 let zenith = TopocentricPosition::from_degrees(0.0, 89.5, observer, epoch).unwrap();
683 assert!(zenith.is_near_zenith());
684
685 let horizon = TopocentricPosition::from_degrees(0.0, 5.0, observer, epoch).unwrap();
686 assert!(horizon.is_near_horizon());
687 }
688
689 #[test]
690 fn test_cardinal_directions() {
691 let observer = test_observer();
692 let epoch = TT::j2000();
693
694 let north = TopocentricPosition::from_degrees(0.0, 45.0, observer, epoch).unwrap();
695 assert_eq!(north.cardinal_direction(), "N");
696
697 let east = TopocentricPosition::from_degrees(90.0, 45.0, observer, epoch).unwrap();
698 assert_eq!(east.cardinal_direction(), "E");
699
700 let south = TopocentricPosition::from_degrees(180.0, 45.0, observer, epoch).unwrap();
701 assert_eq!(south.cardinal_direction(), "S");
702
703 let west = TopocentricPosition::from_degrees(270.0, 45.0, observer, epoch).unwrap();
704 assert_eq!(west.cardinal_direction(), "W");
705
706 let northeast = TopocentricPosition::from_degrees(45.0, 45.0, observer, epoch).unwrap();
707 assert_eq!(northeast.cardinal_direction(), "NE");
708 }
709
710 #[test]
711 fn test_hour_angle_creation() {
712 let observer = test_observer();
713 let epoch = TT::j2000();
714
715 let ha_pos = HourAnglePosition::new(
716 Angle::from_hours(2.0),
717 Angle::from_degrees(45.0),
718 observer,
719 epoch,
720 )
721 .unwrap();
722
723 assert!((ha_pos.hour_angle().hours() - 2.0).abs() < 1e-12);
724 assert!((ha_pos.declination().degrees() - 45.0).abs() < 1e-12);
725 }
726
727 #[test]
728 fn test_hour_angle_to_topocentric() {
729 let observer = test_observer();
730 let epoch = TT::j2000();
731
732 let ha_pos = HourAnglePosition::new(
734 Angle::ZERO, Angle::from_degrees(45.0),
736 observer,
737 epoch,
738 )
739 .unwrap();
740
741 let topo = ha_pos.to_topocentric().unwrap();
742
743 assert!(topo.is_above_horizon());
746 }
747
748 #[test]
749 fn test_circumpolar() {
750 let observer = test_observer(); let epoch = TT::j2000();
752
753 let high_dec =
755 HourAnglePosition::new(Angle::ZERO, Angle::from_degrees(85.0), observer, epoch)
756 .unwrap();
757 assert!(high_dec.is_circumpolar());
758
759 let low_dec =
761 HourAnglePosition::new(Angle::ZERO, Angle::from_degrees(0.0), observer, epoch).unwrap();
762 assert!(!low_dec.is_circumpolar());
763
764 let neg_dec =
766 HourAnglePosition::new(Angle::ZERO, Angle::from_degrees(-85.0), observer, epoch)
767 .unwrap();
768 assert!(neg_dec.never_rises());
769 }
770
771 #[test]
772 fn test_with_distance() {
773 let observer = test_observer();
774 let epoch = TT::j2000();
775 let distance = Distance::from_kilometers(384400.0).unwrap(); let topo = TopocentricPosition::with_distance(
778 Angle::from_degrees(180.0),
779 Angle::from_degrees(45.0),
780 observer,
781 epoch,
782 distance,
783 )
784 .unwrap();
785
786 assert_eq!(topo.distance().unwrap().kilometers(), distance.kilometers());
787
788 let ha_pos = HourAnglePosition::with_distance(
789 Angle::from_hours(1.0),
790 Angle::from_degrees(30.0),
791 observer,
792 epoch,
793 distance,
794 )
795 .unwrap();
796
797 assert_eq!(
798 ha_pos.distance().unwrap().kilometers(),
799 distance.kilometers()
800 );
801 }
802
803 #[test]
804 fn test_topocentric_set_distance() {
805 let observer = test_observer();
806 let epoch = TT::j2000();
807 let mut topo = TopocentricPosition::from_degrees(180.0, 45.0, observer, epoch).unwrap();
808
809 assert!(topo.distance().is_none());
810
811 let distance = Distance::from_kilometers(1000.0).unwrap();
812 topo.set_distance(distance);
813
814 assert!((topo.distance().unwrap().kilometers() - 1000.0).abs() < 1e-6);
815 }
816
817 #[test]
818 fn test_cardinal_directions_all() {
819 let observer = test_observer();
820 let epoch = TT::j2000();
821
822 let directions = [
824 (0.0, "N"),
825 (45.0, "NE"),
826 (90.0, "E"),
827 (135.0, "SE"),
828 (180.0, "S"),
829 (225.0, "SW"),
830 (270.0, "W"),
831 (315.0, "NW"),
832 ];
833
834 for (az, expected) in directions {
835 let topo = TopocentricPosition::from_degrees(az, 45.0, observer, epoch).unwrap();
836 assert_eq!(
837 topo.cardinal_direction(),
838 expected,
839 "Failed for azimuth {}°",
840 az
841 );
842 }
843 }
844
845 #[test]
846 fn test_parallactic_angle() {
847 let observer = test_observer();
848 let epoch = TT::j2000();
849 let topo = TopocentricPosition::from_degrees(180.0, 45.0, observer, epoch).unwrap();
850
851 let ha = Angle::from_hours(1.0);
853 let dec = Angle::from_degrees(45.0);
854 let pa = topo.parallactic_angle(ha, dec);
855
856 assert!(pa.radians().is_finite());
858 }
859
860 #[test]
861 fn test_hour_angle_getters() {
862 let observer = test_observer();
863 let epoch = TT::j2000();
864 let ha_pos = HourAnglePosition::new(
865 Angle::from_hours(2.0),
866 Angle::from_degrees(45.0),
867 observer,
868 epoch,
869 )
870 .unwrap();
871
872 assert_eq!(
873 ha_pos.observer().latitude_degrees(),
874 observer.latitude_degrees()
875 );
876 assert_eq!(ha_pos.epoch(), epoch);
877 assert!(ha_pos.distance().is_none());
878 }
879
880 #[test]
881 fn test_hour_angle_set_distance() {
882 let observer = test_observer();
883 let epoch = TT::j2000();
884 let mut ha_pos = HourAnglePosition::new(
885 Angle::from_hours(2.0),
886 Angle::from_degrees(45.0),
887 observer,
888 epoch,
889 )
890 .unwrap();
891
892 let distance = Distance::from_kilometers(500.0).unwrap();
893 ha_pos.set_distance(distance);
894 assert!((ha_pos.distance().unwrap().kilometers() - 500.0).abs() < 1e-6);
895 }
896
897 #[test]
898 fn test_hour_angle_with_distance_to_topocentric() {
899 let observer = test_observer();
900 let epoch = TT::j2000();
901 let distance = Distance::from_kilometers(1000.0).unwrap();
902
903 let ha_pos = HourAnglePosition::with_distance(
904 Angle::ZERO,
905 Angle::from_degrees(45.0),
906 observer,
907 epoch,
908 distance,
909 )
910 .unwrap();
911
912 let topo = ha_pos.to_topocentric().unwrap();
913 assert!((topo.distance().unwrap().kilometers() - 1000.0).abs() < 1e-6);
914 }
915
916 #[test]
917 fn test_display_formatting() {
918 let observer = test_observer();
919 let epoch = TT::j2000();
920
921 let topo = TopocentricPosition::from_degrees(180.0, 45.0, observer, epoch).unwrap();
923 let display = format!("{}", topo);
924 assert!(display.contains("Topocentric"));
925 assert!(display.contains("180.00°"));
926 assert!(display.contains("45.00°"));
927 assert!(display.contains("S")); let distance = Distance::from_kilometers(1000.0).unwrap();
931 let topo_dist = TopocentricPosition::with_distance(
932 Angle::from_degrees(180.0),
933 Angle::from_degrees(45.0),
934 observer,
935 epoch,
936 distance,
937 )
938 .unwrap();
939 let display_dist = format!("{}", topo_dist);
940 assert!(display_dist.contains("AU") || display_dist.contains("pc")); let ha = HourAnglePosition::new(
944 Angle::from_hours(2.0),
945 Angle::from_degrees(45.0),
946 observer,
947 epoch,
948 )
949 .unwrap();
950 let ha_display = format!("{}", ha);
951 assert!(ha_display.contains("HourAngle"));
952 assert!(ha_display.contains("2."));
953 assert!(ha_display.contains("45."));
954
955 let ha_dist = HourAnglePosition::with_distance(
957 Angle::from_hours(2.0),
958 Angle::from_degrees(45.0),
959 observer,
960 epoch,
961 distance,
962 )
963 .unwrap();
964 let ha_display_dist = format!("{}", ha_dist);
965 assert!(ha_display_dist.contains("AU") || ha_display_dist.contains("pc"));
966 }
968
969 #[test]
970 fn test_air_mass_formulas_at_zenith() {
971 let observer = test_observer();
972 let epoch = TT::j2000();
973 let zenith = TopocentricPosition::from_degrees(0.0, 90.0, observer, epoch).unwrap();
974
975 let rozenberg = zenith.air_mass_rozenberg();
976 let pickering = zenith.air_mass_pickering();
977 let kasten = zenith.air_mass_kasten_young();
978
979 assert!((rozenberg - 1.0).abs() < 0.001);
981 assert!((pickering - 1.0).abs() < 0.001);
982 assert!((kasten - 1.0).abs() < 0.001);
983 }
984
985 #[test]
986 fn test_air_mass_formulas_moderate_angles() {
987 let observer = test_observer();
988 let epoch = TT::j2000();
989
990 let pos_30 = TopocentricPosition::from_degrees(0.0, 60.0, observer, epoch).unwrap();
992 let roz_30 = pos_30.air_mass_rozenberg();
993 let pick_30 = pos_30.air_mass_pickering();
994 let ky_30 = pos_30.air_mass_kasten_young();
995
996 assert!((roz_30 - 1.155).abs() < 0.01);
999 assert!((pick_30 - 1.155).abs() < 0.01);
1000 assert!((ky_30 - 1.155).abs() < 0.01);
1001 assert!((roz_30 - pick_30).abs() < 0.02);
1002 assert!((roz_30 - ky_30).abs() < 0.02);
1003
1004 let pos_60 = TopocentricPosition::from_degrees(0.0, 30.0, observer, epoch).unwrap();
1006 let roz_60 = pos_60.air_mass_rozenberg();
1007 let pick_60 = pos_60.air_mass_pickering();
1008 let ky_60 = pos_60.air_mass_kasten_young();
1009
1010 assert!((roz_60 - 2.0).abs() < 0.05);
1012 assert!((pick_60 - 2.0).abs() < 0.05);
1013 assert!((ky_60 - 2.0).abs() < 0.05);
1014 assert!((roz_60 - pick_60).abs() < 0.1);
1015 }
1016
1017 #[test]
1018 fn test_air_mass_formulas_high_zenith_angles() {
1019 let observer = test_observer();
1020 let epoch = TT::j2000();
1021
1022 let pos_75 = TopocentricPosition::from_degrees(0.0, 15.0, observer, epoch).unwrap();
1024 let roz_75 = pos_75.air_mass_rozenberg();
1025 let pick_75 = pos_75.air_mass_pickering();
1026 let ky_75 = pos_75.air_mass_kasten_young();
1027
1028 assert!(roz_75 > 3.5 && roz_75 < 4.5);
1031 assert!(pick_75 > 3.5 && pick_75 < 4.5);
1032 assert!(ky_75 > 3.5 && ky_75 < 4.5);
1033
1034 let pos_85 = TopocentricPosition::from_degrees(0.0, 5.0, observer, epoch).unwrap();
1036 let roz_85 = pos_85.air_mass_rozenberg();
1037 let pick_85 = pos_85.air_mass_pickering();
1038 let ky_85 = pos_85.air_mass_kasten_young();
1039
1040 assert!(roz_85 > 10.0 && roz_85 < 15.0);
1043 assert!(pick_85 > 10.0 && pick_85 < 15.0);
1044 assert!(ky_85 > 10.0 && ky_85 < 15.0);
1045 }
1046
1047 #[test]
1048 fn test_air_mass_formulas_near_horizon() {
1049 let observer = test_observer();
1050 let epoch = TT::j2000();
1051
1052 let horizon = TopocentricPosition::from_degrees(0.0, 0.0, observer, epoch).unwrap();
1054 let roz_hor = horizon.air_mass_rozenberg();
1055 let pick_hor = horizon.air_mass_pickering();
1056 let ky_hor = horizon.air_mass_kasten_young();
1057
1058 assert!((roz_hor - 40.0).abs() < 0.1);
1060
1061 assert!((ky_hor - 38.0).abs() < 1.0);
1063
1064 assert!(pick_hor > 30.0 && pick_hor < 50.0);
1066
1067 let below = TopocentricPosition::from_degrees(0.0, -1.0, observer, epoch).unwrap();
1069 let roz_below = below.air_mass_rozenberg();
1070 let pick_below = below.air_mass_pickering();
1071
1072 assert_eq!(roz_below, 40.0);
1073 assert!(pick_below > 40.0);
1074 }
1075
1076 #[test]
1077 fn test_air_mass_formula_comparison() {
1078 let observer = test_observer();
1081 let epoch = TT::j2000();
1082
1083 let test_elevations = vec![
1084 90.0, 80.0, 70.0, 60.0, 50.0, 40.0, 30.0, 20.0, 10.0, 5.0, 2.0, 0.0,
1085 ];
1086
1087 for elev in test_elevations {
1088 let pos = TopocentricPosition::from_degrees(0.0, elev, observer, epoch).unwrap();
1089 let roz = pos.air_mass_rozenberg();
1090 let pick = pos.air_mass_pickering();
1091 let ky = pos.air_mass_kasten_young();
1092
1093 assert!(
1095 roz >= 0.999,
1096 "Rozenberg air mass < 0.999 at elevation {}",
1097 elev
1098 );
1099 assert!(
1100 pick >= 0.999,
1101 "Pickering air mass < 0.999 at elevation {}",
1102 elev
1103 );
1104 assert!(
1105 ky >= 0.999,
1106 "Kasten-Young air mass < 0.999 at elevation {}",
1107 elev
1108 );
1109
1110 if elev > 30.0 {
1115 let avg = (roz + pick + ky) / 3.0;
1116 assert!(
1117 (roz - avg).abs() / avg < 0.05,
1118 "Rozenberg deviates >5% at elevation {}",
1119 elev
1120 );
1121 assert!(
1122 (pick - avg).abs() / avg < 0.05,
1123 "Pickering deviates >5% at elevation {}",
1124 elev
1125 );
1126 assert!(
1127 (ky - avg).abs() / avg < 0.05,
1128 "Kasten-Young deviates >5% at elevation {}",
1129 elev
1130 );
1131 }
1132 }
1133 }
1134
1135 #[test]
1136 fn test_atmospheric_refraction_standard_conditions() {
1137 let observer = test_observer();
1138 let epoch = TT::j2000();
1139
1140 let pressure = 1013.25;
1142 let temp = 15.0;
1143 let humidity = 0.5;
1144 let wavelength = 0.574;
1145
1146 let zenith = TopocentricPosition::from_degrees(0.0, 90.0, observer, epoch).unwrap();
1148 let ref_zenith = zenith.atmospheric_refraction(pressure, temp, humidity, wavelength);
1149 assert!(ref_zenith.arcseconds().abs() < 0.1);
1150
1151 let pos_45 = TopocentricPosition::from_degrees(0.0, 45.0, observer, epoch).unwrap();
1153 let ref_45 = pos_45.atmospheric_refraction(pressure, temp, humidity, wavelength);
1154 assert!(ref_45.arcseconds() > 50.0 && ref_45.arcseconds() < 70.0);
1156
1157 let pos_10 = TopocentricPosition::from_degrees(0.0, 10.0, observer, epoch).unwrap();
1159 let ref_10 = pos_10.atmospheric_refraction(pressure, temp, humidity, wavelength);
1160 assert!(ref_10.arcminutes() > 4.0 && ref_10.arcminutes() < 7.0);
1162 }
1163
1164 #[test]
1165 fn test_atmospheric_refraction_with_without() {
1166 let observer = test_observer();
1167 let epoch = TT::j2000();
1168
1169 let pressure = 1013.25;
1170 let temp = 15.0;
1171 let humidity = 0.5;
1172 let wavelength = 0.574;
1173
1174 let true_pos = TopocentricPosition::from_degrees(0.0, 45.0, observer, epoch).unwrap();
1176
1177 let apparent = true_pos.with_refraction(pressure, temp, humidity, wavelength);
1179
1180 assert!(apparent.elevation().degrees() > true_pos.elevation().degrees());
1182
1183 let back_to_true = apparent.without_refraction(pressure, temp, humidity, wavelength);
1185
1186 assert!(
1188 (back_to_true.elevation().degrees() - true_pos.elevation().degrees()).abs() < 0.001
1189 );
1190 }
1191
1192 #[test]
1193 fn test_atmospheric_refraction_zero_pressure() {
1194 let observer = test_observer();
1195 let epoch = TT::j2000();
1196
1197 let pos = TopocentricPosition::from_degrees(0.0, 30.0, observer, epoch).unwrap();
1199 let refraction = pos.atmospheric_refraction(0.0, 15.0, 0.5, 0.574);
1200
1201 assert_eq!(refraction.radians(), 0.0);
1202 }
1203
1204 #[test]
1205 fn test_atmospheric_refraction_radio_vs_optical() {
1206 let observer = test_observer();
1207 let epoch = TT::j2000();
1208
1209 let pressure = 1013.25;
1210 let temp = 15.0;
1211 let humidity = 0.5;
1212
1213 let pos = TopocentricPosition::from_degrees(0.0, 30.0, observer, epoch).unwrap();
1214
1215 let optical = pos.atmospheric_refraction(pressure, temp, humidity, 0.574);
1217
1218 let radio = pos.atmospheric_refraction(pressure, temp, humidity, 200.0);
1220
1221 assert!(optical.arcseconds() > 0.0);
1223 assert!(radio.arcseconds() > 0.0);
1224
1225 assert!(optical.arcseconds() > 0.0);
1227 }
1228
1229 #[test]
1230 fn test_diurnal_parallax_moon() {
1231 let observer = test_observer();
1232 let epoch = TT::j2000();
1233
1234 let moon_distance = Distance::from_kilometers(384400.0).unwrap();
1236
1237 let moon_horizon = TopocentricPosition::with_distance(
1239 Angle::from_degrees(180.0),
1240 Angle::from_degrees(0.0),
1241 observer,
1242 epoch,
1243 moon_distance,
1244 )
1245 .unwrap();
1246
1247 let h_parallax = moon_horizon.horizontal_parallax().unwrap();
1249 assert!(h_parallax.degrees() > 0.9 && h_parallax.degrees() < 1.0);
1250 assert!(h_parallax.arcminutes() > 55.0 && h_parallax.arcminutes() < 59.0);
1251
1252 let diurnal = moon_horizon.diurnal_parallax().unwrap();
1254 assert!((diurnal.degrees() - h_parallax.degrees()).abs() < 0.001);
1255
1256 let moon_zenith = TopocentricPosition::with_distance(
1258 Angle::from_degrees(0.0),
1259 Angle::from_degrees(90.0),
1260 observer,
1261 epoch,
1262 moon_distance,
1263 )
1264 .unwrap();
1265
1266 let zenith_parallax = moon_zenith.diurnal_parallax().unwrap();
1267 assert!(zenith_parallax.arcseconds().abs() < 1.0); }
1269
1270 #[test]
1271 fn test_diurnal_parallax_sun() {
1272 let observer = test_observer();
1273 let epoch = TT::j2000();
1274
1275 let sun_distance = Distance::from_au(1.0).unwrap();
1277 let sun_horizon = TopocentricPosition::with_distance(
1278 Angle::from_degrees(90.0),
1279 Angle::from_degrees(0.0),
1280 observer,
1281 epoch,
1282 sun_distance,
1283 )
1284 .unwrap();
1285
1286 let h_parallax = sun_horizon.horizontal_parallax().unwrap();
1288 assert!(h_parallax.arcseconds() > 8.7 && h_parallax.arcseconds() < 8.9);
1289 }
1290
1291 #[test]
1292 fn test_diurnal_parallax_mars_opposition() {
1293 let observer = test_observer();
1294 let epoch = TT::j2000();
1295
1296 let mars_distance = Distance::from_au(0.38).unwrap();
1298 let mars_horizon = TopocentricPosition::with_distance(
1299 Angle::from_degrees(180.0),
1300 Angle::from_degrees(0.0),
1301 observer,
1302 epoch,
1303 mars_distance,
1304 )
1305 .unwrap();
1306
1307 let h_parallax = mars_horizon.horizontal_parallax().unwrap();
1309 assert!(h_parallax.arcseconds() > 22.0 && h_parallax.arcseconds() < 24.0);
1310 }
1311
1312 #[test]
1313 fn test_diurnal_parallax_at_various_elevations() {
1314 let observer = test_observer();
1315 let epoch = TT::j2000();
1316
1317 let moon_distance = Distance::from_kilometers(384400.0).unwrap();
1319
1320 let elevations = vec![0.0, 30.0, 45.0, 60.0, 90.0];
1322
1323 for elev in elevations {
1324 let pos = TopocentricPosition::with_distance(
1325 Angle::from_degrees(0.0),
1326 Angle::from_degrees(elev),
1327 observer,
1328 epoch,
1329 moon_distance,
1330 )
1331 .unwrap();
1332
1333 let parallax = pos.diurnal_parallax().unwrap();
1334
1335 if elev == 90.0 {
1339 assert!(parallax.arcseconds().abs() < 1.0);
1340 } else if elev == 0.0 {
1341 let h_par = pos.horizontal_parallax().unwrap();
1342 assert!((parallax.degrees() - h_par.degrees()).abs() < 0.001);
1343 } else {
1344 let h_par = pos.horizontal_parallax().unwrap();
1346 assert!(parallax.degrees() > 0.0);
1347 assert!(parallax.degrees() < h_par.degrees());
1348 }
1349 }
1350 }
1351
1352 #[test]
1353 fn test_diurnal_parallax_with_without() {
1354 let observer = test_observer();
1355 let epoch = TT::j2000();
1356
1357 let moon_distance = Distance::from_kilometers(384400.0).unwrap();
1359 let geocentric = TopocentricPosition::with_distance(
1360 Angle::from_degrees(180.0),
1361 Angle::from_degrees(45.0),
1362 observer,
1363 epoch,
1364 moon_distance,
1365 )
1366 .unwrap();
1367
1368 let topocentric = geocentric.with_diurnal_parallax();
1370
1371 assert!(topocentric.elevation().degrees() < geocentric.elevation().degrees());
1373
1374 let back_to_geocentric = topocentric.without_diurnal_parallax();
1376
1377 let diff =
1381 (back_to_geocentric.elevation().degrees() - geocentric.elevation().degrees()).abs();
1382 assert!(diff < 0.01, "Roundtrip error: {} degrees", diff);
1383 }
1384
1385 #[test]
1386 fn test_diurnal_parallax_without_distance() {
1387 let observer = test_observer();
1388 let epoch = TT::j2000();
1389
1390 let star_pos = TopocentricPosition::from_degrees(180.0, 45.0, observer, epoch).unwrap();
1392
1393 assert_eq!(star_pos.diurnal_parallax(), None);
1394 assert_eq!(star_pos.horizontal_parallax(), None);
1395
1396 let with_par = star_pos.with_diurnal_parallax();
1398 assert_eq!(
1399 with_par.elevation().degrees(),
1400 star_pos.elevation().degrees()
1401 );
1402
1403 let without_par = star_pos.without_diurnal_parallax();
1404 assert_eq!(
1405 without_par.elevation().degrees(),
1406 star_pos.elevation().degrees()
1407 );
1408 }
1409
1410 #[test]
1411 fn test_diurnal_parallax_formula_verification() {
1412 let observer = test_observer();
1414 let epoch = TT::j2000();
1415
1416 let distance_au = 0.00257; let elevation_deg = 30.0;
1419 let zenith_deg = 90.0 - elevation_deg;
1420
1421 let moon_distance = Distance::from_au(distance_au).unwrap();
1422 let moon_pos = TopocentricPosition::with_distance(
1423 Angle::from_degrees(0.0),
1424 Angle::from_degrees(elevation_deg),
1425 observer,
1426 epoch,
1427 moon_distance,
1428 )
1429 .unwrap();
1430
1431 let ratio = EARTH_RADIUS_AU / distance_au;
1433 let zenith_rad = zenith_deg.to_radians();
1434 let expected_parallax_rad = libm::asin(ratio * libm::sin(zenith_rad));
1435
1436 let calculated_parallax = moon_pos.diurnal_parallax().unwrap();
1438
1439 assert!((calculated_parallax.radians() - expected_parallax_rad).abs() < 1e-10);
1441 }
1442
1443 #[test]
1444 fn test_topocentric_to_hour_angle() {
1445 let observer = test_observer();
1446 let epoch = TT::j2000();
1447
1448 let topo = TopocentricPosition::from_degrees(180.0, 45.0, observer, epoch).unwrap();
1450 let ha = topo.to_hour_angle().unwrap();
1451
1452 assert!(ha.hour_angle().hours().abs() < 0.001);
1454 }
1455
1456 #[test]
1457 fn test_topocentric_hour_angle_roundtrip() {
1458 let observer = test_observer();
1459 let epoch = TT::j2000();
1460
1461 let test_cases = [
1462 (Angle::from_hours(0.0), Angle::from_degrees(45.0)),
1463 (Angle::from_hours(2.0), Angle::from_degrees(30.0)),
1464 (Angle::from_hours(-3.0), Angle::from_degrees(60.0)),
1465 (Angle::from_hours(6.0), Angle::from_degrees(0.0)),
1466 ];
1467
1468 for (ha, dec) in test_cases {
1469 let original = HourAnglePosition::new(ha, dec, observer, epoch).unwrap();
1470
1471 let topo = original.to_topocentric().unwrap();
1472 let recovered = topo.to_hour_angle().unwrap();
1473
1474 let ha_diff_sec = (original.hour_angle().radians() - recovered.hour_angle().radians())
1475 .abs()
1476 * 206265.0;
1477 let dec_diff_arcsec =
1478 (original.declination().radians() - recovered.declination().radians()).abs()
1479 * 206265.0;
1480
1481 assert!(
1482 ha_diff_sec < 0.001,
1483 "Hour angle roundtrip failed for HA={:.2}h, Dec={:.1}°: diff={:.6} arcsec",
1484 ha.hours(),
1485 dec.degrees(),
1486 ha_diff_sec
1487 );
1488 assert!(
1489 dec_diff_arcsec < 0.001,
1490 "Declination roundtrip failed for HA={:.2}h, Dec={:.1}°: diff={:.6} arcsec",
1491 ha.hours(),
1492 dec.degrees(),
1493 dec_diff_arcsec
1494 );
1495 }
1496 }
1497
1498 #[test]
1499 fn test_topocentric_to_hour_angle_distance_preservation() {
1500 let observer = test_observer();
1501 let epoch = TT::j2000();
1502 let distance = Distance::from_kilometers(384400.0).unwrap();
1503
1504 let topo = TopocentricPosition::with_distance(
1505 Angle::from_degrees(90.0),
1506 Angle::from_degrees(30.0),
1507 observer,
1508 epoch,
1509 distance,
1510 )
1511 .unwrap();
1512
1513 let ha = topo.to_hour_angle().unwrap();
1514 assert_eq!(ha.distance().unwrap().kilometers(), distance.kilometers());
1515 }
1516
1517 #[test]
1518 fn test_topocentric_to_hour_angle_cardinal_points() {
1519 let observer = test_observer();
1520 let epoch = TT::j2000();
1521
1522 let east = TopocentricPosition::from_degrees(90.0, 30.0, observer, epoch).unwrap();
1528 let ha_east = east.to_hour_angle().unwrap();
1529 assert!(
1530 ha_east.hour_angle().hours() < 0.0 || ha_east.hour_angle().hours() > 12.0,
1531 "East object should have negative or >12h hour angle, got {}h",
1532 ha_east.hour_angle().hours()
1533 );
1534
1535 let west = TopocentricPosition::from_degrees(270.0, 30.0, observer, epoch).unwrap();
1537 let ha_west = west.to_hour_angle().unwrap();
1538 assert!(
1539 ha_west.hour_angle().hours() > 0.0 && ha_west.hour_angle().hours() < 12.0,
1540 "West object should have positive hour angle, got {}h",
1541 ha_west.hour_angle().hours()
1542 );
1543 }
1544
1545 #[test]
1546 fn test_hour_angle_to_cirs() {
1547 let observer = test_observer();
1548 let epoch = TT::j2000();
1549 let delta_t = 64.0;
1550
1551 let ha = HourAnglePosition::new(
1552 Angle::from_hours(2.0),
1553 Angle::from_degrees(45.0),
1554 observer,
1555 epoch,
1556 )
1557 .unwrap();
1558
1559 let cirs = ha.to_cirs(delta_t).unwrap();
1560
1561 assert!(cirs.ra().degrees() >= 0.0 && cirs.ra().degrees() < 360.0);
1562 assert_eq!(cirs.dec().degrees(), ha.declination().degrees());
1563 }
1564
1565 #[test]
1566 fn test_hour_angle_cirs_roundtrip() {
1567 use crate::CIRSPosition;
1568
1569 let observer = test_observer();
1570 let epoch = TT::j2000();
1571 let delta_t = 64.0;
1572
1573 let original_cirs = CIRSPosition::from_degrees(120.0, 35.0, epoch).unwrap();
1574
1575 let ha = original_cirs.to_hour_angle(&observer, delta_t).unwrap();
1576 let recovered_cirs = ha.to_cirs(delta_t).unwrap();
1577
1578 let ra_diff_arcsec =
1579 (original_cirs.ra().radians() - recovered_cirs.ra().radians()).abs() * 206265.0;
1580 let dec_diff_arcsec =
1581 (original_cirs.dec().radians() - recovered_cirs.dec().radians()).abs() * 206265.0;
1582
1583 assert!(
1584 ra_diff_arcsec < 0.001,
1585 "RA roundtrip failed: diff={:.6} arcsec",
1586 ra_diff_arcsec
1587 );
1588 assert!(
1589 dec_diff_arcsec < 0.001,
1590 "Dec roundtrip failed: diff={:.6} arcsec",
1591 dec_diff_arcsec
1592 );
1593 }
1594
1595 #[test]
1596 fn test_topocentric_to_cirs() {
1597 let observer = test_observer();
1598 let epoch = TT::j2000();
1599 let delta_t = 64.0;
1600
1601 let topo = TopocentricPosition::from_degrees(180.0, 45.0, observer, epoch).unwrap();
1602 let cirs = topo.to_cirs(delta_t).unwrap();
1603
1604 assert!(cirs.ra().degrees() >= 0.0 && cirs.ra().degrees() < 360.0);
1605 assert!(cirs.dec().degrees() >= -90.0 && cirs.dec().degrees() <= 90.0);
1606 }
1607
1608 #[test]
1609 fn test_full_reverse_chain_roundtrip() {
1610 use crate::CIRSPosition;
1611
1612 let observer = test_observer();
1613 let epoch = TT::j2000();
1614 let delta_t = 64.0;
1615
1616 let original_cirs = CIRSPosition::from_degrees(200.0, 40.0, epoch).unwrap();
1617
1618 let ha = original_cirs.to_hour_angle(&observer, delta_t).unwrap();
1619 let topo = ha.to_topocentric().unwrap();
1620 let recovered_ha = topo.to_hour_angle().unwrap();
1621 let recovered_cirs = recovered_ha.to_cirs(delta_t).unwrap();
1622
1623 let ra_diff_arcsec =
1624 (original_cirs.ra().radians() - recovered_cirs.ra().radians()).abs() * 206265.0;
1625 let dec_diff_arcsec =
1626 (original_cirs.dec().radians() - recovered_cirs.dec().radians()).abs() * 206265.0;
1627
1628 assert!(
1629 ra_diff_arcsec < 0.01,
1630 "Full chain RA roundtrip failed: diff={:.6} arcsec",
1631 ra_diff_arcsec
1632 );
1633 assert!(
1634 dec_diff_arcsec < 0.01,
1635 "Full chain Dec roundtrip failed: diff={:.6} arcsec",
1636 dec_diff_arcsec
1637 );
1638 }
1639}