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 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 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 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 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 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 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 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 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 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 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 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 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 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}