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, sin_cos, 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 = mul_add(term[0], cos(mul_add(term[2], jme, term[1])), sum);
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 = mul_add(x_val, f64::from(TERMS_Y[i][j]), xj_yterm_sum);
235 }
236 let xj_yterm_sum = degrees_to_radians(xj_yterm_sum);
237
238 let (sin_sum, cos_sum) = sin_cos(xj_yterm_sum);
240 let delta_psi_contrib = mul_add(pe_term[1], jce, pe_term[0]) * sin_sum;
241 let delta_epsilon_contrib = mul_add(pe_term[3], jce, pe_term[2]) * cos_sum;
242
243 delta_psi += delta_psi_contrib;
244 delta_epsilon += delta_epsilon_contrib;
245 }
246
247 DeltaPsiEpsilon {
248 delta_psi: delta_psi / 36_000_000.0,
249 delta_epsilon: delta_epsilon / 36_000_000.0,
250 }
251}
252
253fn calculate_true_obliquity_of_ecliptic(jd: &JulianDate, delta_epsilon: f64) -> f64 {
255 let epsilon0 = polynomial(OBLIQUITY_COEFFS, jd.julian_ephemeris_millennium() / 10.0);
256 epsilon0 / 3600.0 + delta_epsilon
257}
258
259fn calculate_apparent_sidereal_time_at_greenwich(
261 jd: &JulianDate,
262 delta_psi: f64,
263 epsilon_degrees: f64,
264) -> f64 {
265 let nu0_degrees = normalize_degrees_0_to_360(mul_add(
266 powi(jd.julian_century(), 2),
267 0.000387933 - jd.julian_century() / 38710000.0,
268 mul_add(
269 360.98564736629f64,
270 jd.julian_date() - 2451545.0,
271 280.46061837,
272 ),
273 ));
274
275 mul_add(
276 delta_psi,
277 cos(degrees_to_radians(epsilon_degrees)),
278 nu0_degrees,
279 )
280}
281
282fn calculate_geocentric_sun_coordinates(
284 beta_rad: f64,
285 epsilon_rad: f64,
286 lambda_rad: f64,
287) -> (f64, f64) {
288 let (sin_lambda, cos_lambda) = sin_cos(lambda_rad);
289 let (sin_epsilon, cos_epsilon) = sin_cos(epsilon_rad);
290 let (sin_beta, cos_beta) = sin_cos(beta_rad);
291
292 let alpha = atan2(
293 mul_add(
294 sin_lambda,
295 cos_epsilon,
296 -(sin_beta / cos_beta) * sin_epsilon,
297 ),
298 cos_lambda,
299 );
300 let delta = asin(mul_add(
301 sin_beta,
302 cos_epsilon,
303 cos_beta * sin_epsilon * sin_lambda,
304 ));
305 (
306 normalize_degrees_0_to_360(radians_to_degrees(alpha)),
307 radians_to_degrees(delta),
308 )
309}
310
311pub fn sunrise_sunset_utc(
352 year: i32,
353 month: u32,
354 day: u32,
355 latitude: f64,
356 longitude: f64,
357 delta_t: f64,
358 elevation_angle: f64,
359) -> Result<crate::SunriseResult<crate::HoursUtc>> {
360 check_coordinates(latitude, longitude)?;
361 check_elevation_angle(elevation_angle)?;
362
363 let jd_midnight = JulianDate::from_utc(year, month, day, 0, 0, 0.0, delta_t)?;
365
366 Ok(calculate_sunrise_sunset_core(
368 jd_midnight,
369 latitude,
370 longitude,
371 delta_t,
372 elevation_angle,
373 ))
374}
375
376pub fn sunrise_sunset_utc_for_horizon(
418 year: i32,
419 month: u32,
420 day: u32,
421 latitude: f64,
422 longitude: f64,
423 delta_t: f64,
424 horizon: crate::Horizon,
425) -> Result<crate::SunriseResult<crate::HoursUtc>> {
426 sunrise_sunset_utc(
427 year,
428 month,
429 day,
430 latitude,
431 longitude,
432 delta_t,
433 horizon.elevation_angle(),
434 )
435}
436
437#[cfg(feature = "chrono")]
482#[cfg_attr(docsrs, doc(cfg(feature = "chrono")))]
483#[allow(clippy::needless_pass_by_value)]
484pub fn sunrise_sunset<Tz: TimeZone>(
485 date: DateTime<Tz>,
486 latitude: f64,
487 longitude: f64,
488 delta_t: f64,
489 elevation_angle: f64,
490) -> Result<crate::SunriseResult<DateTime<Tz>>> {
491 check_coordinates(latitude, longitude)?;
492
493 let tz = date.timezone();
494 let local_date = date.date_naive();
495 let base_utc_date_guess = local_date;
499 let (_base_utc_date, result) =
500 select_utc_date_by_transit(local_date, base_utc_date_guess, |d| {
501 let converted = match sunrise_sunset_utc(
502 d.year(),
503 d.month(),
504 d.day(),
505 latitude,
506 longitude,
507 delta_t,
508 elevation_angle,
509 )? {
510 crate::SunriseResult::RegularDay {
511 sunrise,
512 transit,
513 sunset,
514 } => crate::SunriseResult::RegularDay {
515 sunrise: hours_utc_to_datetime(&tz, d, sunrise),
516 transit: hours_utc_to_datetime(&tz, d, transit),
517 sunset: hours_utc_to_datetime(&tz, d, sunset),
518 },
519 crate::SunriseResult::AllDay { transit } => crate::SunriseResult::AllDay {
520 transit: hours_utc_to_datetime(&tz, d, transit),
521 },
522 crate::SunriseResult::AllNight { transit } => crate::SunriseResult::AllNight {
523 transit: hours_utc_to_datetime(&tz, d, transit),
524 },
525 };
526
527 let transit_local_date = converted.transit().date_naive();
528
529 Ok((transit_local_date, converted))
530 })?;
531
532 Ok(ensure_events_bracket_transit(result))
533}
534
535fn precompute_sunrise_sunset_for_jd_midnight(jd_midnight: JulianDate) -> (f64, [AlphaDelta; 3]) {
537 let jce_day = jd_midnight.julian_ephemeris_century();
539 let x_terms = calculate_nutation_terms(jce_day);
540 let delta_psi_epsilon = calculate_delta_psi_epsilon(jce_day, &x_terms);
541 let epsilon_degrees =
542 calculate_true_obliquity_of_ecliptic(&jd_midnight, delta_psi_epsilon.delta_epsilon);
543 let nu_degrees = calculate_apparent_sidereal_time_at_greenwich(
544 &jd_midnight,
545 delta_psi_epsilon.delta_psi,
546 epsilon_degrees,
547 );
548
549 let mut alpha_deltas = [AlphaDelta {
551 alpha: 0.0,
552 delta: 0.0,
553 }; 3];
554 for (i, alpha_delta) in alpha_deltas.iter_mut().enumerate() {
555 let current_jd = jd_midnight.add_days((i as f64) - 1.0);
556 let current_jme = current_jd.julian_ephemeris_millennium();
557 let current_jce = current_jd.julian_ephemeris_century();
558 let current_x_terms = calculate_nutation_terms(current_jce);
559 let current_delta_psi_epsilon = calculate_delta_psi_epsilon(current_jce, ¤t_x_terms);
560 let current_epsilon_degrees = calculate_true_obliquity_of_ecliptic(
561 ¤t_jd,
562 current_delta_psi_epsilon.delta_epsilon,
563 );
564 *alpha_delta = calculate_alpha_delta(
565 current_jme,
566 current_delta_psi_epsilon.delta_psi,
567 current_epsilon_degrees,
568 );
569 }
570
571 (nu_degrees, alpha_deltas)
572}
573
574fn calculate_sunrise_sunset_hours_with_precomputed(
575 latitude: f64,
576 longitude: f64,
577 delta_t: f64,
578 elevation_angle: f64,
579 nu_degrees: f64,
580 alpha_deltas: [AlphaDelta; 3],
581) -> crate::SunriseResult<crate::HoursUtc> {
582 let m0 = (alpha_deltas[1].alpha - longitude - nu_degrees) / 360.0;
583 let transit_m = rem_euclid(m0, 1.0);
584 let phi = degrees_to_radians(latitude);
585 let delta1_rad = degrees_to_radians(alpha_deltas[1].delta);
586 let elevation_rad = degrees_to_radians(elevation_angle);
587 let (sin_phi, cos_phi) = sin_cos(phi);
588 let (sin_delta1, cos_delta1) = sin_cos(delta1_rad);
589 let acos_arg = mul_add(sin_phi, -sin_delta1, sin(elevation_rad)) / (cos_phi * cos_delta1);
590
591 let polar_transit_hours =
592 calculate_transit_hours(transit_m, longitude, delta_t, nu_degrees, &alpha_deltas);
593
594 if acos_arg < -1.0 {
595 return crate::SunriseResult::AllDay {
596 transit: polar_transit_hours,
597 };
598 }
599 if acos_arg > 1.0 {
600 return crate::SunriseResult::AllNight {
601 transit: polar_transit_hours,
602 };
603 }
604
605 let h0_degrees = radians_to_degrees(acos(acos_arg));
606 let m_values = [
607 transit_m,
608 rem_euclid(m0 - h0_degrees / 360.0, 1.0),
609 rem_euclid(m0 + h0_degrees / 360.0, 1.0),
610 ];
611
612 let (t_frac, r_frac, s_frac) = calculate_final_time_fractions(
613 m_values,
614 nu_degrees,
615 delta_t,
616 latitude,
617 longitude,
618 elevation_angle,
619 alpha_deltas,
620 );
621
622 let (r_frac, s_frac) = bracket_event_fractions_around_transit(t_frac, r_frac, s_frac);
623
624 let transit_hours = crate::HoursUtc::from_hours(t_frac * 24.0);
625 let sunrise_hours = crate::HoursUtc::from_hours(r_frac * 24.0);
626 let sunset_hours = crate::HoursUtc::from_hours(s_frac * 24.0);
627
628 crate::SunriseResult::RegularDay {
629 sunrise: sunrise_hours,
630 transit: transit_hours,
631 sunset: sunset_hours,
632 }
633}
634
635fn bracket_event_fractions_around_transit(
636 transit: f64,
637 mut sunrise: f64,
638 mut sunset: f64,
639) -> (f64, f64) {
640 if sunrise > transit {
641 sunrise -= 1.0;
642 }
643
644 if sunset < transit {
645 sunset += 1.0;
646 }
647
648 (sunrise, sunset)
649}
650
651fn calculate_sunrise_sunset_core(
655 jd_midnight: JulianDate,
656 latitude: f64,
657 longitude: f64,
658 delta_t: f64,
659 elevation_angle: f64,
660) -> crate::SunriseResult<crate::HoursUtc> {
661 let (nu_degrees, alpha_deltas) = precompute_sunrise_sunset_for_jd_midnight(jd_midnight);
662 calculate_sunrise_sunset_hours_with_precomputed(
663 latitude,
664 longitude,
665 delta_t,
666 elevation_angle,
667 nu_degrees,
668 alpha_deltas,
669 )
670}
671
672fn calculate_transit_hours(
678 transit_m: f64,
679 longitude: f64,
680 delta_t: f64,
681 nu_degrees: f64,
682 alpha_deltas: &[AlphaDelta; 3],
683) -> crate::HoursUtc {
684 let transit_nu = mul_add(360.985647f64, transit_m, nu_degrees);
685 let transit_n = [transit_m + delta_t / 86400.0, 0.0, 0.0];
686 let transit_alpha_delta = calculate_interpolated_alpha_deltas(alpha_deltas, &transit_n)[0];
687 let transit_h_prime = limit_h_prime(transit_nu + longitude - transit_alpha_delta.alpha);
688 crate::HoursUtc::from_hours((transit_m - transit_h_prime / 360.0) * 24.0)
689}
690
691fn calculate_final_time_fractions(
694 m_values: [f64; 3],
695 nu_degrees: f64,
696 delta_t: f64,
697 latitude: f64,
698 longitude: f64,
699 elevation_angle: f64,
700 alpha_deltas: [AlphaDelta; 3],
701) -> (f64, f64, f64) {
702 let mut nu = [0.0; 3];
704 for (i, nu_item) in nu.iter_mut().enumerate() {
705 *nu_item = mul_add(360.985647f64, m_values[i], nu_degrees);
706 }
707
708 let mut n = [0.0; 3];
710 for (i, n_item) in n.iter_mut().enumerate() {
711 *n_item = m_values[i] + delta_t / 86400.0;
712 }
713
714 let alpha_delta_primes = calculate_interpolated_alpha_deltas(&alpha_deltas, &n);
716
717 let mut h_prime = [0.0; 3];
719 for i in 0..3 {
720 let h_prime_i = nu[i] + longitude - alpha_delta_primes[i].alpha;
721 h_prime[i] = limit_h_prime(h_prime_i);
722 }
723
724 let phi = degrees_to_radians(latitude);
726 let mut h = [0.0; 3];
727 for i in 0..3 {
728 let delta_prime_rad = degrees_to_radians(alpha_delta_primes[i].delta);
729 h[i] = radians_to_degrees(asin(mul_add(
730 sin(phi),
731 sin(delta_prime_rad),
732 cos(phi) * cos(delta_prime_rad) * cos(degrees_to_radians(h_prime[i])),
733 )));
734 }
735
736 let t = m_values[0] - h_prime[0] / 360.0;
738 let r = m_values[1]
739 + (h[1] - elevation_angle)
740 / (360.0
741 * cos(degrees_to_radians(alpha_delta_primes[1].delta))
742 * cos(phi)
743 * sin(degrees_to_radians(h_prime[1])));
744 let s = m_values[2]
745 + (h[2] - elevation_angle)
746 / (360.0
747 * cos(degrees_to_radians(alpha_delta_primes[2].delta))
748 * cos(phi)
749 * sin(degrees_to_radians(h_prime[2])));
750
751 (t, r, s)
752}
753
754fn calculate_interpolated_alpha_deltas(
756 alpha_deltas: &[AlphaDelta; 3],
757 n: &[f64; 3],
758) -> [AlphaDelta; 3] {
759 let a = limit_if_necessary(alpha_deltas[1].alpha - alpha_deltas[0].alpha);
760 let a_prime = limit_if_necessary(alpha_deltas[1].delta - alpha_deltas[0].delta);
761
762 let b = limit_if_necessary(alpha_deltas[2].alpha - alpha_deltas[1].alpha);
763 let b_prime = limit_if_necessary(alpha_deltas[2].delta - alpha_deltas[1].delta);
764
765 let c = b - a;
766 let c_prime = b_prime - a_prime;
767
768 let mut alpha_delta_primes = [AlphaDelta {
769 alpha: 0.0,
770 delta: 0.0,
771 }; 3];
772 for i in 0..3 {
773 alpha_delta_primes[i].alpha =
774 alpha_deltas[1].alpha + (n[i] * (mul_add(c, n[i], a + b))) / 2.0;
775 alpha_delta_primes[i].delta =
776 alpha_deltas[1].delta + (n[i] * (mul_add(c_prime, n[i], a_prime + b_prime))) / 2.0;
777 }
778 alpha_delta_primes
779}
780
781#[derive(Debug, Clone, Copy)]
782struct AlphaDelta {
783 alpha: f64,
784 delta: f64,
785}
786
787#[cfg(feature = "chrono")]
833#[cfg_attr(docsrs, doc(cfg(feature = "chrono")))]
834pub fn sunrise_sunset_for_horizon<Tz: TimeZone>(
835 date: DateTime<Tz>,
836 latitude: f64,
837 longitude: f64,
838 delta_t: f64,
839 horizon: Horizon,
840) -> Result<crate::SunriseResult<DateTime<Tz>>> {
841 sunrise_sunset(
842 date,
843 latitude,
844 longitude,
845 delta_t,
846 horizon.elevation_angle(),
847 )
848}
849
850fn calculate_alpha_delta(jme: f64, delta_psi: f64, epsilon_degrees: f64) -> AlphaDelta {
853 let b_degrees = lbr_to_normalized_degrees(jme, TERMS_B);
857
858 let r = calculate_lbr_polynomial(jme, TERMS_R);
860
861 let l_degrees = lbr_to_normalized_degrees(jme, TERMS_L);
863
864 let theta_degrees = normalize_degrees_0_to_360(l_degrees + 180.0);
866
867 let beta_degrees = -b_degrees;
869 let beta = degrees_to_radians(beta_degrees);
870 let epsilon = degrees_to_radians(epsilon_degrees);
871
872 let delta_tau = ABERRATION_CONSTANT / (SECONDS_PER_HOUR * r);
874
875 let lambda_degrees = theta_degrees + delta_psi + delta_tau;
877 let lambda = degrees_to_radians(lambda_degrees);
878
879 let (alpha_degrees, delta_degrees) =
881 calculate_geocentric_sun_coordinates(beta, epsilon, lambda);
882
883 AlphaDelta {
884 alpha: alpha_degrees,
885 delta: delta_degrees,
886 }
887}
888
889#[cfg(feature = "chrono")]
890fn select_utc_date_by_transit<V, F>(
891 local_date: NaiveDate,
892 mut utc_date: NaiveDate,
893 mut compute: F,
894) -> Result<(NaiveDate, V)>
895where
896 F: FnMut(NaiveDate) -> Result<(NaiveDate, V)>,
897{
898 let (transit_local_date, value) = compute(utc_date)?;
899 if transit_local_date == local_date {
900 return Ok((utc_date, value));
901 }
902
903 utc_date = if transit_local_date > local_date {
904 utc_date.pred_opt().unwrap_or(utc_date)
905 } else {
906 utc_date.succ_opt().unwrap_or(utc_date)
907 };
908
909 let (transit_local_date, value) = compute(utc_date)?;
910 debug_assert_eq!(transit_local_date, local_date);
911 Ok((utc_date, value))
912}
913
914#[cfg(feature = "chrono")]
915fn hours_utc_to_datetime<Tz: TimeZone>(
916 tz: &Tz,
917 base_utc_date: NaiveDate,
918 hours: crate::HoursUtc,
919) -> DateTime<Tz> {
920 let base_utc_midnight = base_utc_date
921 .and_hms_opt(0, 0, 0)
922 .expect("midnight is always valid")
923 .and_utc();
924
925 let millis_plus = (hours.hours() * 3_600_000.0) as i64;
928 let utc_dt = base_utc_midnight + chrono::Duration::milliseconds(millis_plus);
929
930 tz.from_utc_datetime(&utc_dt.naive_utc())
931}
932
933#[cfg(feature = "chrono")]
934fn ensure_events_bracket_transit<Tz: TimeZone>(
935 result: crate::SunriseResult<DateTime<Tz>>,
936) -> crate::SunriseResult<DateTime<Tz>> {
937 let crate::SunriseResult::RegularDay {
938 mut sunrise,
939 transit,
940 mut sunset,
941 } = result
942 else {
943 return result;
944 };
945
946 if sunrise > transit {
948 sunrise -= chrono::Duration::days(1);
949 }
950
951 if sunset < transit {
952 sunset += chrono::Duration::days(1);
953 }
954
955 crate::SunriseResult::RegularDay {
956 sunrise,
957 transit,
958 sunset,
959 }
960}
961
962fn limit_if_necessary(val: f64) -> f64 {
964 if val.abs() > 2.0 {
965 rem_euclid(val, 1.0)
966 } else {
967 val
968 }
969}
970
971fn limit_h_prime(h_prime: f64) -> f64 {
973 let limited = rem_euclid(h_prime, 360.0);
974 if limited > 180.0 {
975 limited - 360.0
976 } else {
977 limited
978 }
979}
980
981#[cfg(feature = "chrono")]
1037#[cfg_attr(docsrs, doc(cfg(feature = "chrono")))]
1038#[allow(clippy::needless_pass_by_value)]
1039pub fn sunrise_sunset_multiple<Tz, H>(
1040 date: DateTime<Tz>,
1041 latitude: f64,
1042 longitude: f64,
1043 delta_t: f64,
1044 horizons: H,
1045) -> impl Iterator<Item = Result<(Horizon, crate::SunriseResult<DateTime<Tz>>)>>
1046where
1047 Tz: TimeZone,
1048 H: IntoIterator<Item = Horizon>,
1049{
1050 let tz = date.timezone();
1051 let local_date = date.date_naive();
1052 let base_utc_date_guess = local_date;
1056
1057 let precomputed = (|| -> Result<_> {
1059 check_coordinates(latitude, longitude)?;
1060 let (base_utc_date, (nu_degrees, alpha_deltas)) =
1061 select_utc_date_by_transit(local_date, base_utc_date_guess, |d| {
1062 let jd_midnight =
1063 JulianDate::from_utc(d.year(), d.month(), d.day(), 0, 0, 0.0, delta_t)?;
1064
1065 let (nu_degrees, alpha_deltas) =
1066 precompute_sunrise_sunset_for_jd_midnight(jd_midnight);
1067 let transit_m = rem_euclid(
1068 (alpha_deltas[1].alpha - longitude - nu_degrees) / 360.0,
1069 1.0,
1070 );
1071 let transit_hours = calculate_transit_hours(
1072 transit_m,
1073 longitude,
1074 delta_t,
1075 nu_degrees,
1076 &alpha_deltas,
1077 );
1078
1079 let transit_local_date = hours_utc_to_datetime(&tz, d, transit_hours).date_naive();
1080 Ok((transit_local_date, (nu_degrees, alpha_deltas)))
1081 })?;
1082
1083 Ok((base_utc_date, nu_degrees, alpha_deltas))
1084 })();
1085
1086 horizons.into_iter().map(move |horizon| {
1087 let (base_utc_date, nu_degrees, alpha_deltas) = precomputed.clone()?;
1088 let elevation_angle = horizon.elevation_angle();
1089 check_elevation_angle(elevation_angle)?;
1090 let hours_result = calculate_sunrise_sunset_hours_with_precomputed(
1091 latitude,
1092 longitude,
1093 delta_t,
1094 elevation_angle,
1095 nu_degrees,
1096 alpha_deltas,
1097 );
1098
1099 let result = ensure_events_bracket_transit(match hours_result {
1100 crate::SunriseResult::RegularDay {
1101 sunrise,
1102 transit,
1103 sunset,
1104 } => crate::SunriseResult::RegularDay {
1105 sunrise: hours_utc_to_datetime(&tz, base_utc_date, sunrise),
1106 transit: hours_utc_to_datetime(&tz, base_utc_date, transit),
1107 sunset: hours_utc_to_datetime(&tz, base_utc_date, sunset),
1108 },
1109 crate::SunriseResult::AllDay { transit } => crate::SunriseResult::AllDay {
1110 transit: hours_utc_to_datetime(&tz, base_utc_date, transit),
1111 },
1112 crate::SunriseResult::AllNight { transit } => crate::SunriseResult::AllNight {
1113 transit: hours_utc_to_datetime(&tz, base_utc_date, transit),
1114 },
1115 });
1116
1117 Ok((horizon, result))
1118 })
1119}
1120
1121#[cfg(feature = "chrono")]
1157#[cfg_attr(docsrs, doc(cfg(feature = "chrono")))]
1158#[allow(clippy::needless_pass_by_value)]
1159pub fn spa_time_dependent_parts<Tz: TimeZone>(
1160 datetime: DateTime<Tz>,
1161 delta_t: f64,
1162) -> Result<SpaTimeDependent> {
1163 let jd = JulianDate::from_datetime(&datetime, delta_t)?;
1164 spa_time_dependent_from_julian(jd)
1165}
1166
1167pub fn spa_time_dependent_from_julian(jd: JulianDate) -> Result<SpaTimeDependent> {
1174 let jme = jd.julian_ephemeris_millennium();
1175 let jce = jd.julian_ephemeris_century();
1176
1177 let l_degrees = lbr_to_normalized_degrees(jme, TERMS_L);
1179
1180 let b_degrees = lbr_to_normalized_degrees(jme, TERMS_B);
1182
1183 let r = calculate_lbr_polynomial(jme, TERMS_R);
1185
1186 let theta_degrees = normalize_degrees_0_to_360(l_degrees + 180.0);
1188 let beta_degrees = -b_degrees;
1190
1191 let x_terms = calculate_nutation_terms(jce);
1193 let delta_psi_epsilon = calculate_delta_psi_epsilon(jce, &x_terms);
1194
1195 let epsilon_degrees =
1197 calculate_true_obliquity_of_ecliptic(&jd, delta_psi_epsilon.delta_epsilon);
1198
1199 let delta_tau = ABERRATION_CONSTANT / (SECONDS_PER_HOUR * r);
1201
1202 let lambda_degrees = theta_degrees + delta_psi_epsilon.delta_psi + delta_tau;
1204
1205 let nu_degrees = calculate_apparent_sidereal_time_at_greenwich(
1207 &jd,
1208 delta_psi_epsilon.delta_psi,
1209 epsilon_degrees,
1210 );
1211
1212 let beta = degrees_to_radians(beta_degrees);
1214 let epsilon = degrees_to_radians(epsilon_degrees);
1215 let lambda = degrees_to_radians(lambda_degrees);
1216 let (alpha_degrees, delta_degrees) =
1217 calculate_geocentric_sun_coordinates(beta, epsilon, lambda);
1218
1219 Ok(SpaTimeDependent {
1220 r,
1221 nu_degrees,
1222 alpha_degrees,
1223 delta_degrees,
1224 })
1225}
1226
1227pub fn spa_with_time_dependent_parts(
1264 latitude: f64,
1265 longitude: f64,
1266 elevation: f64,
1267 refraction: Option<RefractionCorrection>,
1268 time_dependent: &SpaTimeDependent,
1269) -> Result<SolarPosition> {
1270 check_coordinates(latitude, longitude)?;
1271
1272 let nu_degrees = time_dependent.nu_degrees;
1275
1276 let h_degrees =
1278 normalize_degrees_0_to_360(nu_degrees + longitude - time_dependent.alpha_degrees);
1279 let h = degrees_to_radians(h_degrees);
1280
1281 let xi_degrees = 8.794 / (3600.0 * time_dependent.r);
1283 let xi = degrees_to_radians(xi_degrees);
1284 let phi = degrees_to_radians(latitude);
1285 let delta = degrees_to_radians(time_dependent.delta_degrees);
1286 let sin_xi = sin(xi);
1287 let (sin_phi, cos_phi) = sin_cos(phi);
1288 let (sin_delta, cos_delta) = sin_cos(delta);
1289 let (sin_h, cos_h) = sin_cos(h);
1290
1291 let u = atan(EARTH_FLATTENING_FACTOR * tan(phi));
1292 let (sin_u, cos_u) = sin_cos(u);
1293 let y = mul_add(
1294 EARTH_FLATTENING_FACTOR,
1295 sin_u,
1296 (elevation / EARTH_RADIUS_METERS) * sin_phi,
1297 );
1298 let x = mul_add(elevation / EARTH_RADIUS_METERS, cos_phi, cos_u);
1299
1300 let delta_alpha_prime_degrees = radians_to_degrees(atan2(
1301 -x * sin_xi * sin_h,
1302 mul_add(x * sin_xi, -cos_h, cos_delta),
1303 ));
1304
1305 let delta_prime_degrees = radians_to_degrees(atan2(
1306 mul_add(y, -sin_xi, sin_delta) * cos(degrees_to_radians(delta_alpha_prime_degrees)),
1307 mul_add(x * sin_xi, -cos_h, cos_delta),
1308 ));
1309
1310 let h_prime_degrees = h_degrees - delta_alpha_prime_degrees;
1312 let delta_prime = degrees_to_radians(delta_prime_degrees);
1313 let h_prime = degrees_to_radians(h_prime_degrees);
1314 let (sin_delta_prime, cos_delta_prime) = sin_cos(delta_prime);
1315 let (sin_h_prime, cos_h_prime) = sin_cos(h_prime);
1316
1317 let zenith_angle = radians_to_degrees(acos(mul_add(
1319 sin_phi,
1320 sin_delta_prime,
1321 cos_phi * cos_delta_prime * cos_h_prime,
1322 )));
1323
1324 let azimuth = normalize_degrees_0_to_360(
1326 180.0
1327 + radians_to_degrees(atan2(
1328 sin_h_prime,
1329 mul_add(
1330 sin_delta_prime / cos_delta_prime,
1331 -cos_phi,
1332 cos_h_prime * sin_phi,
1333 ),
1334 )),
1335 );
1336
1337 let elevation_angle = 90.0 - zenith_angle;
1339 let final_zenith = refraction.map_or(zenith_angle, |correction| {
1340 if elevation_angle > Horizon::SunriseSunset.elevation_angle() {
1341 let pressure = correction.pressure();
1342 let temperature = correction.temperature();
1343 zenith_angle
1345 - (pressure / 1010.0) * (283.0 / (273.0 + temperature)) * 1.02
1346 / (60.0
1347 * tan(degrees_to_radians(
1348 elevation_angle + 10.3 / (elevation_angle + 5.11),
1349 )))
1350 } else {
1351 zenith_angle
1352 }
1353 });
1354
1355 SolarPosition::new(azimuth, final_zenith)
1356}
1357
1358#[cfg(all(test, feature = "chrono", feature = "std"))]
1359mod tests {
1360 use super::*;
1361 use chrono::{DateTime, FixedOffset};
1362
1363 fn angular_distance(a: f64, b: f64) -> f64 {
1364 let diff = (a - b).abs();
1365 diff.min(360.0 - diff)
1366 }
1367
1368 #[test]
1369 fn test_spa_basic_functionality() {
1370 let datetime = "2023-06-21T12:00:00Z"
1371 .parse::<DateTime<FixedOffset>>()
1372 .unwrap();
1373
1374 let result = solar_position(
1375 datetime,
1376 37.7749, -122.4194,
1378 0.0,
1379 69.0,
1380 Some(RefractionCorrection::new(1013.25, 15.0).unwrap()),
1381 );
1382
1383 assert!(result.is_ok());
1384 let position = result.unwrap();
1385 assert!(position.azimuth() >= 0.0 && position.azimuth() <= 360.0);
1386 assert!(position.zenith_angle() >= 0.0 && position.zenith_angle() <= 180.0);
1387 }
1388
1389 #[test]
1390 fn test_time_dependent_tracks_seasonal_geometry() {
1391 let june_solstice = spa_time_dependent_from_julian(
1392 JulianDate::from_utc(2023, 6, 21, 12, 0, 0.0, 69.0).unwrap(),
1393 )
1394 .unwrap();
1395 let december_solstice = spa_time_dependent_from_julian(
1396 JulianDate::from_utc(2023, 12, 22, 12, 0, 0.0, 69.0).unwrap(),
1397 )
1398 .unwrap();
1399 let march_equinox = spa_time_dependent_from_julian(
1400 JulianDate::from_utc(2023, 3, 20, 12, 0, 0.0, 69.0).unwrap(),
1401 )
1402 .unwrap();
1403
1404 assert!(june_solstice.declination() > 23.0);
1405 assert!(june_solstice.declination() < 24.0);
1406 assert!(angular_distance(june_solstice.right_ascension(), 90.0) < 2.0);
1407
1408 assert!(december_solstice.declination() < -23.0);
1409 assert!(december_solstice.declination() > -24.0);
1410 assert!(angular_distance(december_solstice.right_ascension(), 270.0) < 2.0);
1411
1412 assert!(march_equinox.declination().abs() < 1.0);
1413 }
1414
1415 #[test]
1416 fn test_time_dependent_earth_radius_vector_changes_over_year() {
1417 let near_perihelion = spa_time_dependent_from_julian(
1418 JulianDate::from_utc(2023, 1, 4, 12, 0, 0.0, 69.0).unwrap(),
1419 )
1420 .unwrap();
1421 let near_aphelion = spa_time_dependent_from_julian(
1422 JulianDate::from_utc(2023, 7, 4, 12, 0, 0.0, 69.0).unwrap(),
1423 )
1424 .unwrap();
1425
1426 assert!(near_perihelion.earth_radius_vector() > 0.98);
1427 assert!(near_perihelion.earth_radius_vector() < 0.99);
1428 assert!(near_aphelion.earth_radius_vector() > 1.01);
1429 assert!(near_aphelion.earth_radius_vector() < 1.02);
1430 assert!(near_aphelion.earth_radius_vector() > near_perihelion.earth_radius_vector());
1431 }
1432
1433 #[test]
1434 fn test_sunrise_sunset_multiple() {
1435 let datetime = "2023-06-21T12:00:00Z"
1436 .parse::<DateTime<FixedOffset>>()
1437 .unwrap();
1438 let horizons = [
1439 Horizon::SunriseSunset,
1440 Horizon::CivilTwilight,
1441 Horizon::NauticalTwilight,
1442 ];
1443
1444 let results: Result<Vec<_>> = sunrise_sunset_multiple(
1445 datetime,
1446 37.7749, -122.4194, 69.0, horizons.iter().copied(),
1450 )
1451 .collect();
1452
1453 let results = results.unwrap();
1454
1455 assert_eq!(results.len(), 3);
1457
1458 for expected_horizon in horizons {
1460 assert!(results.iter().any(|(h, _)| *h == expected_horizon));
1461 }
1462
1463 for (horizon, bulk_result) in &results {
1465 let individual_result =
1466 sunrise_sunset_for_horizon(datetime, 37.7749, -122.4194, 69.0, *horizon).unwrap();
1467
1468 match (&individual_result, bulk_result) {
1470 (
1471 crate::SunriseResult::RegularDay {
1472 sunrise: s1,
1473 transit: t1,
1474 sunset: ss1,
1475 },
1476 crate::SunriseResult::RegularDay {
1477 sunrise: s2,
1478 transit: t2,
1479 sunset: ss2,
1480 },
1481 ) => {
1482 assert_eq!(s1, s2);
1483 assert_eq!(t1, t2);
1484 assert_eq!(ss1, ss2);
1485 }
1486 (
1487 crate::SunriseResult::AllDay { transit: t1 },
1488 crate::SunriseResult::AllDay { transit: t2 },
1489 )
1490 | (
1491 crate::SunriseResult::AllNight { transit: t1 },
1492 crate::SunriseResult::AllNight { transit: t2 },
1493 ) => {
1494 assert_eq!(t1, t2);
1495 }
1496 _ => panic!("Bulk and individual results differ in type for {horizon:?}"),
1497 }
1498 }
1499 }
1500
1501 #[test]
1502 fn test_sunrise_sunset_multiple_polar_consistency() {
1503 let datetime = "2023-06-21T12:00:00Z"
1504 .parse::<DateTime<FixedOffset>>()
1505 .unwrap();
1506
1507 let individual = sunrise_sunset_for_horizon(
1508 datetime,
1509 80.0, 0.0,
1511 69.0,
1512 Horizon::SunriseSunset,
1513 )
1514 .unwrap();
1515
1516 let bulk_results: Result<Vec<_>> =
1517 sunrise_sunset_multiple(datetime, 80.0, 0.0, 69.0, [Horizon::SunriseSunset]).collect();
1518
1519 let (_, bulk) = bulk_results.unwrap().into_iter().next().unwrap();
1520
1521 match (bulk, individual) {
1522 (
1523 crate::SunriseResult::AllDay { transit: t1 },
1524 crate::SunriseResult::AllDay { transit: t2 },
1525 )
1526 | (
1527 crate::SunriseResult::AllNight { transit: t1 },
1528 crate::SunriseResult::AllNight { transit: t2 },
1529 ) => assert_eq!(t1, t2),
1530 _ => panic!("expected matching polar-day/night results between bulk and individual"),
1531 }
1532 }
1533
1534 #[test]
1535 fn test_sunrise_sunset_multiple_rejects_invalid_custom_horizons() {
1536 let datetime = "2023-06-21T12:00:00Z"
1537 .parse::<DateTime<FixedOffset>>()
1538 .unwrap();
1539
1540 for horizon in [Horizon::Custom(91.0), Horizon::Custom(f64::NAN)] {
1541 let result = sunrise_sunset_multiple(datetime, 37.7749, -122.4194, 69.0, [horizon])
1542 .next()
1543 .unwrap();
1544
1545 assert!(matches!(
1546 result,
1547 Err(crate::Error::InvalidElevationAngle { .. })
1548 ));
1549 }
1550 }
1551
1552 #[test]
1553 fn test_spa_no_refraction() {
1554 let datetime = "2023-06-21T12:00:00Z"
1555 .parse::<DateTime<FixedOffset>>()
1556 .unwrap();
1557
1558 let result = solar_position(datetime, 37.7749, -122.4194, 0.0, 69.0, None);
1559
1560 assert!(result.is_ok());
1561 let position = result.unwrap();
1562 assert!(position.azimuth() >= 0.0 && position.azimuth() <= 360.0);
1563 assert!(position.zenith_angle() >= 0.0 && position.zenith_angle() <= 180.0);
1564 }
1565
1566 #[test]
1567 fn test_spa_coordinate_validation() {
1568 let datetime = "2023-06-21T12:00:00Z"
1569 .parse::<DateTime<FixedOffset>>()
1570 .unwrap();
1571
1572 assert!(solar_position(
1574 datetime,
1575 95.0,
1576 0.0,
1577 0.0,
1578 0.0,
1579 Some(RefractionCorrection::new(1013.25, 15.0).unwrap())
1580 )
1581 .is_err());
1582
1583 assert!(solar_position(
1585 datetime,
1586 0.0,
1587 185.0,
1588 0.0,
1589 0.0,
1590 Some(RefractionCorrection::new(1013.25, 15.0).unwrap())
1591 )
1592 .is_err());
1593 }
1594
1595 #[test]
1596 fn test_sunrise_sunset_basic() {
1597 let date = "2023-06-21T00:00:00Z"
1598 .parse::<DateTime<FixedOffset>>()
1599 .unwrap();
1600
1601 let result = sunrise_sunset(date, 37.7749, -122.4194, 69.0, -0.833);
1602 assert!(result.is_ok());
1603
1604 let result =
1605 sunrise_sunset_for_horizon(date, 37.7749, -122.4194, 69.0, Horizon::SunriseSunset);
1606 assert!(result.is_ok());
1607 }
1608
1609 #[test]
1610 fn test_horizon_enum() {
1611 assert_eq!(Horizon::SunriseSunset.elevation_angle(), -0.83337);
1612 assert_eq!(Horizon::CivilTwilight.elevation_angle(), -6.0);
1613 assert_eq!(Horizon::Custom(-10.5).elevation_angle(), -10.5);
1614 }
1615}