1use crate::angle::{
17 decompose_magnitude, format_dec, format_ra, parse_dec, parse_ra, Angle, ParseMode, SexaStyle,
18};
19use crate::error::{Error, Result};
20
21const RAD_PER_DEG: f64 = core::f64::consts::PI / 180.0;
22
23#[derive(Debug, Clone, Copy, PartialEq)]
32#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
33pub enum Epoch {
34 J2000,
36 OfDate(f64),
38}
39
40impl Epoch {
41 #[must_use]
50 pub fn julian_centuries_from_j2000(self) -> f64 {
51 match self {
52 Epoch::J2000 => 0.0,
53 Epoch::OfDate(year) => (year - 2000.0) / 100.0,
54 }
55 }
56}
57
58#[derive(Debug, Clone, Copy, PartialEq)]
72#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
73pub struct Equatorial {
74 ra: Angle,
75 dec: Angle,
76 epoch: Epoch,
77}
78
79impl Equatorial {
80 pub fn at_epoch(ra: Angle, dec: Angle, epoch: Epoch) -> Result<Self> {
98 let ra_deg = ra.degrees();
99 let dec_deg = dec.degrees();
100 if !ra_deg.is_finite() || !(0.0..360.0).contains(&ra_deg) {
101 return Err(Error::OutOfRange {
102 what: "right ascension",
103 value: ra_deg,
104 });
105 }
106 if !dec_deg.is_finite() || !(-90.0..=90.0).contains(&dec_deg) {
107 return Err(Error::OutOfRange {
108 what: "declination",
109 value: dec_deg,
110 });
111 }
112 if let Epoch::OfDate(year) = epoch {
113 if !year.is_finite() {
114 return Err(Error::OutOfRange {
115 what: "epoch year",
116 value: year,
117 });
118 }
119 }
120 Ok(Self { ra, dec, epoch })
121 }
122
123 pub fn j2000(ra: Angle, dec: Angle) -> Result<Self> {
136 Self::at_epoch(ra, dec, Epoch::J2000)
137 }
138
139 pub fn j2000_lenient(ra_deg: f64, dec_deg: f64) -> Result<Self> {
160 if !ra_deg.is_finite() {
161 return Err(Error::OutOfRange {
162 what: "right ascension",
163 value: ra_deg,
164 });
165 }
166 if !dec_deg.is_finite() {
167 return Err(Error::OutOfRange {
168 what: "declination",
169 value: dec_deg,
170 });
171 }
172 let ra = Angle::from_degrees(ra_deg).normalized_0_360();
173 let dec = Angle::from_degrees(dec_deg.clamp(-90.0, 90.0));
174 Self::j2000(ra, dec)
175 }
176
177 pub fn parse_at_epoch(ra: &str, dec: &str, epoch: Epoch, mode: ParseMode) -> Result<Self> {
191 Self::at_epoch(parse_ra(ra, mode)?, parse_dec(dec, mode)?, epoch)
192 }
193
194 pub fn parse_j2000(ra: &str, dec: &str, mode: ParseMode) -> Result<Self> {
207 Self::parse_at_epoch(ra, dec, Epoch::J2000, mode)
208 }
209
210 #[must_use]
220 pub fn ra(self) -> Angle {
221 self.ra
222 }
223 #[must_use]
233 pub fn dec(self) -> Angle {
234 self.dec
235 }
236 #[must_use]
246 pub fn epoch(self) -> Epoch {
247 self.epoch
248 }
249 #[must_use]
261 pub fn to_degrees(self) -> (f64, f64) {
262 (self.ra.degrees(), self.dec.degrees())
263 }
264
265 #[must_use]
275 pub fn ra_sexagesimal(self, style: SexaStyle) -> String {
276 format_ra(self.ra, style)
277 }
278 #[must_use]
288 pub fn dec_sexagesimal(self, style: SexaStyle) -> String {
289 format_dec(self.dec, style)
290 }
291
292 #[must_use]
309 pub fn ra_hms(self) -> (u32, u32, f64) {
310 let hours = self.ra.normalized_0_360().degrees() / 15.0;
311 decompose_magnitude(hours)
312 }
313
314 #[must_use]
329 pub fn dec_dms(self) -> (bool, u32, u32, f64) {
330 let deg = self.dec.degrees();
331 let neg = deg.is_sign_negative() && deg != 0.0;
332 let (d, m, s) = decompose_magnitude(deg.abs());
333 (neg, d, m, s)
334 }
335
336 pub(crate) fn to_unit_vector(self) -> [f64; 3] {
338 let (a, d) = (self.ra.radians(), self.dec.radians());
339 [d.cos() * a.cos(), d.cos() * a.sin(), d.sin()]
340 }
341
342 pub(crate) fn from_unit_vector(v: [f64; 3], epoch: Epoch) -> Self {
345 let ra = Angle::from_radians(v[1].atan2(v[0])).normalized_0_360();
346 let dec = Angle::from_radians(v[2].atan2((v[0] * v[0] + v[1] * v[1]).sqrt()));
347 Self { ra, dec, epoch }
348 }
349}
350
351#[must_use]
368pub fn separation(a: Equatorial, b: Equatorial) -> Angle {
369 let (ra1, dec1) = (a.ra.radians(), a.dec.radians());
370 let (ra2, dec2) = (b.ra.radians(), b.dec.radians());
371 let (dra, ddec) = (ra2 - ra1, dec2 - dec1);
372 let sin_ddec = (ddec / 2.0).sin();
373 let sin_dra = (dra / 2.0).sin();
374 let h = sin_ddec.mul_add(sin_ddec, dec1.cos() * dec2.cos() * sin_dra * sin_dra);
376 let central = 2.0 * h.sqrt().clamp(0.0, 1.0).asin();
377 Angle::from_radians(central)
378}
379
380#[must_use]
395pub fn position_angle(from: Equatorial, to: Equatorial) -> Angle {
396 let (a0, d0) = (from.ra.radians(), from.dec.radians());
397 let (a, d) = (to.ra.radians(), to.dec.radians());
398 let da = a - a0;
399 let y = d.cos() * da.sin();
400 let x = d0.cos() * d.sin() - d0.sin() * d.cos() * da.cos();
401 Angle::from_radians(y.atan2(x)).normalized_0_360()
402}
403
404#[must_use]
427pub fn transport_position_angle(from: Equatorial, to: Equatorial, angle: Angle) -> Option<Angle> {
428 let from_vector = from.to_unit_vector();
429 let to_vector = to.to_unit_vector();
430 let dot = dot_product(from_vector, to_vector).clamp(-1.0, 1.0);
431
432 if dot <= -1.0 + 1e-12 {
435 return None;
436 }
437
438 let (from_east, from_north) = local_tangent_basis(from);
439 let tangent = add_vectors(
440 scale_vector(from_east, angle.radians().sin()),
441 scale_vector(from_north, angle.radians().cos()),
442 );
443 let axis = cross_product(from_vector, to_vector);
444 let axis_length = dot_product(axis, axis).sqrt();
445 let transported = if axis_length <= f64::EPSILON {
446 tangent
449 } else {
450 let unit_axis = scale_vector(axis, 1.0 / axis_length);
451 add_vectors(
452 add_vectors(
453 scale_vector(tangent, dot),
454 scale_vector(cross_product(unit_axis, tangent), axis_length),
455 ),
456 scale_vector(unit_axis, dot_product(unit_axis, tangent) * (1.0 - dot)),
457 )
458 };
459
460 let (to_east, to_north) = local_tangent_basis(to);
461 Some(
462 Angle::from_radians(
463 dot_product(transported, to_east).atan2(dot_product(transported, to_north)),
464 )
465 .normalized_0_360(),
466 )
467}
468
469fn local_tangent_basis(position: Equatorial) -> ([f64; 3], [f64; 3]) {
470 let (ra, dec) = (position.ra.radians(), position.dec.radians());
471 let east = [-ra.sin(), ra.cos(), 0.0];
472 let north = [-dec.sin() * ra.cos(), -dec.sin() * ra.sin(), dec.cos()];
473 (east, north)
474}
475
476fn dot_product(a: [f64; 3], b: [f64; 3]) -> f64 {
477 a[0].mul_add(b[0], a[1].mul_add(b[1], a[2] * b[2]))
478}
479
480fn cross_product(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
481 [
482 a[1] * b[2] - a[2] * b[1],
483 a[2] * b[0] - a[0] * b[2],
484 a[0] * b[1] - a[1] * b[0],
485 ]
486}
487
488fn scale_vector(vector: [f64; 3], scale: f64) -> [f64; 3] {
489 [vector[0] * scale, vector[1] * scale, vector[2] * scale]
490}
491
492fn add_vectors(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
493 [a[0] + b[0], a[1] + b[1], a[2] + b[2]]
494}
495
496#[derive(Debug, Clone, Copy, PartialEq)]
503#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
504pub struct GnomonicPoint {
505 pub east: f64,
507 pub north: f64,
509}
510
511#[must_use]
529pub fn gnomonic_project(center: Equatorial, point: Equatorial) -> Option<GnomonicPoint> {
530 let (ra0, dec0) = (center.ra.radians(), center.dec.radians());
531 let (ra, dec) = (point.ra.radians(), point.dec.radians());
532 let delta_ra = ra - ra0;
533 let denominator = dec0.sin() * dec.sin() + dec0.cos() * dec.cos() * delta_ra.cos();
534
535 if denominator <= 16.0 * f64::EPSILON {
538 return None;
539 }
540
541 Some(GnomonicPoint {
542 east: dec.cos() * delta_ra.sin() / denominator,
543 north: (dec0.cos() * dec.sin() - dec0.sin() * dec.cos() * delta_ra.cos()) / denominator,
544 })
545}
546
547#[must_use]
563pub fn gnomonic_unproject(center: Equatorial, point: GnomonicPoint) -> Option<Equatorial> {
564 if !point.east.is_finite() || !point.north.is_finite() {
565 return None;
566 }
567
568 let radius = point.east.hypot(point.north);
569 if !radius.is_finite() {
570 return None;
571 }
572 if radius == 0.0 {
573 return Some(center);
574 }
575
576 let angular_distance = radius.atan();
577 let (sin_distance, cos_distance) = angular_distance.sin_cos();
578 let (ra0, dec0) = (center.ra.radians(), center.dec.radians());
579 let dec = (cos_distance * dec0.sin() + point.north * sin_distance * dec0.cos() / radius)
580 .clamp(-1.0, 1.0)
581 .asin();
582 let ra = ra0
583 + (point.east * sin_distance)
584 .atan2(radius * dec0.cos() * cos_distance - point.north * dec0.sin() * sin_distance);
585
586 Some(Equatorial {
587 ra: Angle::from_radians(ra).normalized_0_360(),
588 dec: Angle::from_radians(dec),
589 epoch: center.epoch,
590 })
591}
592
593#[derive(Debug, Clone, Copy, PartialEq)]
597#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
598pub struct TangentOffset {
599 pub east: Angle,
601 pub north: Angle,
603}
604
605#[must_use]
620pub fn tangent_offset(from: Equatorial, to: Equatorial) -> TangentOffset {
621 let sep = separation(from, to).radians();
622 let pa = position_angle(from, to).radians();
623 TangentOffset {
624 east: Angle::from_radians(sep * pa.sin()),
625 north: Angle::from_radians(sep * pa.cos()),
626 }
627}
628
629#[must_use]
649pub fn apply_offset(from: Equatorial, offset: TangentOffset) -> Equatorial {
650 let (e, n) = (offset.east.radians(), offset.north.radians());
651 let sep = e.hypot(n);
652 if sep == 0.0 {
653 return from;
654 }
655 let pa = e.atan2(n);
656 let (phi1, lam1) = (from.dec.radians(), from.ra.radians());
657 let (sin_phi2_raw, cos_sep) = (
658 phi1.sin() * sep.cos() + phi1.cos() * sep.sin() * pa.cos(),
659 sep.cos(),
660 );
661 let sin_phi2 = sin_phi2_raw.clamp(-1.0, 1.0);
662 let phi2 = sin_phi2.asin();
663 let lam2 = lam1 + (pa.sin() * sep.sin() * phi1.cos()).atan2(cos_sep - phi1.sin() * sin_phi2);
664 Equatorial {
665 ra: Angle::from_radians(lam2).normalized_0_360(),
666 dec: Angle::from_degrees(phi2.to_degrees().clamp(-90.0, 90.0)),
667 epoch: from.epoch,
668 }
669}
670
671#[must_use]
691pub fn precess(pos: Equatorial, to: Epoch) -> Equatorial {
692 if pos.epoch == to {
693 return pos;
694 }
695 let at_j2000 = match pos.epoch {
697 Epoch::J2000 => pos,
698 Epoch::OfDate(year) => {
699 let v = apply_matrix(&transpose(&precession_matrix(year)), pos.to_unit_vector());
700 Equatorial::from_unit_vector(v, Epoch::J2000)
701 }
702 };
703 match to {
704 Epoch::J2000 => at_j2000,
705 Epoch::OfDate(year) => {
706 let v = apply_matrix(&precession_matrix(year), at_j2000.to_unit_vector());
707 Equatorial::from_unit_vector(v, to)
708 }
709 }
710}
711
712fn precession_matrix(year: f64) -> [[f64; 3]; 3] {
717 let t = (year - 2000.0) / 100.0; let arcsec = |a: f64| a * (RAD_PER_DEG / 3600.0);
719 let zeta = arcsec(2306.2181 * t + 0.301_88 * t * t + 0.017_998 * t * t * t);
720 let z = arcsec(2306.2181 * t + 1.094_68 * t * t + 0.018_203 * t * t * t);
721 let theta = arcsec(2004.3109 * t - 0.426_65 * t * t - 0.041_833 * t * t * t);
722 mat_mul(&mat_mul(&rot_z(-z), &rot_y(theta)), &rot_z(-zeta))
723}
724
725fn rot_z(phi: f64) -> [[f64; 3]; 3] {
726 let (s, c) = phi.sin_cos();
727 [[c, s, 0.0], [-s, c, 0.0], [0.0, 0.0, 1.0]]
728}
729fn rot_y(phi: f64) -> [[f64; 3]; 3] {
730 let (s, c) = phi.sin_cos();
731 [[c, 0.0, -s], [0.0, 1.0, 0.0], [s, 0.0, c]]
732}
733fn mat_mul(a: &[[f64; 3]; 3], b: &[[f64; 3]; 3]) -> [[f64; 3]; 3] {
734 let mut out = [[0.0; 3]; 3];
735 for (i, row) in out.iter_mut().enumerate() {
736 for (j, cell) in row.iter_mut().enumerate() {
737 *cell = a[i][0] * b[0][j] + a[i][1] * b[1][j] + a[i][2] * b[2][j];
738 }
739 }
740 out
741}
742fn transpose(m: &[[f64; 3]; 3]) -> [[f64; 3]; 3] {
743 let mut t = [[0.0; 3]; 3];
744 for i in 0..3 {
745 for j in 0..3 {
746 t[i][j] = m[j][i];
747 }
748 }
749 t
750}
751pub(crate) fn apply_matrix(m: &[[f64; 3]; 3], v: [f64; 3]) -> [f64; 3] {
752 [
753 m[0][0] * v[0] + m[0][1] * v[1] + m[0][2] * v[2],
754 m[1][0] * v[0] + m[1][1] * v[1] + m[1][2] * v[2],
755 m[2][0] * v[0] + m[2][1] * v[1] + m[2][2] * v[2],
756 ]
757}
758
759#[cfg(test)]
760mod tests {
761 use super::*;
762
763 fn approx(a: f64, b: f64, eps: f64) -> bool {
764 (a - b).abs() < eps
765 }
766 fn eq(ra: f64, dec: f64) -> Equatorial {
767 Equatorial::j2000(Angle::from_degrees(ra), Angle::from_degrees(dec)).unwrap()
768 }
769
770 #[test]
771 fn equatorial_validates_domain() {
772 assert!(eq(10.0, 41.0).ra().degrees() > 0.0);
773 assert!(matches!(
774 Equatorial::j2000(Angle::from_degrees(360.0), Angle::from_degrees(0.0)),
775 Err(Error::OutOfRange {
776 what: "right ascension",
777 ..
778 })
779 ));
780 assert!(matches!(
781 Equatorial::j2000(Angle::from_degrees(0.0), Angle::from_degrees(90.1)),
782 Err(Error::OutOfRange {
783 what: "declination",
784 ..
785 })
786 ));
787 assert!(matches!(
788 Equatorial::at_epoch(
789 Angle::from_degrees(0.0),
790 Angle::from_degrees(0.0),
791 Epoch::OfDate(f64::NAN)
792 ),
793 Err(Error::OutOfRange {
794 what: "epoch year",
795 ..
796 })
797 ));
798 }
799
800 #[test]
801 fn j2000_lenient_wraps_ra_and_clamps_dec() {
802 let p = Equatorial::j2000_lenient(370.0, 91.0).unwrap();
803 assert!(approx(p.ra().degrees(), 10.0, 1e-9));
804 assert!(approx(p.dec().degrees(), 90.0, 1e-9));
805
806 let n = Equatorial::j2000_lenient(-10.0, -91.0).unwrap();
807 assert!(approx(n.ra().degrees(), 350.0, 1e-9));
808 assert!(approx(n.dec().degrees(), -90.0, 1e-9));
809
810 let unchanged = Equatorial::j2000_lenient(10.6847, 41.2688).unwrap();
811 assert!(approx(unchanged.ra().degrees(), 10.6847, 1e-9));
812 assert!(approx(unchanged.dec().degrees(), 41.2688, 1e-9));
813 }
814
815 #[test]
816 fn j2000_lenient_rejects_non_finite() {
817 assert!(matches!(
818 Equatorial::j2000_lenient(f64::NAN, 0.0),
819 Err(Error::OutOfRange {
820 what: "right ascension",
821 ..
822 })
823 ));
824 assert!(matches!(
825 Equatorial::j2000_lenient(0.0, f64::INFINITY),
826 Err(Error::OutOfRange {
827 what: "declination",
828 ..
829 })
830 ));
831 }
832
833 #[test]
834 fn ra_hms_and_dec_dms_components() {
835 let m31 = Equatorial::parse_j2000("00:42:44.3", "+41:16:09", ParseMode::Strict).unwrap();
836 let (h, m, s) = m31.ra_hms();
837 assert_eq!((h, m), (0, 42));
838 assert!(approx(s, 44.3, 1e-2));
839
840 let (neg, d, m, s) = m31.dec_dms();
841 assert!(!neg);
842 assert_eq!((d, m), (41, 16));
843 assert!(approx(s, 9.0, 1e-2));
844 }
845
846 #[test]
847 fn dec_dms_negative_sign_and_zero_edge() {
848 let south = Equatorial::parse_j2000("10:00:00", "-05:30:00", ParseMode::Strict).unwrap();
849 let (neg, d, m, _) = south.dec_dms();
850 assert!(neg);
851 assert_eq!((d, m), (5, 30));
852
853 let zero = Equatorial::parse_j2000("00:00:00", "00:00:00", ParseMode::Strict).unwrap();
854 let (neg, d, m, s) = zero.dec_dms();
855 assert!(!neg);
856 assert_eq!((d, m), (0, 0));
857 assert!(approx(s, 0.0, 1e-9));
858 }
859
860 #[test]
861 fn ra_hms_wraps_into_0_24() {
862 let p = eq(360.0 - 1e-9, 0.0);
863 let (h, _, _) = p.ra_hms();
864 assert!(h < 24);
865 }
866
867 #[test]
868 fn parse_ra_is_hours_dec_is_degrees() {
869 let p = Equatorial::parse_j2000("06:00:00", "06:00:00", ParseMode::Strict).unwrap();
870 assert!(approx(p.ra().degrees(), 90.0, 1e-9));
871 assert!(approx(p.dec().degrees(), 6.0, 1e-9));
872 }
873
874 #[test]
875 fn separation_known_cases() {
876 let m31 = eq(10.6847, 41.2688);
877 assert!(separation(m31, m31).arcseconds() < 1e-6);
878 let (a, b) = (eq(100.0, 0.0), eq(101.0, 0.0));
879 assert!(approx(separation(a, b).degrees(), 1.0, 1e-9));
880 let (c, d) = (eq(100.0, 60.0), eq(101.0, 60.0));
881 assert!(approx(separation(c, d).degrees(), 0.5, 1e-3));
882 let m110 = eq(10.0921, 41.6853);
883 assert!((0.4..0.9).contains(&separation(m31, m110).degrees()));
884 }
885
886 #[test]
887 fn position_angle_cardinal_directions() {
888 let c = eq(180.0, 0.0);
889 assert!(approx(
891 position_angle(c, eq(180.0, 1.0)).degrees(),
892 0.0,
893 1e-6
894 ));
895 assert!(approx(
896 position_angle(c, eq(181.0, 0.0)).degrees(),
897 90.0,
898 1e-6
899 ));
900 assert!(approx(
901 position_angle(c, eq(180.0, -1.0)).degrees(),
902 180.0,
903 1e-6
904 ));
905 assert!(approx(
906 position_angle(c, eq(179.0, 0.0)).degrees(),
907 270.0,
908 1e-6
909 ));
910 }
911
912 #[test]
913 fn transport_identity_and_equator() {
914 let from = eq(10.0, 0.0);
915 assert!(approx(
916 transport_position_angle(from, from, Angle::from_degrees(370.0))
917 .unwrap()
918 .degrees(),
919 10.0,
920 1e-12
921 ));
922
923 let to = eq(80.0, 0.0);
924 assert!(approx(
925 transport_position_angle(from, to, Angle::from_degrees(0.0))
926 .unwrap()
927 .degrees(),
928 0.0,
929 1e-12
930 ));
931 }
932
933 #[test]
934 fn transport_tangent_arrives_on_same_geodesic() {
935 let from = eq(12.0, 34.0);
936 let to = eq(123.0, 56.0);
937 let departure = position_angle(from, to);
938 let expected_arrival =
939 (position_angle(to, from) + Angle::from_degrees(180.0)).normalized_0_360();
940 let transported = transport_position_angle(from, to, departure).unwrap();
941 assert!(approx(
942 crate::angle::circular_distance(transported, expected_arrival).degrees(),
943 0.0,
944 1e-10
945 ));
946 }
947
948 #[test]
949 fn transport_round_trips_across_ra_wrap_and_high_declination() {
950 let from = eq(359.8, 82.0);
951 let to = eq(0.3, 84.0);
952 let initial = Angle::from_degrees(217.0);
953 let at_to = transport_position_angle(from, to, initial).unwrap();
954 let restored = transport_position_angle(to, from, at_to).unwrap();
955 assert!(crate::angle::circular_distance(initial, restored).degrees() < 1e-10);
956
957 let pole = eq(45.0, 90.0);
958 let near_pole = eq(180.0, 89.0);
959 assert!(transport_position_angle(pole, near_pole, initial)
960 .unwrap()
961 .degrees()
962 .is_finite());
963 }
964
965 #[test]
966 fn transport_reexpresses_north_pole_ra_aliases() {
967 let from = eq(0.0, 90.0);
968 let to = eq(90.0, 90.0);
969 let transported = transport_position_angle(from, to, Angle::from_degrees(0.0)).unwrap();
970 assert!(
971 crate::angle::circular_distance(transported, Angle::from_degrees(90.0)).degrees()
972 < 1e-10
973 );
974 }
975
976 #[test]
977 fn transport_reexpresses_south_pole_ra_aliases() {
978 let from = eq(0.0, -90.0);
979 let to = eq(90.0, -90.0);
980 let transported = transport_position_angle(from, to, Angle::from_degrees(0.0)).unwrap();
981 assert!(
982 crate::angle::circular_distance(transported, Angle::from_degrees(270.0)).degrees()
983 < 1e-10
984 );
985 }
986
987 #[test]
988 fn transport_rejects_antipodal_ambiguity() {
989 let from = eq(0.0, 0.0);
990 assert!(
991 transport_position_angle(from, eq(180.0, 0.0), Angle::from_degrees(10.0)).is_none()
992 );
993 assert!(
994 transport_position_angle(from, eq(180.0 - 1e-7, 0.0), Angle::from_degrees(10.0))
995 .is_none()
996 );
997 }
998
999 #[test]
1000 fn gnomonic_origin_known_offset_and_ra_wrap_round_trip() {
1001 let center = eq(0.0, 0.0);
1002 assert_eq!(
1003 gnomonic_project(center, center),
1004 Some(GnomonicPoint {
1005 east: 0.0,
1006 north: 0.0
1007 })
1008 );
1009 let east_45 = gnomonic_project(center, eq(45.0, 0.0)).unwrap();
1010 assert!(approx(east_45.east, 1.0, 1e-12));
1011 assert!(approx(east_45.north, 0.0, 1e-12));
1012
1013 let wrap_center = eq(359.5, 70.0);
1014 let point = eq(0.5, 71.0);
1015 let projected = gnomonic_project(wrap_center, point).unwrap();
1016 let restored = gnomonic_unproject(wrap_center, projected).unwrap();
1017 assert!(separation(point, restored).arcseconds() < 1e-6);
1018 }
1019
1020 #[test]
1021 fn gnomonic_rejects_horizon_and_non_finite_plane_points() {
1022 let center = eq(0.0, 0.0);
1023 assert!(gnomonic_project(center, eq(90.0, 0.0)).is_none());
1024 assert!(gnomonic_project(center, eq(100.0, 0.0)).is_none());
1025 assert!(gnomonic_unproject(
1026 center,
1027 GnomonicPoint {
1028 east: f64::NAN,
1029 north: 0.0
1030 }
1031 )
1032 .is_none());
1033 }
1034
1035 #[test]
1036 fn gnomonic_remains_finite_near_horizon() {
1037 let center = eq(0.0, 0.0);
1038 let projected = gnomonic_project(center, eq(89.999, 0.0)).unwrap();
1039 assert!(projected.east.is_finite());
1040 assert!(projected.east > 50_000.0);
1041 let restored = gnomonic_unproject(center, projected).unwrap();
1042 assert!(separation(restored, eq(89.999, 0.0)).arcseconds() < 1e-5);
1043 }
1044
1045 #[test]
1046 fn offset_round_trip() {
1047 let from = eq(10.6847, 41.2688);
1048 let to = eq(10.0921, 41.6853);
1049 let off = tangent_offset(from, to);
1050 let back = apply_offset(from, off);
1051 assert!(
1052 separation(to, back).arcseconds() < 1e-3,
1053 "drift {}",
1054 separation(to, back).arcseconds()
1055 );
1056 }
1057
1058 #[test]
1059 fn offset_across_ra_wrap() {
1060 let from = eq(359.5, 10.0);
1061 let to = eq(0.5, 10.2);
1062 let off = tangent_offset(from, to);
1063 assert!(off.east.degrees() > 0.0, "east across wrap");
1064 let back = apply_offset(from, off);
1065 assert!(separation(to, back).arcseconds() < 1e-3);
1066 }
1067
1068 #[test]
1069 fn zero_offset_is_identity() {
1070 let p = eq(50.0, -30.0);
1071 let off = TangentOffset {
1072 east: Angle::from_degrees(0.0),
1073 north: Angle::from_degrees(0.0),
1074 };
1075 assert_eq!(apply_offset(p, off), p);
1076 }
1077
1078 #[test]
1079 fn precession_identity_and_round_trip() {
1080 let p = eq(45.0, 20.0);
1081 assert_eq!(precess(p, Epoch::J2000), p);
1082 let to_date = precess(p, Epoch::OfDate(2050.0));
1083 assert_eq!(to_date.epoch(), Epoch::OfDate(2050.0));
1084 let back = precess(to_date, Epoch::J2000);
1085 assert!(separation(p, back).arcseconds() < 1e-6);
1086 }
1087
1088 #[test]
1089 fn precession_rate_matches_iau() {
1090 let p = eq(0.0, 0.0);
1091 let d = precess(p, Epoch::OfDate(2100.0));
1092 assert!(
1093 approx(d.dec().arcseconds(), 2004.31, 2.0),
1094 "dec shift {}",
1095 d.dec().arcseconds()
1096 );
1097 let d26 = precess(p, Epoch::OfDate(2026.0));
1098 let shift = separation(p, d26).arcminutes();
1099 assert!((5.0..30.0).contains(&shift), "26yr shift {shift} arcmin");
1100 }
1101}