Skip to main content

celestial_coords/frames/
topocentric.rs

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; // 6378.137 km
7
8#[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    /// Computes airmass using Pickering's (2002) empirical formula.
119    ///
120    /// # Valid Range
121    /// - Returns `f64::INFINITY` for elevations ≤ -2° (below horizon)
122    /// - Accurate for elevations > -2° (including astronomical twilight)
123    /// - Values become increasingly unreliable as elevation approaches -2°
124    ///
125    /// # Numerical Stability
126    /// Near the horizon (0° to 5°), results can be very large but remain finite.
127    /// Use this method only if observations extend below the horizon; otherwise
128    /// prefer `air_mass_hardie()` or `air_mass_kasten_young()`.
129    ///
130    /// Reference: Pickering, K. A. (2002). "The Southern Limits of the Ancient Star
131    /// Catalog". DIO 12, 3-27.
132    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        // Apply refraction formula: dZ = A tan(Z) + B tan³(Z)
163        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        // Zero pressure means no refraction
182        if pressure_hpa <= 0.0 {
183            return (0.0, 0.0);
184        }
185
186        // Determine if optical/IR or radio
187        let is_optical = (0.0..100.0).contains(&wavelength_um);
188
189        // Temperature in Kelvin
190        let temp_kelvin = temp_celsius + 273.15;
191
192        let pw = if pressure_hpa > 0.0 {
193            // Saturation pressure (Gill 1982, with pressure correction)
194            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            // Actual water vapor pressure (Crane 1976)
198            rel_humidity * ps / (1.0 - (1.0 - rel_humidity) * ps / pressure_hpa)
199        } else {
200            0.0
201        };
202
203        if is_optical {
204            // Optical/IR refraction (Hohenkerk & Sinclair 1985, IAG 1999)
205            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            // Beta from Stone (1996) with empirical adjustments
211            let beta = 4.4474e-6 * temp_kelvin;
212
213            // Green (1987) Equation 4.31
214            let refa = gamma * (1.0 - beta);
215            let refb = -gamma * (beta - gamma / 2.0);
216
217            (refa, refb)
218        } else {
219            // Radio refraction (Rueger 2002)
220            let gamma = (77.6890e-6 * pressure_hpa - (6.3938e-6 - 0.375463 / temp_kelvin) * pw)
221                / temp_kelvin;
222
223            // Beta with humidity correction for radio
224            let mut beta = 4.4474e-6 * temp_kelvin;
225            beta -= 0.0074 * pw * beta;
226
227            // Green (1987) Equation 4.31
228            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        // Refraction increases apparent elevation (decreases zenith distance)
246        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        // Remove refraction to get true elevation
268        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            // Earth's equatorial radius in AU
282            let distance_au = d.au();
283            let zenith_angle = self.zenith_angle();
284
285            // Parallax formula: p = arcsin((a/r) × sin(z))
286            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                // Object inside Earth - return maximum parallax
291                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            // Parallax correction decreases elevation (object appears lower)
314            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            // Remove parallax correction (increases elevation)
331            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(); // [-180°, +180°]
442        let declination = declination.validate_declination(true)?; // beyond_pole for GEM pier-flips
443
444        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        // Step 1: Pre-compute trigonometric functions
491        let (sin_ha, cos_ha) = self.hour_angle.sin_cos(); // sh, ch
492        let (sin_dec, cos_dec) = self.declination.sin_cos(); // sd, cd
493        let (sin_lat, cos_lat) = self.observer.latitude_angle().sin_cos(); // sp, cp
494
495        // Step 2: Compute 3D unit vector in Az/El system
496        // This implements the rotation matrix transformation
497        let x = -cos_ha * cos_dec * sin_lat + sin_dec * cos_lat; // X component
498        let y = -sin_ha * cos_dec; // Y component
499        let z = cos_ha * cos_dec * cos_lat + sin_dec * sin_lat; // Z component
500
501        // Step 3: Convert to spherical coordinates
502        let r = libm::sqrt(x * x + y * y); // Horizontal distance
503
504        let raw_azimuth = if r != 0.0 { libm::atan2(y, x) } else { 0.0 }; // Raw azimuth angle
505
506        let azimuth = if raw_azimuth < 0.0 {
507            // Normalize to [0, 2π]
508            raw_azimuth + TWOPI
509        } else {
510            raw_azimuth
511        };
512
513        let elevation = libm::atan2(z, r); // Elevation angle
514
515        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
564// Note: Topocentric coordinates cannot implement CoordinateFrame directly
565// because they require both time AND observer location for transformation.
566// They need specialized transformation methods.
567
568impl 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        // Keck Observatory, Mauna Kea (4145m per keckobservatory.org)
609        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        // Valid coordinates
633        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        // Azimuth gets normalized
637        let topo = TopocentricPosition::from_degrees(380.0, 45.0, observer, epoch).unwrap();
638        assert!((topo.azimuth().degrees() - 20.0).abs() < 1e-12);
639
640        // Invalid elevation
641        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        // Object at zenith - Rozenberg formula
650        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        // Object at 60° elevation (zenith angle = 30°) - Rozenberg
655        let high = TopocentricPosition::from_degrees(0.0, 60.0, observer, epoch).unwrap();
656        assert!((high.zenith_angle().degrees() - 30.0).abs() < 1e-12);
657        // Rozenberg gives slightly different value than simple sec(z)
658        assert!((high.air_mass() - 1.154).abs() < 0.01);
659
660        // Object at horizon - Rozenberg
661        let horizon = TopocentricPosition::from_degrees(0.0, 0.0, observer, epoch).unwrap();
662        assert!((horizon.air_mass() - 40.0).abs() < 0.1);
663
664        // Object below horizon
665        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        // Object on meridian at 45° declination
733        let ha_pos = HourAnglePosition::new(
734            Angle::ZERO, // On meridian
735            Angle::from_degrees(45.0),
736            observer,
737            epoch,
738        )
739        .unwrap();
740
741        let topo = ha_pos.to_topocentric().unwrap();
742
743        // On meridian should be due south (or north depending on observer latitude)
744        // and elevation should be related to observer latitude and declination
745        assert!(topo.is_above_horizon());
746    }
747
748    #[test]
749    fn test_circumpolar() {
750        let observer = test_observer(); // Latitude ~20°N
751        let epoch = TT::j2000();
752
753        // Very high declination object (near north pole)
754        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        // Low declination object
760        let low_dec =
761            HourAnglePosition::new(Angle::ZERO, Angle::from_degrees(0.0), observer, epoch).unwrap();
762        assert!(!low_dec.is_circumpolar());
763
764        // Very negative declination
765        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(); // Moon distance
776
777        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        // Test all 8 cardinal directions
823        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        // Test parallactic angle calculation
852        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        // Should return a valid angle
857        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        // Test TopocentricPosition display
922        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")); // Cardinal direction
928
929        // Test with distance
930        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")); // Distance shown
941
942        // Test HourAnglePosition display
943        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        // Test HourAngle with distance
956        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        // Distance shown
967    }
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        // All formulas should give ~1.0 at zenith
980        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        // Test at 30° zenith angle (60° elevation)
991        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        // Simple sec(30°) = 1.1547
997        // All formulas should be within 1% of each other at moderate angles
998        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        // Test at 60° zenith angle (30° elevation)
1005        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        // Simple sec(60°) = 2.0
1011        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        // Test at 75° zenith angle (15° elevation)
1023        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        // Simple sec(75°) = 3.864
1029        // Formulas may diverge more at high angles
1030        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        // Test at 85° zenith angle (5° elevation)
1035        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        // Simple sec(85°) = 11.47
1041        // All formulas valid to horizon
1042        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        // Test at horizon (0° elevation, 90° zenith)
1053        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        // Rozenberg: horizon air mass = 40
1059        assert!((roz_hor - 40.0).abs() < 0.1);
1060
1061        // Kasten-Young: horizon air mass ~38
1062        assert!((ky_hor - 38.0).abs() < 1.0);
1063
1064        // Pickering: should also be reasonable at horizon
1065        assert!(pick_hor > 30.0 && pick_hor < 50.0);
1066
1067        // Test slightly below horizon
1068        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        // Verify that all three formulas produce reasonable and consistent results
1079        // across the full range of zenith angles
1080        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            // All values should be >= 1.0 (with tolerance for formula approximations at zenith)
1094            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            // Air mass should increase as elevation decreases
1111            // (this is implicitly tested by the monotonic nature of the formulas)
1112
1113            // For high elevations (> 30°), all formulas should agree within 5%
1114            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        // Standard conditions: sea level, 15°C, 50% humidity, optical (0.574 μm)
1141        let pressure = 1013.25;
1142        let temp = 15.0;
1143        let humidity = 0.5;
1144        let wavelength = 0.574;
1145
1146        // Test at zenith (no refraction)
1147        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        // Test at 45° elevation
1152        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        // Typical refraction at 45° elevation ~60 arcsec
1155        assert!(ref_45.arcseconds() > 50.0 && ref_45.arcseconds() < 70.0);
1156
1157        // Test near horizon (10° elevation)
1158        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        // Refraction increases dramatically near horizon, ~5-6 arcmin
1161        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        // Start with true position at 45°
1175        let true_pos = TopocentricPosition::from_degrees(0.0, 45.0, observer, epoch).unwrap();
1176
1177        // Apply refraction to get apparent position
1178        let apparent = true_pos.with_refraction(pressure, temp, humidity, wavelength);
1179
1180        // Apparent elevation should be higher than true
1181        assert!(apparent.elevation().degrees() > true_pos.elevation().degrees());
1182
1183        // Remove refraction to get back to true
1184        let back_to_true = apparent.without_refraction(pressure, temp, humidity, wavelength);
1185
1186        // Should be close to original (within numerical precision)
1187        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        // Zero pressure = no atmosphere = no refraction
1198        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        // Optical wavelength (0.574 μm)
1216        let optical = pos.atmospheric_refraction(pressure, temp, humidity, 0.574);
1217
1218        // Radio wavelength (>100 μm)
1219        let radio = pos.atmospheric_refraction(pressure, temp, humidity, 200.0);
1220
1221        // Both should give positive refraction
1222        assert!(optical.arcseconds() > 0.0);
1223        assert!(radio.arcseconds() > 0.0);
1224
1225        // Radio refraction should be less affected by humidity (simplified model)
1226        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        // Moon at mean distance: 384,400 km ≈ 0.00257 AU
1235        let moon_distance = Distance::from_kilometers(384400.0).unwrap();
1236
1237        // Moon at horizon (maximum parallax)
1238        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        // Horizontal parallax for Moon: ~57 arcmin = 0.95°
1248        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        // At horizon, diurnal parallax = horizontal parallax
1253        let diurnal = moon_horizon.diurnal_parallax().unwrap();
1254        assert!((diurnal.degrees() - h_parallax.degrees()).abs() < 0.001);
1255
1256        // Moon at zenith (zero parallax)
1257        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); // Should be nearly zero
1268    }
1269
1270    #[test]
1271    fn test_diurnal_parallax_sun() {
1272        let observer = test_observer();
1273        let epoch = TT::j2000();
1274
1275        // Sun at 1 AU
1276        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        // Solar horizontal parallax: ~8.794 arcsec
1287        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        // Mars at closest approach: ~0.38 AU
1297        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        // Mars horizontal parallax at opposition: ~23 arcsec
1308        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        // Moon at mean distance
1318        let moon_distance = Distance::from_kilometers(384400.0).unwrap();
1319
1320        // Test at different elevations
1321        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            // Parallax should decrease with increasing elevation
1336            // At zenith (90°), it should be nearly zero
1337            // At horizon (0°), it should equal horizontal parallax
1338            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                // Parallax should be between 0 and horizontal parallax
1345                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        // Moon at 45° elevation
1358        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        // Apply parallax correction
1369        let topocentric = geocentric.with_diurnal_parallax();
1370
1371        // Topocentric elevation should be LOWER than geocentric
1372        assert!(topocentric.elevation().degrees() < geocentric.elevation().degrees());
1373
1374        // Remove parallax to get back to geocentric
1375        let back_to_geocentric = topocentric.without_diurnal_parallax();
1376
1377        // Should match original within tolerance
1378        // (not exact due to zenith angle changing during correction - this is correct physics)
1379        // For Moon at 45° with ~0.9° parallax, expect ~0.01° roundtrip error
1380        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        // Position without distance (star)
1391        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        // with/without should return unchanged
1397        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        // Verify the parallax formula: p = arcsin((R_Earth/r) × sin(z))
1413        let observer = test_observer();
1414        let epoch = TT::j2000();
1415
1416        // Moon at known distance and elevation
1417        let distance_au = 0.00257; // Moon's distance
1418        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        // Calculate expected parallax
1432        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        // Get calculated parallax
1437        let calculated_parallax = moon_pos.diurnal_parallax().unwrap();
1438
1439        // Should match within numerical precision
1440        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        // Test object on meridian at 45° elevation
1449        let topo = TopocentricPosition::from_degrees(180.0, 45.0, observer, epoch).unwrap();
1450        let ha = topo.to_hour_angle().unwrap();
1451
1452        // On meridian (Az=180°), hour angle should be ~0
1453        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        // North (Az=0°): object in northern sky, HA=0 on meridian
1523        // Actually Az=0° is north, but object crosses north meridian at HA=0 only for circumpolar
1524        // Let's test east/west instead
1525
1526        // Due east (Az=90°): object is rising, HA should be negative (before meridian)
1527        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        // Due west (Az=270°): object is setting, HA should be positive
1536        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}