Skip to main content

celestial_coords/frames/
ecliptic.rs

1use crate::{transforms::CoordinateFrame, CoordResult, Distance, ICRSPosition};
2use celestial_core::{matrix::RotationMatrix3, Angle};
3use celestial_time::{transforms::PrecessionCalculator, TT};
4
5#[cfg(feature = "serde")]
6use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, PartialEq)]
9#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
10pub struct EclipticPosition {
11    lambda: Angle,
12    beta: Angle,
13    epoch: TT,
14    distance: Option<Distance>,
15}
16
17impl EclipticPosition {
18    pub fn new(lambda: Angle, beta: Angle, epoch: TT) -> CoordResult<Self> {
19        let lambda = lambda.validate_longitude(true)?;
20        let beta = beta.validate_latitude()?;
21
22        Ok(Self {
23            lambda,
24            beta,
25            epoch,
26            distance: None,
27        })
28    }
29
30    pub fn with_distance(
31        lambda: Angle,
32        beta: Angle,
33        epoch: TT,
34        distance: Distance,
35    ) -> CoordResult<Self> {
36        let mut pos = Self::new(lambda, beta, epoch)?;
37        pos.distance = Some(distance);
38        Ok(pos)
39    }
40
41    pub fn from_degrees(lambda_deg: f64, beta_deg: f64, epoch: TT) -> CoordResult<Self> {
42        Self::new(
43            Angle::from_degrees(lambda_deg),
44            Angle::from_degrees(beta_deg),
45            epoch,
46        )
47    }
48
49    pub fn lambda(&self) -> Angle {
50        self.lambda
51    }
52
53    pub fn beta(&self) -> Angle {
54        self.beta
55    }
56
57    pub fn epoch(&self) -> TT {
58        self.epoch
59    }
60
61    pub fn distance(&self) -> Option<Distance> {
62        self.distance
63    }
64
65    pub fn set_distance(&mut self, distance: Distance) {
66        self.distance = Some(distance);
67    }
68
69    pub fn mean_obliquity(&self) -> Angle {
70        let jd = self.epoch.to_julian_date();
71        Angle::from_radians(celestial_core::obliquity::iau_2006_mean_obliquity(
72            jd.jd1(),
73            jd.jd2(),
74        ))
75    }
76
77    pub fn true_obliquity(&self) -> CoordResult<Angle> {
78        use celestial_time::transforms::nutation::NutationCalculator;
79
80        let nutation =
81            self.epoch
82                .nutation_iau2006a()
83                .map_err(|e| crate::CoordError::CoreError {
84                    message: format!("Nutation calculation failed: {}", e),
85                })?;
86
87        let jd = self.epoch.to_julian_date();
88        let mean_obliquity = celestial_core::obliquity::iau_2006_mean_obliquity(jd.jd1(), jd.jd2());
89
90        let true_obliquity = mean_obliquity + nutation.nutation_obliquity();
91
92        Ok(Angle::from_radians(true_obliquity))
93    }
94
95    pub fn vernal_equinox(epoch: TT) -> Self {
96        Self {
97            lambda: Angle::ZERO,
98            beta: Angle::ZERO,
99            epoch,
100            distance: None,
101        }
102    }
103
104    pub fn summer_solstice(epoch: TT) -> Self {
105        Self {
106            lambda: Angle::HALF_PI,
107            beta: Angle::ZERO,
108            epoch,
109            distance: None,
110        }
111    }
112
113    pub fn autumnal_equinox(epoch: TT) -> Self {
114        Self {
115            lambda: Angle::PI,
116            beta: Angle::ZERO,
117            epoch,
118            distance: None,
119        }
120    }
121
122    pub fn winter_solstice(epoch: TT) -> Self {
123        Self {
124            lambda: Angle::from_degrees(270.0),
125            beta: Angle::ZERO,
126            epoch,
127            distance: None,
128        }
129    }
130
131    pub fn north_ecliptic_pole(epoch: TT) -> Self {
132        Self {
133            lambda: Angle::ZERO,
134            beta: Angle::HALF_PI,
135            epoch,
136            distance: None,
137        }
138    }
139
140    pub fn south_ecliptic_pole(epoch: TT) -> Self {
141        Self {
142            lambda: Angle::ZERO,
143            beta: -Angle::HALF_PI,
144            epoch,
145            distance: None,
146        }
147    }
148
149    pub fn is_near_ecliptic_plane(&self) -> bool {
150        self.beta.abs().degrees() < 5.0
151    }
152
153    pub fn is_near_ecliptic_pole(&self) -> bool {
154        self.beta.abs().degrees() > 85.0
155    }
156
157    pub fn season_index(&self) -> u8 {
158        let lambda_deg = self.lambda.degrees();
159        if lambda_deg < 90.0 {
160            0
161        } else if lambda_deg < 180.0 {
162            1
163        } else if lambda_deg < 270.0 {
164            2
165        } else {
166            3
167        }
168    }
169
170    pub fn angular_separation(&self, other: &Self) -> Angle {
171        let (sin_b1, cos_b1) = self.beta.sin_cos();
172        let (sin_b2, cos_b2) = other.beta.sin_cos();
173        let delta_lambda = (self.lambda - other.lambda).radians();
174
175        let angle_rad = celestial_core::math::vincenty_angular_separation(
176            sin_b1,
177            cos_b1,
178            sin_b2,
179            cos_b2,
180            delta_lambda,
181        );
182
183        Angle::from_radians(angle_rad)
184    }
185}
186
187fn ecm06_matrix(epoch: &TT) -> CoordResult<RotationMatrix3> {
188    let precession = epoch.precession()?;
189    let bias_precession_matrix = precession.bias_precession_matrix;
190
191    let jd = epoch.to_julian_date();
192    let mean_obliquity = celestial_core::obliquity::iau_2006_mean_obliquity(jd.jd1(), jd.jd2());
193
194    let mut ecliptic_rotation = RotationMatrix3::identity();
195    ecliptic_rotation.rotate_x(mean_obliquity);
196
197    Ok(ecliptic_rotation.multiply(&bias_precession_matrix))
198}
199
200impl CoordinateFrame for EclipticPosition {
201    fn to_icrs(&self, epoch: &TT) -> CoordResult<ICRSPosition> {
202        let lambda = self.lambda.radians();
203        let beta = self.beta.radians();
204
205        let (sin_beta, cos_beta) = libm::sincos(beta);
206        let (sin_lambda, cos_lambda) = libm::sincos(lambda);
207
208        let ecliptic_cartesian = [cos_beta * cos_lambda, cos_beta * sin_lambda, sin_beta];
209
210        let icrs_to_ecliptic_matrix = ecm06_matrix(epoch)?;
211        let icrs_cartesian = icrs_to_ecliptic_matrix
212            .transpose()
213            .apply_to_vector(ecliptic_cartesian);
214
215        let x = icrs_cartesian[0];
216        let y = icrs_cartesian[1];
217        let z = icrs_cartesian[2];
218
219        let rxy2 = x * x + y * y;
220        let ra = if rxy2 == 0.0 { 0.0 } else { libm::atan2(y, x) };
221        let dec = if z == 0.0 {
222            0.0
223        } else {
224            libm::atan2(z, libm::sqrt(rxy2))
225        };
226
227        let d2pi = celestial_core::constants::TWOPI;
228        let mut ra_normalized = ra % d2pi;
229        if ra_normalized < 0.0 {
230            ra_normalized += d2pi;
231        }
232
233        let dpi = celestial_core::constants::PI;
234        let mut dec_normalized = dec % d2pi;
235        if dec_normalized.abs() >= dpi {
236            dec_normalized -= libm::copysign(d2pi, dec);
237        }
238
239        let mut icrs = ICRSPosition::new(
240            Angle::from_radians(ra_normalized),
241            Angle::from_radians(dec_normalized),
242        )?;
243
244        if let Some(distance) = self.distance {
245            icrs.set_distance(distance);
246        }
247
248        Ok(icrs)
249    }
250
251    fn from_icrs(icrs: &ICRSPosition, epoch: &TT) -> CoordResult<Self> {
252        let ra = icrs.ra().radians();
253        let dec = icrs.dec().radians();
254
255        let (sin_dec, cos_dec) = libm::sincos(dec);
256        let (sin_ra, cos_ra) = libm::sincos(ra);
257
258        let icrs_cartesian = [cos_dec * cos_ra, cos_dec * sin_ra, sin_dec];
259
260        let icrs_to_ecliptic_matrix = ecm06_matrix(epoch)?;
261        let ecliptic_cartesian = icrs_to_ecliptic_matrix.apply_to_vector(icrs_cartesian);
262
263        let x = ecliptic_cartesian[0];
264        let y = ecliptic_cartesian[1];
265        let z = ecliptic_cartesian[2];
266
267        let rxy2 = x * x + y * y;
268        let lambda = if rxy2 != 0.0 { libm::atan2(y, x) } else { 0.0 };
269        let beta = if rxy2 != 0.0 || z != 0.0 {
270            libm::atan2(z, libm::sqrt(rxy2))
271        } else {
272            0.0
273        };
274
275        let d2pi = celestial_core::constants::TWOPI;
276        let mut lambda_normalized = lambda % d2pi;
277        if lambda_normalized < 0.0 {
278            lambda_normalized += d2pi;
279        }
280
281        let mut ecliptic = Self::new(
282            Angle::from_radians(lambda_normalized),
283            Angle::from_radians(beta),
284            *epoch,
285        )?;
286
287        if let Some(distance) = icrs.distance() {
288            ecliptic.set_distance(distance);
289        }
290
291        Ok(ecliptic)
292    }
293}
294
295impl std::fmt::Display for EclipticPosition {
296    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
297        write!(
298            f,
299            "Ecliptic(λ={:.6}°, β={:.6}°, epoch=J{:.1}",
300            self.lambda.degrees(),
301            self.beta.degrees(),
302            self.epoch.julian_year()
303        )?;
304
305        if let Some(distance) = self.distance {
306            write!(f, ", d={}", distance)?;
307        }
308
309        write!(f, ")")
310    }
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316    use crate::Distance;
317
318    mod erfa_reference {
319        use super::*;
320
321        #[test]
322        fn test_obliquity_at_j2000() {
323            let epoch = TT::j2000();
324            let pos = EclipticPosition::from_degrees(0.0, 0.0, epoch).unwrap();
325            let mean_obliquity = pos.mean_obliquity();
326            assert_eq!(mean_obliquity.radians(), 4.09092600600582889658e-01);
327        }
328
329        #[test]
330        fn test_ecm06_matrix_at_j2000() {
331            let epoch = TT::j2000();
332            let matrix = ecm06_matrix(&epoch).unwrap();
333            let m = matrix.elements();
334
335            assert_eq!(m[0][0], 9.99999999999994115818e-01);
336            assert_eq!(m[0][1], -7.07836896097155612759e-08);
337            assert_eq!(m[0][2], 8.05621397761318608390e-08);
338            assert_eq!(m[1][0], 3.28970040774196464850e-08);
339            assert_eq!(m[1][1], 9.17482129914958366435e-01);
340            assert_eq!(m[1][2], 3.97776999444047929533e-01);
341            assert_eq!(m[2][0], -1.02070447254843554005e-07);
342            assert_eq!(m[2][1], -3.97776999444043044551e-01);
343            assert_eq!(m[2][2], 9.17482129914955590877e-01);
344        }
345
346        #[test]
347        fn test_north_ecliptic_pole_to_icrs() {
348            let epoch = TT::j2000();
349            let north_pole = EclipticPosition::north_ecliptic_pole(epoch);
350            let icrs = north_pole.to_icrs(&epoch).unwrap();
351
352            assert_eq!(icrs.ra().radians(), 4.71238872378250484019e+00);
353            assert_eq!(icrs.dec().radians(), 1.16170369313486876450e+00);
354        }
355
356        #[test]
357        fn test_south_ecliptic_pole_to_icrs() {
358            let epoch = TT::j2000();
359            let south_pole = EclipticPosition::south_ecliptic_pole(epoch);
360            let icrs = south_pole.to_icrs(&epoch).unwrap();
361
362            assert_eq!(icrs.ra().radians(), 1.57079607019271128010e+00);
363            assert_eq!(icrs.dec().radians(), -1.16170369313486876450e+00);
364        }
365
366        #[test]
367        fn test_roundtrip_decimal_coords() {
368            let epoch = TT::j2000();
369
370            // (123.456789°, 45.678901°)
371            let original = EclipticPosition::from_degrees(123.456789, 45.678901, epoch).unwrap();
372            let icrs = original.to_icrs(&epoch).unwrap();
373            let roundtrip = EclipticPosition::from_icrs(&icrs, &epoch).unwrap();
374
375            assert_eq!(
376                original.lambda().radians(),
377                roundtrip.lambda().radians(),
378                "Lambda mismatch: orig={}, round={}",
379                original.lambda().degrees(),
380                roundtrip.lambda().degrees()
381            );
382        }
383
384        #[test]
385        fn test_roundtrip_negative_beta() {
386            let epoch = TT::j2000();
387
388            // (267.314159°, -23.271828°) roundtrip: ERFA shows lambda diff = 0 exactly
389            let original = EclipticPosition::from_degrees(267.314159, -23.271828, epoch).unwrap();
390            let icrs = original.to_icrs(&epoch).unwrap();
391            let roundtrip = EclipticPosition::from_icrs(&icrs, &epoch).unwrap();
392
393            assert_eq!(
394                original.lambda().radians(),
395                roundtrip.lambda().radians(),
396                "Lambda mismatch: orig={}, round={}",
397                original.lambda().degrees(),
398                roundtrip.lambda().degrees()
399            );
400        }
401
402        #[test]
403        fn test_roundtrip_high_latitude() {
404            let epoch = TT::j2000();
405
406            // (45.123456°, 67.890123°) roundtrip
407            let original = EclipticPosition::from_degrees(45.123456, 67.890123, epoch).unwrap();
408            let icrs = original.to_icrs(&epoch).unwrap();
409            let roundtrip = EclipticPosition::from_icrs(&icrs, &epoch).unwrap();
410
411            // ERFA shows lambda diff = -1.11e-16 rad which wraps to 0
412            // Our implementation should also produce 0 or very close
413            let lambda_diff = (original.lambda().radians() - roundtrip.lambda().radians()).abs();
414            assert!(
415                lambda_diff < 1e-14,
416                "Lambda diff too large: {} rad",
417                lambda_diff
418            );
419        }
420    }
421
422    #[test]
423    fn test_constructor_with_distance() {
424        let epoch = TT::j2000();
425        let distance = Distance::from_parsecs(10.0).unwrap();
426
427        let pos = EclipticPosition::with_distance(
428            Angle::from_degrees(180.0),
429            Angle::from_degrees(45.0),
430            epoch,
431            distance,
432        )
433        .unwrap();
434
435        assert_eq!(pos.lambda().degrees(), 180.0);
436        assert_eq!(pos.beta().degrees(), 45.0);
437        assert_eq!(pos.epoch(), epoch);
438        assert_eq!(pos.distance().unwrap(), distance);
439    }
440
441    #[test]
442    fn test_accessor_methods() {
443        let epoch = TT::j2000();
444        let distance = Distance::from_parsecs(5.0).unwrap();
445
446        let mut pos = EclipticPosition::from_degrees(90.0, -30.0, epoch).unwrap();
447
448        let expected_lambda = Angle::from_degrees(90.0).degrees();
449        let expected_beta = Angle::from_degrees(-30.0).degrees();
450
451        assert_eq!(pos.lambda().degrees(), expected_lambda);
452        assert_eq!(pos.beta().degrees(), expected_beta);
453        assert_eq!(pos.epoch(), epoch);
454        assert_eq!(pos.distance(), None);
455
456        pos.set_distance(distance);
457        assert_eq!(pos.distance().unwrap(), distance);
458    }
459
460    #[test]
461    fn test_obliquity_calculations() {
462        let epoch = TT::j2000();
463        let pos = EclipticPosition::from_degrees(0.0, 0.0, epoch).unwrap();
464
465        let mean_obliquity = pos.mean_obliquity();
466        let true_obliquity = pos.true_obliquity().unwrap();
467
468        // IAU 2006 mean obliquity at J2000.0: 84381.406 arcseconds
469        let expected_mean_obliquity_arcsec = 84381.406;
470        let expected_mean_obliquity_deg = expected_mean_obliquity_arcsec / 3600.0;
471        assert_eq!(mean_obliquity.degrees(), expected_mean_obliquity_deg);
472
473        // True obliquity differs from mean by nutation in obliquity
474        // At J2000.0, nutation is small but non-zero
475        assert_ne!(true_obliquity.radians(), mean_obliquity.radians());
476    }
477
478    #[test]
479    fn test_special_position_constructors() {
480        let epoch = TT::j2000();
481
482        let vernal = EclipticPosition::vernal_equinox(epoch);
483        assert_eq!(vernal.lambda().degrees(), 0.0);
484        assert_eq!(vernal.beta().degrees(), 0.0);
485        assert_eq!(vernal.epoch(), epoch);
486        assert_eq!(vernal.distance(), None);
487
488        let summer = EclipticPosition::summer_solstice(epoch);
489        assert_eq!(summer.lambda().degrees(), 90.0);
490        assert_eq!(summer.beta().degrees(), 0.0);
491
492        let autumn = EclipticPosition::autumnal_equinox(epoch);
493        assert_eq!(autumn.lambda().degrees(), 180.0);
494        assert_eq!(autumn.beta().degrees(), 0.0);
495
496        let winter = EclipticPosition::winter_solstice(epoch);
497        assert_eq!(winter.lambda().degrees(), 270.0);
498        assert_eq!(winter.beta().degrees(), 0.0);
499
500        let north_pole = EclipticPosition::north_ecliptic_pole(epoch);
501        assert_eq!(north_pole.lambda().degrees(), 0.0);
502        assert_eq!(north_pole.beta().degrees(), 90.0);
503
504        let south_pole = EclipticPosition::south_ecliptic_pole(epoch);
505        assert_eq!(south_pole.lambda().degrees(), 0.0);
506        assert_eq!(south_pole.beta().degrees(), -90.0);
507    }
508
509    #[test]
510    fn test_angular_separation() {
511        let epoch = TT::j2000();
512
513        let vernal = EclipticPosition::vernal_equinox(epoch);
514        let summer = EclipticPosition::summer_solstice(epoch);
515        let north_pole = EclipticPosition::north_ecliptic_pole(epoch);
516
517        let sep_vernal_summer = vernal.angular_separation(&summer);
518        assert_eq!(sep_vernal_summer.degrees(), 90.0);
519
520        let sep_pole_vernal = north_pole.angular_separation(&vernal);
521        assert_eq!(sep_pole_vernal.degrees(), 90.0);
522
523        let sep_self = vernal.angular_separation(&vernal);
524        assert_eq!(sep_self.degrees(), 0.0);
525    }
526
527    #[test]
528    fn test_coordinate_transformations_roundtrip() {
529        let epoch = TT::j2000();
530
531        // Test roundtrip coordinate transformations
532        // Lambda diff can be ~1e-15 rad due to numerical paths, but angular separation is ~0
533        let test_cases = [(90.0, 0.0), (180.0, 0.0), (270.0, 0.0)];
534
535        for (lambda_deg, beta_deg) in test_cases {
536            let original = EclipticPosition::from_degrees(lambda_deg, beta_deg, epoch).unwrap();
537
538            let icrs = original.to_icrs(&epoch).unwrap();
539            let roundtrip = EclipticPosition::from_icrs(&icrs, &epoch).unwrap();
540
541            // Verify angular separation is essentially zero
542            // Accounts for ~1e-15 rad lambda drift and ~1e-16 rad beta drift
543            let separation = original.angular_separation(&roundtrip);
544            assert!(
545                separation.radians() < 1e-14,
546                "Separation too large for ({}, {}): {} rad",
547                lambda_deg,
548                beta_deg,
549                separation.radians()
550            );
551        }
552    }
553
554    #[test]
555    fn test_coordinate_transformations_roundtrip_zero_boundary() {
556        // The 0/360 boundary: lambda 0° can become 6.28... due to atan2 returning near-2π
557        // ERFA shows same behavior: lambda roundtrip from 0° goes through RA ~360° back to λ ~360°
558        let epoch = TT::j2000();
559
560        let original = EclipticPosition::from_degrees(0.0, 0.0, epoch).unwrap();
561        let icrs = original.to_icrs(&epoch).unwrap();
562        let roundtrip = EclipticPosition::from_icrs(&icrs, &epoch).unwrap();
563
564        // Angular separation should be essentially zero even if lambda differs by ~2π
565        // ERFA shows wrapped lambda diff = 0
566        let separation = original.angular_separation(&roundtrip);
567        assert!(
568            separation.radians() < 1e-14,
569            "Angular separation too large: {} rad",
570            separation.radians()
571        );
572    }
573
574    #[test]
575    fn test_coordinate_transformations_with_distance() {
576        let epoch = TT::j2000();
577        let distance = Distance::from_parsecs(10.0).unwrap();
578
579        let original = EclipticPosition::with_distance(
580            Angle::from_degrees(45.0),
581            Angle::from_degrees(30.0),
582            epoch,
583            distance,
584        )
585        .unwrap();
586
587        let icrs = original.to_icrs(&epoch).unwrap();
588        assert_eq!(icrs.distance().unwrap(), distance);
589
590        let roundtrip = EclipticPosition::from_icrs(&icrs, &epoch).unwrap();
591        assert_eq!(roundtrip.distance().unwrap(), distance);
592    }
593
594    #[test]
595    fn test_display_formatting() {
596        let epoch = TT::j2000();
597        let distance = Distance::from_parsecs(5.0).unwrap();
598
599        let pos_no_dist = EclipticPosition::from_degrees(45.123456, -30.987654, epoch).unwrap();
600        let display_no_dist = format!("{}", pos_no_dist);
601        assert!(display_no_dist.contains("λ=45.123456°"));
602        assert!(display_no_dist.contains("β=-30.987654°"));
603        assert!(display_no_dist.contains("epoch=J2000.0"));
604        assert!(!display_no_dist.contains("d="));
605
606        let mut pos_with_dist = pos_no_dist.clone();
607        pos_with_dist.set_distance(distance);
608        let display_with_dist = format!("{}", pos_with_dist);
609        assert!(display_with_dist.contains("λ=45.123456°"));
610        assert!(display_with_dist.contains("β=-30.987654°"));
611        assert!(display_with_dist.contains("epoch=J2000.0"));
612        assert!(display_with_dist.contains("d=5"));
613    }
614
615    #[test]
616    fn test_seasonal_classification() {
617        let epoch = TT::j2000();
618
619        let spring = EclipticPosition::from_degrees(45.0, 0.0, epoch).unwrap();
620        assert_eq!(spring.season_index(), 0);
621
622        let summer = EclipticPosition::from_degrees(135.0, 0.0, epoch).unwrap();
623        assert_eq!(summer.season_index(), 1);
624
625        let autumn = EclipticPosition::from_degrees(225.0, 0.0, epoch).unwrap();
626        assert_eq!(autumn.season_index(), 2);
627
628        let winter = EclipticPosition::from_degrees(315.0, 0.0, epoch).unwrap();
629        assert_eq!(winter.season_index(), 3);
630    }
631
632    #[test]
633    fn test_ecliptic_plane_classification() {
634        let epoch = TT::j2000();
635
636        let on_plane = EclipticPosition::from_degrees(45.0, 2.0, epoch).unwrap();
637        assert!(on_plane.is_near_ecliptic_plane());
638        assert!(!on_plane.is_near_ecliptic_pole());
639
640        let off_plane = EclipticPosition::from_degrees(45.0, 45.0, epoch).unwrap();
641        assert!(!off_plane.is_near_ecliptic_plane());
642
643        let near_pole = EclipticPosition::from_degrees(0.0, 87.0, epoch).unwrap();
644        assert!(near_pole.is_near_ecliptic_pole());
645    }
646
647    #[test]
648    fn test_coordinate_edge_cases() {
649        let epoch = TT::j2000();
650
651        let wrapped_lambda = EclipticPosition::from_degrees(370.0, 0.0, epoch).unwrap();
652        let expected_wrapped = Angle::from_degrees(370.0)
653            .validate_longitude(true)
654            .unwrap()
655            .degrees();
656        assert_eq!(wrapped_lambda.lambda().degrees(), expected_wrapped);
657
658        let negative_lambda = EclipticPosition::from_degrees(-90.0, 0.0, epoch).unwrap();
659        let expected_negative = Angle::from_degrees(-90.0)
660            .validate_longitude(true)
661            .unwrap()
662            .degrees();
663        assert_eq!(negative_lambda.lambda().degrees(), expected_negative);
664
665        assert!(EclipticPosition::from_degrees(0.0, 95.0, epoch).is_err());
666        assert!(EclipticPosition::from_degrees(0.0, -95.0, epoch).is_err());
667
668        let max_beta = EclipticPosition::from_degrees(0.0, 90.0, epoch).unwrap();
669        assert_eq!(max_beta.beta().degrees(), 90.0);
670
671        let min_beta = EclipticPosition::from_degrees(0.0, -90.0, epoch).unwrap();
672        assert_eq!(min_beta.beta().degrees(), -90.0);
673    }
674
675    #[test]
676    fn test_pole_angular_separation_edge_cases() {
677        let epoch = TT::j2000();
678
679        let north_pole = EclipticPosition::north_ecliptic_pole(epoch);
680        let south_pole = EclipticPosition::south_ecliptic_pole(epoch);
681
682        let pole_separation = north_pole.angular_separation(&south_pole);
683        assert_eq!(pole_separation.degrees(), 180.0);
684
685        // Two points at the same pole with different longitudes should have zero separation.
686        // At poles, longitude is undefined (singularity), so we test with identical coordinates.
687        let same_pole = EclipticPosition::north_ecliptic_pole(epoch);
688        let pole_separation_same = north_pole.angular_separation(&same_pole);
689        assert_eq!(pole_separation_same.degrees(), 0.0);
690    }
691
692    #[test]
693    fn test_pole_singularity_different_longitudes() {
694        // At the poles, longitude is mathematically undefined (singularity).
695        // Two points at beta=90° with different lambda values represent the same point.
696        // Due to cos(90°) ≈ 6e-17 (not exactly 0), Vincenty formula returns tiny non-zero.
697        // This test documents this known floating-point limitation.
698        let epoch = TT::j2000();
699
700        let north_pole = EclipticPosition::north_ecliptic_pole(epoch);
701        let same_pole_diff_lon = EclipticPosition::from_degrees(123.456, 90.0, epoch).unwrap();
702
703        let separation = north_pole.angular_separation(&same_pole_diff_lon);
704
705        // The separation should be essentially zero (within floating-point noise)
706        // cos(π/2) ≈ 6.12e-17, which propagates through Vincenty formula
707        assert!(
708            separation.degrees() < 1e-12,
709            "Pole singularity: expected ~0, got {} degrees",
710            separation.degrees()
711        );
712    }
713
714    #[test]
715    fn test_coordinate_transformations_at_poles() {
716        let epoch = TT::j2000();
717
718        let north_pole = EclipticPosition::north_ecliptic_pole(epoch);
719        let icrs_north = north_pole.to_icrs(&epoch).unwrap();
720        let roundtrip_north = EclipticPosition::from_icrs(&icrs_north, &epoch).unwrap();
721
722        assert_eq!(roundtrip_north.beta().degrees(), 90.0);
723
724        let south_pole = EclipticPosition::south_ecliptic_pole(epoch);
725        let icrs_south = south_pole.to_icrs(&epoch).unwrap();
726        let roundtrip_south = EclipticPosition::from_icrs(&icrs_south, &epoch).unwrap();
727
728        assert_eq!(roundtrip_south.beta().degrees(), -90.0);
729    }
730
731    #[test]
732    fn test_seasonal_boundary_cases() {
733        let epoch = TT::j2000();
734
735        let exactly_90 = EclipticPosition::from_degrees(90.0, 0.0, epoch).unwrap();
736        assert_eq!(exactly_90.season_index(), 1);
737
738        let exactly_180 = EclipticPosition::from_degrees(180.0, 0.0, epoch).unwrap();
739        assert_eq!(exactly_180.season_index(), 2);
740
741        let exactly_270 = EclipticPosition::from_degrees(270.0, 0.0, epoch).unwrap();
742        assert_eq!(exactly_270.season_index(), 3);
743
744        let exactly_0 = EclipticPosition::from_degrees(0.0, 0.0, epoch).unwrap();
745        assert_eq!(exactly_0.season_index(), 0);
746
747        let almost_360 = EclipticPosition::from_degrees(359.9, 0.0, epoch).unwrap();
748        assert_eq!(almost_360.season_index(), 3);
749    }
750
751    #[test]
752    fn test_plane_classification_boundary_cases() {
753        let epoch = TT::j2000();
754
755        let exactly_5_deg = EclipticPosition::from_degrees(0.0, 5.0, epoch).unwrap();
756        assert!(!exactly_5_deg.is_near_ecliptic_plane());
757
758        let just_under_5_deg = EclipticPosition::from_degrees(0.0, 4.99, epoch).unwrap();
759        assert!(just_under_5_deg.is_near_ecliptic_plane());
760
761        let exactly_85_deg = EclipticPosition::from_degrees(0.0, 85.0, epoch).unwrap();
762        assert!(!exactly_85_deg.is_near_ecliptic_pole());
763
764        let just_over_85_deg = EclipticPosition::from_degrees(0.0, 85.01, epoch).unwrap();
765        assert!(just_over_85_deg.is_near_ecliptic_pole());
766
767        let neg_85_deg = EclipticPosition::from_degrees(0.0, -85.01, epoch).unwrap();
768        assert!(neg_85_deg.is_near_ecliptic_pole());
769    }
770}