celestial_coords/
solar.rs1use crate::{CoordResult, ICRSPosition};
2use celestial_core::constants::{ARCSEC_TO_RAD, DEG_TO_RAD, J2000_JD, TWOPI};
3use celestial_core::utils::{normalize_angle_rad, normalize_angle_to_positive};
4use celestial_core::Angle;
5use celestial_time::TT;
6
7const SOLAR_EQUATOR_INCLINATION_DEG: f64 = 7.25;
8const SOLAR_EQUATOR_INCLINATION_RAD: f64 = SOLAR_EQUATOR_INCLINATION_DEG * DEG_TO_RAD;
9
10const SOLAR_ASCENDING_NODE_J2000_DEG: f64 = 75.76;
11
12pub const CARRINGTON_EPOCH_JD: f64 = 2398220.0;
13pub const CARRINGTON_SYNODIC_PERIOD: f64 = 27.2753;
14
15pub struct SolarOrientation {
16 pub b0: Angle,
17 pub l0: Angle,
18 pub p: Angle,
19}
20
21pub fn compute_solar_orientation(epoch: &TT) -> SolarOrientation {
22 let jd = epoch.to_julian_date();
23 let d = (jd.jd1() - J2000_JD) + jd.jd2();
24 let t = d / celestial_core::constants::DAYS_PER_JULIAN_CENTURY;
25
26 let (sun_lon, sun_lat, obliquity) = solar_ecliptic_coords(t);
27 let (b0, l0, p) = heliographic_coords(t, sun_lon, sun_lat, obliquity);
28
29 SolarOrientation {
30 b0: Angle::from_radians(b0),
31 l0: Angle::from_radians(l0),
32 p: Angle::from_radians(p),
33 }
34}
35
36pub fn compute_b0(epoch: &TT) -> Angle {
37 compute_solar_orientation(epoch).b0
38}
39
40pub fn compute_l0(epoch: &TT) -> Angle {
41 compute_solar_orientation(epoch).l0
42}
43
44pub fn compute_p(epoch: &TT) -> Angle {
45 compute_solar_orientation(epoch).p
46}
47
48pub fn carrington_rotation_number(epoch: &TT) -> u32 {
49 let jd = epoch.to_julian_date();
50 let jd_days = (jd.jd1() - CARRINGTON_EPOCH_JD) + jd.jd2();
51 libm::floor(jd_days / CARRINGTON_SYNODIC_PERIOD) as u32 + 1
52}
53
54pub fn sun_earth_distance(epoch: &TT) -> f64 {
55 let jd = epoch.to_julian_date();
56 let d = (jd.jd1() - J2000_JD) + jd.jd2();
57 let t = d / celestial_core::constants::DAYS_PER_JULIAN_CENTURY;
58
59 let m = (357.52911 + 35999.05029 * t - 0.0001537 * t * t) * DEG_TO_RAD;
60 let e = 0.016708634 - 0.000042037 * t - 0.0000001267 * t * t;
61
62 let c_rad = (1.914602 - 0.004817 * t - 0.000014 * t * t) * DEG_TO_RAD * libm::sin(m)
63 + (0.019993 - 0.000101 * t) * DEG_TO_RAD * libm::sin(2.0 * m)
64 + 0.000289 * DEG_TO_RAD * libm::sin(3.0 * m);
65
66 let true_anomaly = m + c_rad;
67 let a = 1.000001018; a * (1.0 - e * e) / (1.0 + e * libm::cos(true_anomaly))
70}
71
72fn heliographic_coords(t: f64, sun_lon: f64, _sun_lat: f64, obliquity: f64) -> (f64, f64, f64) {
73 let i = SOLAR_EQUATOR_INCLINATION_RAD;
74 let k = (SOLAR_ASCENDING_NODE_J2000_DEG + 1.3958333 * t) * DEG_TO_RAD;
75
76 let lambda = sun_lon;
77 let theta = lambda - k;
78 let (sin_theta, cos_theta) = libm::sincos(theta);
79 let (sin_i, cos_i) = libm::sincos(i);
80 let (_sin_obl, cos_obl) = libm::sincos(obliquity);
81
82 let b0 = libm::asin(sin_theta * sin_i);
83
84 let eta = libm::atan2(sin_i * cos_theta, cos_i);
85 let jd_days =
86 t * celestial_core::constants::DAYS_PER_JULIAN_CENTURY + J2000_JD - CARRINGTON_EPOCH_JD;
87 let l0_raw = 360.0 / CARRINGTON_SYNODIC_PERIOD * jd_days;
88 let l0 = normalize_angle_to_positive((l0_raw * DEG_TO_RAD - eta) % TWOPI);
89
90 let rho = libm::atan(cos_theta * sin_i / cos_obl);
91 let sigma = libm::atan(sin_theta * cos_i);
92 let p = normalize_angle_rad(rho + sigma);
93
94 (b0, l0, p)
95}
96
97fn solar_ecliptic_coords(t: f64) -> (f64, f64, f64) {
98 let l0 = 280.46646 + 36000.76983 * t + 0.0003032 * t * t;
99 let m = 357.52911 + 35999.05029 * t - 0.0001537 * t * t;
100 let m_rad = m * DEG_TO_RAD;
101
102 let c = (1.914602 - 0.004817 * t - 0.000014 * t * t) * libm::sin(m_rad)
103 + (0.019993 - 0.000101 * t) * libm::sin(2.0 * m_rad)
104 + 0.000289 * libm::sin(3.0 * m_rad);
105
106 let sun_true_lon = l0 + c;
107
108 let omega = 125.04 - 1934.136 * t;
109 let omega_rad = omega * DEG_TO_RAD;
110 let apparent_lon = sun_true_lon - 0.00569 - 0.00478 * libm::sin(omega_rad);
111
112 let obliquity = mean_obliquity(t);
113
114 (
115 normalize_angle_to_positive(apparent_lon * DEG_TO_RAD),
116 0.0,
117 obliquity,
118 )
119}
120
121fn mean_obliquity(t: f64) -> f64 {
122 let eps0_arcsec = 84381.448 - 46.8150 * t - 0.00059 * t * t + 0.001813 * t * t * t;
123 eps0_arcsec * ARCSEC_TO_RAD
124}
125
126pub(crate) fn get_sun_icrs(epoch: &TT) -> CoordResult<ICRSPosition> {
127 let jd = epoch.to_julian_date();
128 let d = (jd.jd1() - J2000_JD) + jd.jd2();
129 let t = d / celestial_core::constants::DAYS_PER_JULIAN_CENTURY;
130
131 let l0 = 280.46646 + 36000.76983 * t + 0.0003032 * t * t;
132 let m = 357.52911 + 35999.05029 * t - 0.0001537 * t * t;
133 let m_rad = m * DEG_TO_RAD;
134
135 let c = (1.914602 - 0.004817 * t - 0.000014 * t * t) * libm::sin(m_rad)
136 + (0.019993 - 0.000101 * t) * libm::sin(2.0 * m_rad)
137 + 0.000289 * libm::sin(3.0 * m_rad);
138
139 let sun_true_lon = l0 + c;
140 let omega = 125.04 - 1934.136 * t;
141 let omega_rad = omega * DEG_TO_RAD;
142 let apparent_lon = sun_true_lon - 0.00569 - 0.00478 * libm::sin(omega_rad);
143
144 let lambda = apparent_lon * DEG_TO_RAD;
145 let eps = (23.439291 - 0.0130042 * t) * DEG_TO_RAD;
146
147 let (sin_lambda, cos_lambda) = libm::sincos(lambda);
148 let (sin_eps, cos_eps) = libm::sincos(eps);
149
150 let ra = libm::atan2(sin_lambda * cos_eps, cos_lambda);
151 let dec = libm::asin(sin_lambda * sin_eps);
152
153 ICRSPosition::new(
154 Angle::from_radians(normalize_angle_to_positive(ra)),
155 Angle::from_radians(dec),
156 )
157}
158
159#[cfg(test)]
160mod tests {
161 use super::*;
162 use celestial_time::julian::JulianDate;
163
164 #[test]
165 fn test_b0_range() {
166 let epochs = [
167 TT::j2000(),
168 TT::from_julian_date(JulianDate::new(J2000_JD + 91.0, 0.0)),
169 TT::from_julian_date(JulianDate::new(J2000_JD + 182.0, 0.0)),
170 TT::from_julian_date(JulianDate::new(J2000_JD + 273.0, 0.0)),
171 ];
172
173 for epoch in &epochs {
174 let b0 = compute_b0(epoch);
175 assert!(
176 b0.degrees().abs() <= 7.3,
177 "B0 = {} degrees exceeds expected range ±7.25°",
178 b0.degrees()
179 );
180 }
181 }
182
183 #[test]
184 fn test_l0_range() {
185 let epoch = TT::j2000();
186 let l0 = compute_l0(&epoch);
187 assert!(
188 l0.degrees() >= 0.0 && l0.degrees() < 360.0,
189 "L0 = {} degrees outside [0, 360) range",
190 l0.degrees()
191 );
192 }
193
194 #[test]
195 fn test_p_range() {
196 let epochs = [
197 TT::j2000(),
198 TT::from_julian_date(JulianDate::new(J2000_JD + 91.0, 0.0)),
199 TT::from_julian_date(JulianDate::new(J2000_JD + 182.0, 0.0)),
200 TT::from_julian_date(JulianDate::new(J2000_JD + 273.0, 0.0)),
201 ];
202
203 for epoch in &epochs {
204 let p = compute_p(epoch);
205 assert!(
206 p.degrees().abs() <= 45.0,
207 "P = {} degrees exceeds expected range ±45°",
208 p.degrees()
209 );
210 }
211 }
212
213 #[test]
214 fn test_carrington_rotation_period() {
215 let epoch1 = TT::j2000();
216 let l0_1 = compute_l0(&epoch1);
217
218 let epoch2 =
219 TT::from_julian_date(JulianDate::new(J2000_JD + CARRINGTON_SYNODIC_PERIOD, 0.0));
220 let l0_2 = compute_l0(&epoch2);
221
222 let diff = (l0_2.degrees() - l0_1.degrees()).abs();
223 assert!(
224 (diff - 360.0).abs() < 5.0 || diff < 5.0,
225 "L0 should change by ~360° in one Carrington rotation, got {} degrees",
226 diff
227 );
228 }
229
230 #[test]
231 fn test_solar_orientation_combined() {
232 let epoch = TT::j2000();
233 let orientation = compute_solar_orientation(&epoch);
234
235 assert!(
236 orientation.b0.degrees().abs() <= 7.3,
237 "B0 = {} out of range",
238 orientation.b0.degrees()
239 );
240 assert!(
241 orientation.l0.degrees() >= 0.0 && orientation.l0.degrees() < 360.0,
242 "L0 = {} out of range",
243 orientation.l0.degrees()
244 );
245 assert!(
246 orientation.p.degrees().abs() <= 30.0,
247 "P = {} out of range",
248 orientation.p.degrees()
249 );
250 }
251}