1#![allow(clippy::similar_names)]
10#![allow(clippy::many_single_char_names)]
11#![allow(clippy::unreadable_literal)]
12
13use crate::error::{check_coordinates, check_elevation_angle};
14use crate::math::{
15 acos, asin, atan, atan2, cos, degrees_to_radians, mul_add, normalize_degrees_0_to_360,
16 polynomial, powi, radians_to_degrees, rem_euclid, sin, tan,
17};
18use crate::time::JulianDate;
19use crate::{Horizon, RefractionCorrection, Result, SolarPosition};
20
21pub mod coefficients;
22use coefficients::{
23 NUTATION_COEFFS, OBLIQUITY_COEFFS, TERMS_B, TERMS_L, TERMS_PE, TERMS_R, TERMS_Y,
24};
25
26#[cfg(feature = "chrono")]
27use chrono::{DateTime, Datelike, NaiveDate, TimeZone};
28
29const ABERRATION_CONSTANT: f64 = -20.4898;
31
32const EARTH_FLATTENING_FACTOR: f64 = 0.99664719;
34
35const EARTH_RADIUS_METERS: f64 = 6378140.0;
37
38const SECONDS_PER_HOUR: f64 = 3600.0;
40
41#[cfg(feature = "chrono")]
88#[cfg_attr(docsrs, doc(cfg(feature = "chrono")))]
89#[allow(clippy::needless_pass_by_value)]
90pub fn solar_position<Tz: TimeZone>(
91 datetime: DateTime<Tz>,
92 latitude: f64,
93 longitude: f64,
94 elevation: f64,
95 delta_t: f64,
96 refraction: Option<RefractionCorrection>,
97) -> Result<SolarPosition> {
98 let jd = JulianDate::from_datetime(&datetime, delta_t)?;
99 solar_position_from_julian(jd, latitude, longitude, elevation, refraction)
100}
101
102pub fn solar_position_from_julian(
138 jd: JulianDate,
139 latitude: f64,
140 longitude: f64,
141 elevation: f64,
142 refraction: Option<RefractionCorrection>,
143) -> Result<SolarPosition> {
144 let time_dependent = spa_time_dependent_from_julian(jd)?;
145 spa_with_time_dependent_parts(latitude, longitude, elevation, refraction, &time_dependent)
146}
147
148#[derive(Debug, Clone)]
153pub struct SpaTimeDependent {
154 pub(crate) r: f64,
156 pub(crate) nu_degrees: f64,
158 pub(crate) alpha_degrees: f64,
160 pub(crate) delta_degrees: f64,
162}
163
164impl SpaTimeDependent {
165 #[must_use]
167 pub const fn earth_radius_vector(&self) -> f64 {
168 self.r
169 }
170
171 #[must_use]
173 pub const fn right_ascension(&self) -> f64 {
174 self.alpha_degrees
175 }
176
177 #[must_use]
179 pub const fn declination(&self) -> f64 {
180 self.delta_degrees
181 }
182}
183
184#[derive(Debug, Clone, Copy)]
185struct DeltaPsiEpsilon {
186 delta_psi: f64,
187 delta_epsilon: f64,
188}
189
190fn calculate_lbr_polynomial(jme: f64, term_coeffs: &[&[&[f64; 3]]]) -> f64 {
192 let mut term_sums = [0.0; 6];
193
194 for (i, term_set) in term_coeffs.iter().enumerate() {
195 let mut sum = 0.0;
196 for term in *term_set {
197 sum += term[0] * cos(mul_add(term[2], jme, term[1]));
198 }
199 term_sums[i] = sum;
200 }
201
202 polynomial(&term_sums[..term_coeffs.len()], jme) / 1e8
203}
204
205fn lbr_to_normalized_degrees(jme: f64, term_coeffs: &[&[&[f64; 3]]]) -> f64 {
207 normalize_degrees_0_to_360(radians_to_degrees(calculate_lbr_polynomial(
208 jme,
209 term_coeffs,
210 )))
211}
212
213fn calculate_nutation_terms(jce: f64) -> [f64; 5] {
215 [
218 polynomial(NUTATION_COEFFS[0], jce),
219 polynomial(NUTATION_COEFFS[1], jce),
220 polynomial(NUTATION_COEFFS[2], jce),
221 polynomial(NUTATION_COEFFS[3], jce),
222 polynomial(NUTATION_COEFFS[4], jce),
223 ]
224}
225
226fn calculate_delta_psi_epsilon(jce: f64, x: &[f64; 5]) -> DeltaPsiEpsilon {
228 let mut delta_psi = 0.0;
229 let mut delta_epsilon = 0.0;
230
231 for (i, pe_term) in TERMS_PE.iter().enumerate() {
232 let mut xj_yterm_sum = 0.0;
233 for (j, &x_val) in x.iter().enumerate() {
234 xj_yterm_sum += x_val * f64::from(TERMS_Y[i][j]);
235 }
236 let xj_yterm_sum = degrees_to_radians(xj_yterm_sum);
237
238 let delta_psi_contrib = mul_add(pe_term[1], jce, pe_term[0]) * sin(xj_yterm_sum);
240 let delta_epsilon_contrib = mul_add(pe_term[3], jce, pe_term[2]) * cos(xj_yterm_sum);
241
242 delta_psi += delta_psi_contrib;
243 delta_epsilon += delta_epsilon_contrib;
244 }
245
246 DeltaPsiEpsilon {
247 delta_psi: delta_psi / 36_000_000.0,
248 delta_epsilon: delta_epsilon / 36_000_000.0,
249 }
250}
251
252fn calculate_true_obliquity_of_ecliptic(jd: &JulianDate, delta_epsilon: f64) -> f64 {
254 let epsilon0 = polynomial(OBLIQUITY_COEFFS, jd.julian_ephemeris_millennium() / 10.0);
255 epsilon0 / 3600.0 + delta_epsilon
256}
257
258fn calculate_apparent_sidereal_time_at_greenwich(
260 jd: &JulianDate,
261 delta_psi: f64,
262 epsilon_degrees: f64,
263) -> f64 {
264 let nu0_degrees = normalize_degrees_0_to_360(mul_add(
265 powi(jd.julian_century(), 2),
266 0.000387933 - jd.julian_century() / 38710000.0,
267 mul_add(
268 360.98564736629f64,
269 jd.julian_date() - 2451545.0,
270 280.46061837,
271 ),
272 ));
273
274 mul_add(
275 delta_psi,
276 cos(degrees_to_radians(epsilon_degrees)),
277 nu0_degrees,
278 )
279}
280
281fn calculate_geocentric_sun_coordinates(
283 beta_rad: f64,
284 epsilon_rad: f64,
285 lambda_rad: f64,
286) -> (f64, f64) {
287 let sin_lambda = sin(lambda_rad);
288 let cos_lambda = cos(lambda_rad);
289 let sin_epsilon = sin(epsilon_rad);
290 let cos_epsilon = cos(epsilon_rad);
291 let sin_beta = sin(beta_rad);
292 let cos_beta = cos(beta_rad);
293
294 let alpha = atan2(
295 mul_add(
296 sin_lambda,
297 cos_epsilon,
298 -(sin_beta / cos_beta) * sin_epsilon,
299 ),
300 cos_lambda,
301 );
302 let delta = asin(mul_add(
303 sin_beta,
304 cos_epsilon,
305 cos_beta * sin_epsilon * sin_lambda,
306 ));
307 (
308 normalize_degrees_0_to_360(radians_to_degrees(alpha)),
309 radians_to_degrees(delta),
310 )
311}
312
313pub fn sunrise_sunset_utc(
354 year: i32,
355 month: u32,
356 day: u32,
357 latitude: f64,
358 longitude: f64,
359 delta_t: f64,
360 elevation_angle: f64,
361) -> Result<crate::SunriseResult<crate::HoursUtc>> {
362 check_coordinates(latitude, longitude)?;
363 check_elevation_angle(elevation_angle)?;
364
365 let jd_midnight = JulianDate::from_utc(year, month, day, 0, 0, 0.0, delta_t)?;
367
368 Ok(calculate_sunrise_sunset_core(
370 jd_midnight,
371 latitude,
372 longitude,
373 delta_t,
374 elevation_angle,
375 ))
376}
377
378pub fn sunrise_sunset_utc_for_horizon(
420 year: i32,
421 month: u32,
422 day: u32,
423 latitude: f64,
424 longitude: f64,
425 delta_t: f64,
426 horizon: crate::Horizon,
427) -> Result<crate::SunriseResult<crate::HoursUtc>> {
428 sunrise_sunset_utc(
429 year,
430 month,
431 day,
432 latitude,
433 longitude,
434 delta_t,
435 horizon.elevation_angle(),
436 )
437}
438
439#[cfg(feature = "chrono")]
484#[cfg_attr(docsrs, doc(cfg(feature = "chrono")))]
485#[allow(clippy::needless_pass_by_value)]
486pub fn sunrise_sunset<Tz: TimeZone>(
487 date: DateTime<Tz>,
488 latitude: f64,
489 longitude: f64,
490 delta_t: f64,
491 elevation_angle: f64,
492) -> Result<crate::SunriseResult<DateTime<Tz>>> {
493 check_coordinates(latitude, longitude)?;
494
495 let tz = date.timezone();
496 let local_date = date.date_naive();
497 let base_utc_date_guess = local_date;
501 let (_base_utc_date, result) =
502 select_utc_date_by_transit(local_date, base_utc_date_guess, |d| {
503 let converted = match sunrise_sunset_utc(
504 d.year(),
505 d.month(),
506 d.day(),
507 latitude,
508 longitude,
509 delta_t,
510 elevation_angle,
511 )? {
512 crate::SunriseResult::RegularDay {
513 sunrise,
514 transit,
515 sunset,
516 } => crate::SunriseResult::RegularDay {
517 sunrise: hours_utc_to_datetime(&tz, d, sunrise),
518 transit: hours_utc_to_datetime(&tz, d, transit),
519 sunset: hours_utc_to_datetime(&tz, d, sunset),
520 },
521 crate::SunriseResult::AllDay { transit } => crate::SunriseResult::AllDay {
522 transit: hours_utc_to_datetime(&tz, d, transit),
523 },
524 crate::SunriseResult::AllNight { transit } => crate::SunriseResult::AllNight {
525 transit: hours_utc_to_datetime(&tz, d, transit),
526 },
527 };
528
529 let transit_local_date = converted.transit().date_naive();
530
531 Ok((transit_local_date, converted))
532 })?;
533
534 Ok(ensure_events_bracket_transit(result))
535}
536
537fn precompute_sunrise_sunset_for_jd_midnight(jd_midnight: JulianDate) -> (f64, [AlphaDelta; 3]) {
539 let jce_day = jd_midnight.julian_ephemeris_century();
541 let x_terms = calculate_nutation_terms(jce_day);
542 let delta_psi_epsilon = calculate_delta_psi_epsilon(jce_day, &x_terms);
543 let epsilon_degrees =
544 calculate_true_obliquity_of_ecliptic(&jd_midnight, delta_psi_epsilon.delta_epsilon);
545 let nu_degrees = calculate_apparent_sidereal_time_at_greenwich(
546 &jd_midnight,
547 delta_psi_epsilon.delta_psi,
548 epsilon_degrees,
549 );
550
551 let mut alpha_deltas = [AlphaDelta {
553 alpha: 0.0,
554 delta: 0.0,
555 }; 3];
556 for (i, alpha_delta) in alpha_deltas.iter_mut().enumerate() {
557 let current_jd = jd_midnight.add_days((i as f64) - 1.0);
558 let current_jme = current_jd.julian_ephemeris_millennium();
559 let current_jce = current_jd.julian_ephemeris_century();
560 let current_x_terms = calculate_nutation_terms(current_jce);
561 let current_delta_psi_epsilon = calculate_delta_psi_epsilon(current_jce, ¤t_x_terms);
562 let current_epsilon_degrees = calculate_true_obliquity_of_ecliptic(
563 ¤t_jd,
564 current_delta_psi_epsilon.delta_epsilon,
565 );
566 *alpha_delta = calculate_alpha_delta(
567 current_jme,
568 current_delta_psi_epsilon.delta_psi,
569 current_epsilon_degrees,
570 );
571 }
572
573 (nu_degrees, alpha_deltas)
574}
575
576fn calculate_sunrise_sunset_hours_with_precomputed(
577 latitude: f64,
578 longitude: f64,
579 delta_t: f64,
580 elevation_angle: f64,
581 nu_degrees: f64,
582 alpha_deltas: [AlphaDelta; 3],
583) -> crate::SunriseResult<crate::HoursUtc> {
584 let m0 = (alpha_deltas[1].alpha - longitude - nu_degrees) / 360.0;
585 let transit_m = rem_euclid(m0, 1.0);
586 let phi = degrees_to_radians(latitude);
587 let delta1_rad = degrees_to_radians(alpha_deltas[1].delta);
588 let elevation_rad = degrees_to_radians(elevation_angle);
589 let acos_arg =
590 mul_add(sin(phi), -sin(delta1_rad), sin(elevation_rad)) / (cos(phi) * cos(delta1_rad));
591
592 let polar_transit_hours =
593 calculate_transit_hours(transit_m, longitude, delta_t, nu_degrees, &alpha_deltas);
594
595 if acos_arg < -1.0 {
596 return crate::SunriseResult::AllDay {
597 transit: polar_transit_hours,
598 };
599 }
600 if acos_arg > 1.0 {
601 return crate::SunriseResult::AllNight {
602 transit: polar_transit_hours,
603 };
604 }
605
606 let h0_degrees = radians_to_degrees(acos(acos_arg));
607 let m_values = [
608 transit_m,
609 rem_euclid(m0 - h0_degrees / 360.0, 1.0),
610 rem_euclid(m0 + h0_degrees / 360.0, 1.0),
611 ];
612
613 let (t_frac, r_frac, s_frac) = calculate_final_time_fractions(
614 m_values,
615 nu_degrees,
616 delta_t,
617 latitude,
618 longitude,
619 elevation_angle,
620 alpha_deltas,
621 );
622
623 let (r_frac, s_frac) = bracket_event_fractions_around_transit(t_frac, r_frac, s_frac);
624
625 let transit_hours = crate::HoursUtc::from_hours(t_frac * 24.0);
626 let sunrise_hours = crate::HoursUtc::from_hours(r_frac * 24.0);
627 let sunset_hours = crate::HoursUtc::from_hours(s_frac * 24.0);
628
629 crate::SunriseResult::RegularDay {
630 sunrise: sunrise_hours,
631 transit: transit_hours,
632 sunset: sunset_hours,
633 }
634}
635
636fn bracket_event_fractions_around_transit(
637 transit: f64,
638 mut sunrise: f64,
639 mut sunset: f64,
640) -> (f64, f64) {
641 if sunrise > transit {
642 sunrise -= 1.0;
643 }
644
645 if sunset < transit {
646 sunset += 1.0;
647 }
648
649 (sunrise, sunset)
650}
651
652fn calculate_sunrise_sunset_core(
656 jd_midnight: JulianDate,
657 latitude: f64,
658 longitude: f64,
659 delta_t: f64,
660 elevation_angle: f64,
661) -> crate::SunriseResult<crate::HoursUtc> {
662 let (nu_degrees, alpha_deltas) = precompute_sunrise_sunset_for_jd_midnight(jd_midnight);
663 calculate_sunrise_sunset_hours_with_precomputed(
664 latitude,
665 longitude,
666 delta_t,
667 elevation_angle,
668 nu_degrees,
669 alpha_deltas,
670 )
671}
672
673fn calculate_transit_hours(
679 transit_m: f64,
680 longitude: f64,
681 delta_t: f64,
682 nu_degrees: f64,
683 alpha_deltas: &[AlphaDelta; 3],
684) -> crate::HoursUtc {
685 let transit_nu = mul_add(360.985647f64, transit_m, nu_degrees);
686 let transit_n = [transit_m + delta_t / 86400.0, 0.0, 0.0];
687 let transit_alpha_delta = calculate_interpolated_alpha_deltas(alpha_deltas, &transit_n)[0];
688 let transit_h_prime = limit_h_prime(transit_nu + longitude - transit_alpha_delta.alpha);
689 crate::HoursUtc::from_hours((transit_m - transit_h_prime / 360.0) * 24.0)
690}
691
692fn calculate_final_time_fractions(
695 m_values: [f64; 3],
696 nu_degrees: f64,
697 delta_t: f64,
698 latitude: f64,
699 longitude: f64,
700 elevation_angle: f64,
701 alpha_deltas: [AlphaDelta; 3],
702) -> (f64, f64, f64) {
703 let mut nu = [0.0; 3];
705 for (i, nu_item) in nu.iter_mut().enumerate() {
706 *nu_item = mul_add(360.985647f64, m_values[i], nu_degrees);
707 }
708
709 let mut n = [0.0; 3];
711 for (i, n_item) in n.iter_mut().enumerate() {
712 *n_item = m_values[i] + delta_t / 86400.0;
713 }
714
715 let alpha_delta_primes = calculate_interpolated_alpha_deltas(&alpha_deltas, &n);
717
718 let mut h_prime = [0.0; 3];
720 for i in 0..3 {
721 let h_prime_i = nu[i] + longitude - alpha_delta_primes[i].alpha;
722 h_prime[i] = limit_h_prime(h_prime_i);
723 }
724
725 let phi = degrees_to_radians(latitude);
727 let mut h = [0.0; 3];
728 for i in 0..3 {
729 let delta_prime_rad = degrees_to_radians(alpha_delta_primes[i].delta);
730 h[i] = radians_to_degrees(asin(mul_add(
731 sin(phi),
732 sin(delta_prime_rad),
733 cos(phi) * cos(delta_prime_rad) * cos(degrees_to_radians(h_prime[i])),
734 )));
735 }
736
737 let t = m_values[0] - h_prime[0] / 360.0;
739 let r = m_values[1]
740 + (h[1] - elevation_angle)
741 / (360.0
742 * cos(degrees_to_radians(alpha_delta_primes[1].delta))
743 * cos(phi)
744 * sin(degrees_to_radians(h_prime[1])));
745 let s = m_values[2]
746 + (h[2] - elevation_angle)
747 / (360.0
748 * cos(degrees_to_radians(alpha_delta_primes[2].delta))
749 * cos(phi)
750 * sin(degrees_to_radians(h_prime[2])));
751
752 (t, r, s)
753}
754
755fn calculate_interpolated_alpha_deltas(
757 alpha_deltas: &[AlphaDelta; 3],
758 n: &[f64; 3],
759) -> [AlphaDelta; 3] {
760 let a = limit_if_necessary(alpha_deltas[1].alpha - alpha_deltas[0].alpha);
761 let a_prime = limit_if_necessary(alpha_deltas[1].delta - alpha_deltas[0].delta);
762
763 let b = limit_if_necessary(alpha_deltas[2].alpha - alpha_deltas[1].alpha);
764 let b_prime = limit_if_necessary(alpha_deltas[2].delta - alpha_deltas[1].delta);
765
766 let c = b - a;
767 let c_prime = b_prime - a_prime;
768
769 let mut alpha_delta_primes = [AlphaDelta {
770 alpha: 0.0,
771 delta: 0.0,
772 }; 3];
773 for i in 0..3 {
774 alpha_delta_primes[i].alpha =
775 alpha_deltas[1].alpha + (n[i] * (mul_add(c, n[i], a + b))) / 2.0;
776 alpha_delta_primes[i].delta =
777 alpha_deltas[1].delta + (n[i] * (mul_add(c_prime, n[i], a_prime + b_prime))) / 2.0;
778 }
779 alpha_delta_primes
780}
781
782#[derive(Debug, Clone, Copy)]
783struct AlphaDelta {
784 alpha: f64,
785 delta: f64,
786}
787
788#[cfg(feature = "chrono")]
834#[cfg_attr(docsrs, doc(cfg(feature = "chrono")))]
835pub fn sunrise_sunset_for_horizon<Tz: TimeZone>(
836 date: DateTime<Tz>,
837 latitude: f64,
838 longitude: f64,
839 delta_t: f64,
840 horizon: Horizon,
841) -> Result<crate::SunriseResult<DateTime<Tz>>> {
842 sunrise_sunset(
843 date,
844 latitude,
845 longitude,
846 delta_t,
847 horizon.elevation_angle(),
848 )
849}
850
851fn calculate_alpha_delta(jme: f64, delta_psi: f64, epsilon_degrees: f64) -> AlphaDelta {
854 let b_degrees = lbr_to_normalized_degrees(jme, TERMS_B);
858
859 let r = calculate_lbr_polynomial(jme, TERMS_R);
861 assert!(
862 r != 0.0,
863 "Earth radius vector is zero - astronomical impossibility"
864 );
865
866 let l_degrees = lbr_to_normalized_degrees(jme, TERMS_L);
868
869 let theta_degrees = normalize_degrees_0_to_360(l_degrees + 180.0);
871
872 let beta_degrees = -b_degrees;
874 let beta = degrees_to_radians(beta_degrees);
875 let epsilon = degrees_to_radians(epsilon_degrees);
876
877 let delta_tau = ABERRATION_CONSTANT / (SECONDS_PER_HOUR * r);
879
880 let lambda_degrees = theta_degrees + delta_psi + delta_tau;
882 let lambda = degrees_to_radians(lambda_degrees);
883
884 let (alpha_degrees, delta_degrees) =
886 calculate_geocentric_sun_coordinates(beta, epsilon, lambda);
887
888 AlphaDelta {
889 alpha: alpha_degrees,
890 delta: delta_degrees,
891 }
892}
893
894#[cfg(feature = "chrono")]
895fn select_utc_date_by_transit<V, F>(
896 local_date: NaiveDate,
897 mut utc_date: NaiveDate,
898 mut compute: F,
899) -> Result<(NaiveDate, V)>
900where
901 F: FnMut(NaiveDate) -> Result<(NaiveDate, V)>,
902{
903 let (transit_local_date, value) = compute(utc_date)?;
904 if transit_local_date == local_date {
905 return Ok((utc_date, value));
906 }
907
908 utc_date = if transit_local_date > local_date {
909 utc_date.pred_opt().unwrap_or(utc_date)
910 } else {
911 utc_date.succ_opt().unwrap_or(utc_date)
912 };
913
914 let (transit_local_date, value) = compute(utc_date)?;
915 debug_assert_eq!(transit_local_date, local_date);
916 Ok((utc_date, value))
917}
918
919#[cfg(feature = "chrono")]
920fn hours_utc_to_datetime<Tz: TimeZone>(
921 tz: &Tz,
922 base_utc_date: NaiveDate,
923 hours: crate::HoursUtc,
924) -> DateTime<Tz> {
925 let base_utc_midnight = base_utc_date
926 .and_hms_opt(0, 0, 0)
927 .expect("midnight is always valid")
928 .and_utc();
929
930 let millis_plus = (hours.hours() * 3_600_000.0) as i64;
933 let utc_dt = base_utc_midnight + chrono::Duration::milliseconds(millis_plus);
934
935 tz.from_utc_datetime(&utc_dt.naive_utc())
936}
937
938#[cfg(feature = "chrono")]
939fn ensure_events_bracket_transit<Tz: TimeZone>(
940 result: crate::SunriseResult<DateTime<Tz>>,
941) -> crate::SunriseResult<DateTime<Tz>> {
942 let crate::SunriseResult::RegularDay {
943 mut sunrise,
944 transit,
945 mut sunset,
946 } = result
947 else {
948 return result;
949 };
950
951 if sunrise > transit {
953 sunrise -= chrono::Duration::days(1);
954 }
955
956 if sunset < transit {
957 sunset += chrono::Duration::days(1);
958 }
959
960 crate::SunriseResult::RegularDay {
961 sunrise,
962 transit,
963 sunset,
964 }
965}
966
967fn limit_if_necessary(val: f64) -> f64 {
969 if val.abs() > 2.0 {
970 rem_euclid(val, 1.0)
971 } else {
972 val
973 }
974}
975
976fn limit_h_prime(h_prime: f64) -> f64 {
978 let limited = rem_euclid(h_prime, 360.0);
979 if limited > 180.0 {
980 limited - 360.0
981 } else {
982 limited
983 }
984}
985
986#[cfg(feature = "chrono")]
1041#[cfg_attr(docsrs, doc(cfg(feature = "chrono")))]
1042#[allow(clippy::needless_pass_by_value)]
1043pub fn sunrise_sunset_multiple<Tz, H>(
1044 date: DateTime<Tz>,
1045 latitude: f64,
1046 longitude: f64,
1047 delta_t: f64,
1048 horizons: H,
1049) -> impl Iterator<Item = Result<(Horizon, crate::SunriseResult<DateTime<Tz>>)>>
1050where
1051 Tz: TimeZone,
1052 H: IntoIterator<Item = Horizon>,
1053{
1054 let tz = date.timezone();
1055 let local_date = date.date_naive();
1056 let base_utc_date_guess = local_date;
1060
1061 let precomputed = (|| -> Result<_> {
1063 check_coordinates(latitude, longitude)?;
1064 let (base_utc_date, (nu_degrees, alpha_deltas)) =
1065 select_utc_date_by_transit(local_date, base_utc_date_guess, |d| {
1066 let jd_midnight =
1067 JulianDate::from_utc(d.year(), d.month(), d.day(), 0, 0, 0.0, delta_t)?;
1068
1069 let (nu_degrees, alpha_deltas) =
1070 precompute_sunrise_sunset_for_jd_midnight(jd_midnight);
1071 let transit_m = rem_euclid(
1072 (alpha_deltas[1].alpha - longitude - nu_degrees) / 360.0,
1073 1.0,
1074 );
1075 let transit_hours = calculate_transit_hours(
1076 transit_m,
1077 longitude,
1078 delta_t,
1079 nu_degrees,
1080 &alpha_deltas,
1081 );
1082
1083 let transit_local_date = hours_utc_to_datetime(&tz, d, transit_hours).date_naive();
1084 Ok((transit_local_date, (nu_degrees, alpha_deltas)))
1085 })?;
1086
1087 Ok((base_utc_date, nu_degrees, alpha_deltas))
1088 })();
1089
1090 horizons.into_iter().map(move |horizon| {
1091 let (base_utc_date, nu_degrees, alpha_deltas) = precomputed.clone()?;
1092 let hours_result = calculate_sunrise_sunset_hours_with_precomputed(
1093 latitude,
1094 longitude,
1095 delta_t,
1096 horizon.elevation_angle(),
1097 nu_degrees,
1098 alpha_deltas,
1099 );
1100
1101 let result = ensure_events_bracket_transit(match hours_result {
1102 crate::SunriseResult::RegularDay {
1103 sunrise,
1104 transit,
1105 sunset,
1106 } => crate::SunriseResult::RegularDay {
1107 sunrise: hours_utc_to_datetime(&tz, base_utc_date, sunrise),
1108 transit: hours_utc_to_datetime(&tz, base_utc_date, transit),
1109 sunset: hours_utc_to_datetime(&tz, base_utc_date, sunset),
1110 },
1111 crate::SunriseResult::AllDay { transit } => crate::SunriseResult::AllDay {
1112 transit: hours_utc_to_datetime(&tz, base_utc_date, transit),
1113 },
1114 crate::SunriseResult::AllNight { transit } => crate::SunriseResult::AllNight {
1115 transit: hours_utc_to_datetime(&tz, base_utc_date, transit),
1116 },
1117 });
1118
1119 Ok((horizon, result))
1120 })
1121}
1122
1123#[cfg(feature = "chrono")]
1161#[cfg_attr(docsrs, doc(cfg(feature = "chrono")))]
1162#[allow(clippy::needless_pass_by_value)]
1163pub fn spa_time_dependent_parts<Tz: TimeZone>(
1164 datetime: DateTime<Tz>,
1165 delta_t: f64,
1166) -> Result<SpaTimeDependent> {
1167 let jd = JulianDate::from_datetime(&datetime, delta_t)?;
1168 spa_time_dependent_from_julian(jd)
1169}
1170
1171pub fn spa_time_dependent_from_julian(jd: JulianDate) -> Result<SpaTimeDependent> {
1181 let jme = jd.julian_ephemeris_millennium();
1182 let jce = jd.julian_ephemeris_century();
1183
1184 let l_degrees = lbr_to_normalized_degrees(jme, TERMS_L);
1186
1187 let b_degrees = lbr_to_normalized_degrees(jme, TERMS_B);
1189
1190 let r = calculate_lbr_polynomial(jme, TERMS_R);
1192
1193 assert!(
1195 r != 0.0,
1196 "Earth radius vector is zero - astronomical impossibility"
1197 );
1198
1199 let theta_degrees = normalize_degrees_0_to_360(l_degrees + 180.0);
1201 let beta_degrees = -b_degrees;
1203
1204 let x_terms = calculate_nutation_terms(jce);
1206 let delta_psi_epsilon = calculate_delta_psi_epsilon(jce, &x_terms);
1207
1208 let epsilon_degrees =
1210 calculate_true_obliquity_of_ecliptic(&jd, delta_psi_epsilon.delta_epsilon);
1211
1212 let delta_tau = ABERRATION_CONSTANT / (SECONDS_PER_HOUR * r);
1214
1215 let lambda_degrees = theta_degrees + delta_psi_epsilon.delta_psi + delta_tau;
1217
1218 let nu_degrees = calculate_apparent_sidereal_time_at_greenwich(
1220 &jd,
1221 delta_psi_epsilon.delta_psi,
1222 epsilon_degrees,
1223 );
1224
1225 let beta = degrees_to_radians(beta_degrees);
1227 let epsilon = degrees_to_radians(epsilon_degrees);
1228 let lambda = degrees_to_radians(lambda_degrees);
1229 let (alpha_degrees, delta_degrees) =
1230 calculate_geocentric_sun_coordinates(beta, epsilon, lambda);
1231
1232 Ok(SpaTimeDependent {
1233 r,
1234 nu_degrees,
1235 alpha_degrees,
1236 delta_degrees,
1237 })
1238}
1239
1240pub fn spa_with_time_dependent_parts(
1277 latitude: f64,
1278 longitude: f64,
1279 elevation: f64,
1280 refraction: Option<RefractionCorrection>,
1281 time_dependent: &SpaTimeDependent,
1282) -> Result<SolarPosition> {
1283 check_coordinates(latitude, longitude)?;
1284
1285 let nu_degrees = time_dependent.nu_degrees;
1288
1289 let h_degrees =
1291 normalize_degrees_0_to_360(nu_degrees + longitude - time_dependent.alpha_degrees);
1292 let h = degrees_to_radians(h_degrees);
1293
1294 let xi_degrees = 8.794 / (3600.0 * time_dependent.r);
1296 let xi = degrees_to_radians(xi_degrees);
1297 let phi = degrees_to_radians(latitude);
1298 let delta = degrees_to_radians(time_dependent.delta_degrees);
1299
1300 let u = atan(EARTH_FLATTENING_FACTOR * tan(phi));
1301 let y = mul_add(
1302 EARTH_FLATTENING_FACTOR,
1303 sin(u),
1304 (elevation / EARTH_RADIUS_METERS) * sin(phi),
1305 );
1306 let x = mul_add(elevation / EARTH_RADIUS_METERS, cos(phi), cos(u));
1307
1308 let delta_alpha_prime_degrees = radians_to_degrees(atan2(
1309 -x * sin(xi) * sin(h),
1310 mul_add(x * sin(xi), -cos(h), cos(delta)),
1311 ));
1312
1313 let delta_prime = radians_to_degrees(atan2(
1314 mul_add(y, -sin(xi), sin(delta)) * cos(degrees_to_radians(delta_alpha_prime_degrees)),
1315 mul_add(x * sin(xi), -cos(h), cos(delta)),
1316 ));
1317
1318 let h_prime_degrees = h_degrees - delta_alpha_prime_degrees;
1320
1321 let zenith_angle = radians_to_degrees(acos(mul_add(
1323 sin(degrees_to_radians(latitude)),
1324 sin(degrees_to_radians(delta_prime)),
1325 cos(degrees_to_radians(latitude))
1326 * cos(degrees_to_radians(delta_prime))
1327 * cos(degrees_to_radians(h_prime_degrees)),
1328 )));
1329
1330 let azimuth = normalize_degrees_0_to_360(
1332 180.0
1333 + radians_to_degrees(atan2(
1334 sin(degrees_to_radians(h_prime_degrees)),
1335 cos(degrees_to_radians(h_prime_degrees)) * sin(degrees_to_radians(latitude))
1336 - tan(degrees_to_radians(delta_prime)) * cos(degrees_to_radians(latitude)),
1337 )),
1338 );
1339
1340 let elevation_angle = 90.0 - zenith_angle;
1342 let final_zenith = refraction.map_or(zenith_angle, |correction| {
1343 if elevation_angle > Horizon::SunriseSunset.elevation_angle() {
1344 let pressure = correction.pressure();
1345 let temperature = correction.temperature();
1346 zenith_angle
1348 - (pressure / 1010.0) * (283.0 / (273.0 + temperature)) * 1.02
1349 / (60.0
1350 * tan(degrees_to_radians(
1351 elevation_angle + 10.3 / (elevation_angle + 5.11),
1352 )))
1353 } else {
1354 zenith_angle
1355 }
1356 });
1357
1358 SolarPosition::new(azimuth, final_zenith)
1359}
1360
1361#[cfg(all(test, feature = "chrono", feature = "std"))]
1362mod tests {
1363 use super::*;
1364 use chrono::{DateTime, FixedOffset};
1365
1366 fn angular_distance(a: f64, b: f64) -> f64 {
1367 let diff = (a - b).abs();
1368 diff.min(360.0 - diff)
1369 }
1370
1371 #[test]
1372 fn test_spa_basic_functionality() {
1373 let datetime = "2023-06-21T12:00:00Z"
1374 .parse::<DateTime<FixedOffset>>()
1375 .unwrap();
1376
1377 let result = solar_position(
1378 datetime,
1379 37.7749, -122.4194,
1381 0.0,
1382 69.0,
1383 Some(RefractionCorrection::new(1013.25, 15.0).unwrap()),
1384 );
1385
1386 assert!(result.is_ok());
1387 let position = result.unwrap();
1388 assert!(position.azimuth() >= 0.0 && position.azimuth() <= 360.0);
1389 assert!(position.zenith_angle() >= 0.0 && position.zenith_angle() <= 180.0);
1390 }
1391
1392 #[test]
1393 fn test_time_dependent_tracks_seasonal_geometry() {
1394 let june_solstice = spa_time_dependent_from_julian(
1395 JulianDate::from_utc(2023, 6, 21, 12, 0, 0.0, 69.0).unwrap(),
1396 )
1397 .unwrap();
1398 let december_solstice = spa_time_dependent_from_julian(
1399 JulianDate::from_utc(2023, 12, 22, 12, 0, 0.0, 69.0).unwrap(),
1400 )
1401 .unwrap();
1402 let march_equinox = spa_time_dependent_from_julian(
1403 JulianDate::from_utc(2023, 3, 20, 12, 0, 0.0, 69.0).unwrap(),
1404 )
1405 .unwrap();
1406
1407 assert!(june_solstice.declination() > 23.0);
1408 assert!(june_solstice.declination() < 24.0);
1409 assert!(angular_distance(june_solstice.right_ascension(), 90.0) < 2.0);
1410
1411 assert!(december_solstice.declination() < -23.0);
1412 assert!(december_solstice.declination() > -24.0);
1413 assert!(angular_distance(december_solstice.right_ascension(), 270.0) < 2.0);
1414
1415 assert!(march_equinox.declination().abs() < 1.0);
1416 }
1417
1418 #[test]
1419 fn test_time_dependent_earth_radius_vector_changes_over_year() {
1420 let near_perihelion = spa_time_dependent_from_julian(
1421 JulianDate::from_utc(2023, 1, 4, 12, 0, 0.0, 69.0).unwrap(),
1422 )
1423 .unwrap();
1424 let near_aphelion = spa_time_dependent_from_julian(
1425 JulianDate::from_utc(2023, 7, 4, 12, 0, 0.0, 69.0).unwrap(),
1426 )
1427 .unwrap();
1428
1429 assert!(near_perihelion.earth_radius_vector() > 0.98);
1430 assert!(near_perihelion.earth_radius_vector() < 0.99);
1431 assert!(near_aphelion.earth_radius_vector() > 1.01);
1432 assert!(near_aphelion.earth_radius_vector() < 1.02);
1433 assert!(near_aphelion.earth_radius_vector() > near_perihelion.earth_radius_vector());
1434 }
1435
1436 #[test]
1437 fn test_sunrise_sunset_multiple() {
1438 let datetime = "2023-06-21T12:00:00Z"
1439 .parse::<DateTime<FixedOffset>>()
1440 .unwrap();
1441 let horizons = [
1442 Horizon::SunriseSunset,
1443 Horizon::CivilTwilight,
1444 Horizon::NauticalTwilight,
1445 ];
1446
1447 let results: Result<Vec<_>> = sunrise_sunset_multiple(
1448 datetime,
1449 37.7749, -122.4194, 69.0, horizons.iter().copied(),
1453 )
1454 .collect();
1455
1456 let results = results.unwrap();
1457
1458 assert_eq!(results.len(), 3);
1460
1461 for expected_horizon in horizons {
1463 assert!(results.iter().any(|(h, _)| *h == expected_horizon));
1464 }
1465
1466 for (horizon, bulk_result) in &results {
1468 let individual_result =
1469 sunrise_sunset_for_horizon(datetime, 37.7749, -122.4194, 69.0, *horizon).unwrap();
1470
1471 match (&individual_result, bulk_result) {
1473 (
1474 crate::SunriseResult::RegularDay {
1475 sunrise: s1,
1476 transit: t1,
1477 sunset: ss1,
1478 },
1479 crate::SunriseResult::RegularDay {
1480 sunrise: s2,
1481 transit: t2,
1482 sunset: ss2,
1483 },
1484 ) => {
1485 assert_eq!(s1, s2);
1486 assert_eq!(t1, t2);
1487 assert_eq!(ss1, ss2);
1488 }
1489 (
1490 crate::SunriseResult::AllDay { transit: t1 },
1491 crate::SunriseResult::AllDay { transit: t2 },
1492 )
1493 | (
1494 crate::SunriseResult::AllNight { transit: t1 },
1495 crate::SunriseResult::AllNight { transit: t2 },
1496 ) => {
1497 assert_eq!(t1, t2);
1498 }
1499 _ => panic!("Bulk and individual results differ in type for {horizon:?}"),
1500 }
1501 }
1502 }
1503
1504 #[test]
1505 fn test_sunrise_sunset_multiple_polar_consistency() {
1506 let datetime = "2023-06-21T12:00:00Z"
1507 .parse::<DateTime<FixedOffset>>()
1508 .unwrap();
1509
1510 let individual = sunrise_sunset_for_horizon(
1511 datetime,
1512 80.0, 0.0,
1514 69.0,
1515 Horizon::SunriseSunset,
1516 )
1517 .unwrap();
1518
1519 let bulk_results: Result<Vec<_>> =
1520 sunrise_sunset_multiple(datetime, 80.0, 0.0, 69.0, [Horizon::SunriseSunset]).collect();
1521
1522 let (_, bulk) = bulk_results.unwrap().into_iter().next().unwrap();
1523
1524 match (bulk, individual) {
1525 (
1526 crate::SunriseResult::AllDay { transit: t1 },
1527 crate::SunriseResult::AllDay { transit: t2 },
1528 )
1529 | (
1530 crate::SunriseResult::AllNight { transit: t1 },
1531 crate::SunriseResult::AllNight { transit: t2 },
1532 ) => assert_eq!(t1, t2),
1533 _ => panic!("expected matching polar-day/night results between bulk and individual"),
1534 }
1535 }
1536
1537 #[test]
1538 fn test_spa_no_refraction() {
1539 let datetime = "2023-06-21T12:00:00Z"
1540 .parse::<DateTime<FixedOffset>>()
1541 .unwrap();
1542
1543 let result = solar_position(datetime, 37.7749, -122.4194, 0.0, 69.0, None);
1544
1545 assert!(result.is_ok());
1546 let position = result.unwrap();
1547 assert!(position.azimuth() >= 0.0 && position.azimuth() <= 360.0);
1548 assert!(position.zenith_angle() >= 0.0 && position.zenith_angle() <= 180.0);
1549 }
1550
1551 #[test]
1552 fn test_spa_coordinate_validation() {
1553 let datetime = "2023-06-21T12:00:00Z"
1554 .parse::<DateTime<FixedOffset>>()
1555 .unwrap();
1556
1557 assert!(solar_position(
1559 datetime,
1560 95.0,
1561 0.0,
1562 0.0,
1563 0.0,
1564 Some(RefractionCorrection::new(1013.25, 15.0).unwrap())
1565 )
1566 .is_err());
1567
1568 assert!(solar_position(
1570 datetime,
1571 0.0,
1572 185.0,
1573 0.0,
1574 0.0,
1575 Some(RefractionCorrection::new(1013.25, 15.0).unwrap())
1576 )
1577 .is_err());
1578 }
1579
1580 #[test]
1581 fn test_sunrise_sunset_basic() {
1582 let date = "2023-06-21T00:00:00Z"
1583 .parse::<DateTime<FixedOffset>>()
1584 .unwrap();
1585
1586 let result = sunrise_sunset(date, 37.7749, -122.4194, 69.0, -0.833);
1587 assert!(result.is_ok());
1588
1589 let result =
1590 sunrise_sunset_for_horizon(date, 37.7749, -122.4194, 69.0, Horizon::SunriseSunset);
1591 assert!(result.is_ok());
1592 }
1593
1594 #[test]
1595 fn test_horizon_enum() {
1596 assert_eq!(Horizon::SunriseSunset.elevation_angle(), -0.83337);
1597 assert_eq!(Horizon::CivilTwilight.elevation_angle(), -6.0);
1598 assert_eq!(Horizon::Custom(-10.5).elevation_angle(), -10.5);
1599 }
1600}