1#![allow(clippy::similar_names)]
10#![allow(clippy::many_single_char_names)]
11#![allow(clippy::unreadable_literal)]
12
13use crate::error::check_coordinates;
14use crate::math::{
15 acos, asin, atan, atan2, cos, degrees_to_radians, floor, mul_add, normalize_degrees_0_to_360,
16 polynomial, powi, radians_to_degrees, sin, tan,
17};
18use crate::time::JulianDate;
19#[cfg(feature = "chrono")]
20use crate::Horizon;
21use crate::{RefractionCorrection, Result, SolarPosition};
22
23pub mod coefficients;
24use coefficients::{
25 NUTATION_COEFFS, OBLIQUITY_COEFFS, TERMS_B, TERMS_L, TERMS_PE, TERMS_R, TERMS_Y,
26};
27
28#[cfg(feature = "chrono")]
29use chrono::{DateTime, LocalResult, TimeZone, Utc};
30
31const SUNRISE_SUNSET_ANGLE: f64 = -0.83337;
33
34const ABERRATION_CONSTANT: f64 = -20.4898;
36
37const EARTH_FLATTENING_FACTOR: f64 = 0.99664719;
39
40const EARTH_RADIUS_METERS: f64 = 6378140.0;
42
43const SECONDS_PER_HOUR: f64 = 3600.0;
45
46#[cfg(feature = "chrono")]
93#[cfg_attr(docsrs, doc(cfg(feature = "chrono")))]
94#[allow(clippy::needless_pass_by_value)]
95pub fn solar_position<Tz: TimeZone>(
96 datetime: DateTime<Tz>,
97 latitude: f64,
98 longitude: f64,
99 elevation: f64,
100 delta_t: f64,
101 refraction: Option<RefractionCorrection>,
102) -> Result<SolarPosition> {
103 let jd = JulianDate::from_datetime(&datetime, delta_t)?;
104 solar_position_from_julian(jd, latitude, longitude, elevation, refraction)
105}
106
107pub fn solar_position_from_julian(
143 jd: JulianDate,
144 latitude: f64,
145 longitude: f64,
146 elevation: f64,
147 refraction: Option<RefractionCorrection>,
148) -> Result<SolarPosition> {
149 let time_dependent = spa_time_dependent_from_julian(jd)?;
150 spa_with_time_dependent_parts(latitude, longitude, elevation, refraction, &time_dependent)
151}
152
153#[derive(Debug, Clone)]
158#[allow(dead_code)]
159pub struct SpaTimeDependent {
160 pub(crate) theta_degrees: f64,
162 pub(crate) beta_degrees: f64,
164 pub(crate) r: f64,
166 pub(crate) delta_psi: f64,
168 pub(crate) epsilon_degrees: f64,
170 pub(crate) lambda_degrees: f64,
172 pub(crate) nu_degrees: f64,
174 pub(crate) alpha_degrees: f64,
176 pub(crate) delta_degrees: f64,
178}
179
180#[derive(Debug, Clone, Copy)]
181struct DeltaPsiEpsilon {
182 delta_psi: f64,
183 delta_epsilon: f64,
184}
185
186fn calculate_lbr_terms(jme: f64, term_coeffs: &[&[&[f64; 3]]]) -> [f64; 6] {
188 let mut lbr_terms = [0.0; 6];
191
192 for (i, term_set) in term_coeffs.iter().enumerate().take(6) {
193 let mut lbr_sum = 0.0;
194 for term in *term_set {
195 lbr_sum += term[0] * cos(mul_add(term[2], jme, term[1]));
196 }
197 lbr_terms[i] = lbr_sum;
198 }
199
200 lbr_terms
201}
202
203fn calculate_lbr_polynomial(jme: f64, terms: &[f64], num_terms: usize) -> f64 {
205 polynomial(&terms[..num_terms], jme) / 1e8
206}
207
208fn lbr_to_normalized_degrees(jme: f64, terms: &[f64], num_terms: usize) -> f64 {
210 normalize_degrees_0_to_360(radians_to_degrees(calculate_lbr_polynomial(
211 jme, terms, num_terms,
212 )))
213}
214
215fn calculate_nutation_terms(jce: f64) -> [f64; 5] {
217 [
220 polynomial(NUTATION_COEFFS[0], jce),
221 polynomial(NUTATION_COEFFS[1], jce),
222 polynomial(NUTATION_COEFFS[2], jce),
223 polynomial(NUTATION_COEFFS[3], jce),
224 polynomial(NUTATION_COEFFS[4], jce),
225 ]
226}
227
228fn calculate_delta_psi_epsilon(jce: f64, x: &[f64]) -> DeltaPsiEpsilon {
230 let mut delta_psi = 0.0;
231 let mut delta_epsilon = 0.0;
232
233 for (i, pe_term) in TERMS_PE.iter().enumerate() {
234 let xj_yterm_sum = degrees_to_radians(calculate_xj_yterm_sum(i, x));
235
236 let delta_psi_contrib = mul_add(pe_term[1], jce, pe_term[0]) * sin(xj_yterm_sum);
238 let delta_epsilon_contrib = mul_add(pe_term[3], jce, pe_term[2]) * cos(xj_yterm_sum);
239
240 delta_psi += delta_psi_contrib;
241 delta_epsilon += delta_epsilon_contrib;
242 }
243
244 DeltaPsiEpsilon {
245 delta_psi: delta_psi / 36_000_000.0,
246 delta_epsilon: delta_epsilon / 36_000_000.0,
247 }
248}
249
250fn calculate_xj_yterm_sum(i: usize, x: &[f64]) -> f64 {
252 let mut sum = 0.0;
253 for (j, &x_val) in x.iter().enumerate() {
254 sum += x_val * f64::from(TERMS_Y[i][j]);
255 }
256 sum
257}
258
259fn calculate_true_obliquity_of_ecliptic(jd: &JulianDate, delta_epsilon: f64) -> f64 {
261 let epsilon0 = polynomial(OBLIQUITY_COEFFS, jd.julian_ephemeris_millennium() / 10.0);
262 epsilon0 / 3600.0 + delta_epsilon
263}
264
265fn calculate_apparent_sidereal_time_at_greenwich(
267 jd: &JulianDate,
268 delta_psi: f64,
269 epsilon_degrees: f64,
270) -> f64 {
271 let nu0_degrees = normalize_degrees_0_to_360(mul_add(
272 powi(jd.julian_century(), 2),
273 0.000387933 - jd.julian_century() / 38710000.0,
274 mul_add(
275 360.98564736629f64,
276 jd.julian_date() - 2451545.0,
277 280.46061837,
278 ),
279 ));
280
281 mul_add(
282 delta_psi,
283 cos(degrees_to_radians(epsilon_degrees)),
284 nu0_degrees,
285 )
286}
287
288fn calculate_geocentric_sun_right_ascension(
290 beta_rad: f64,
291 epsilon_rad: f64,
292 lambda_rad: f64,
293) -> f64 {
294 let alpha = atan2(
295 mul_add(
296 sin(lambda_rad),
297 cos(epsilon_rad),
298 -(tan(beta_rad) * sin(epsilon_rad)),
299 ),
300 cos(lambda_rad),
301 );
302 normalize_degrees_0_to_360(radians_to_degrees(alpha))
303}
304
305fn calculate_geocentric_sun_declination(beta_rad: f64, epsilon_rad: f64, lambda_rad: f64) -> f64 {
307 asin(mul_add(
308 sin(beta_rad),
309 cos(epsilon_rad),
310 cos(beta_rad) * sin(epsilon_rad) * sin(lambda_rad),
311 ))
312}
313
314pub fn sunrise_sunset_utc(
355 year: i32,
356 month: u32,
357 day: u32,
358 latitude: f64,
359 longitude: f64,
360 delta_t: f64,
361 elevation_angle: f64,
362) -> Result<crate::SunriseResult<crate::HoursUtc>> {
363 check_coordinates(latitude, longitude)?;
364
365 let jd_midnight = JulianDate::from_utc(year, month, day, 0, 0, 0.0, delta_t)?;
367
368 calculate_sunrise_sunset_core(jd_midnight, latitude, longitude, delta_t, elevation_angle)
370}
371
372pub fn sunrise_sunset_utc_for_horizon(
413 year: i32,
414 month: u32,
415 day: u32,
416 latitude: f64,
417 longitude: f64,
418 delta_t: f64,
419 horizon: crate::Horizon,
420) -> Result<crate::SunriseResult<crate::HoursUtc>> {
421 sunrise_sunset_utc(
422 year,
423 month,
424 day,
425 latitude,
426 longitude,
427 delta_t,
428 horizon.elevation_angle(),
429 )
430}
431
432#[cfg(feature = "chrono")]
469#[cfg_attr(docsrs, doc(cfg(feature = "chrono")))]
470#[allow(clippy::needless_pass_by_value)]
471pub fn sunrise_sunset<Tz: TimeZone>(
472 date: DateTime<Tz>,
473 latitude: f64,
474 longitude: f64,
475 delta_t: f64,
476 elevation_angle: f64,
477) -> Result<crate::SunriseResult<DateTime<Tz>>> {
478 check_coordinates(latitude, longitude)?;
479
480 let day_start = truncate_to_day_start(&date);
481
482 calculate_sunrise_sunset_spa_algorithm(day_start, latitude, longitude, delta_t, elevation_angle)
484}
485
486#[allow(clippy::unnecessary_wraps)]
490fn calculate_sunrise_sunset_core(
491 jd_midnight: JulianDate,
492 latitude: f64,
493 longitude: f64,
494 delta_t: f64,
495 elevation_angle: f64,
496) -> Result<crate::SunriseResult<crate::HoursUtc>> {
497 let jce_day = jd_midnight.julian_ephemeris_century();
499 let x_terms = calculate_nutation_terms(jce_day);
500 let delta_psi_epsilon = calculate_delta_psi_epsilon(jce_day, &x_terms);
501 let epsilon_degrees =
502 calculate_true_obliquity_of_ecliptic(&jd_midnight, delta_psi_epsilon.delta_epsilon);
503 let nu_degrees = calculate_apparent_sidereal_time_at_greenwich(
504 &jd_midnight,
505 delta_psi_epsilon.delta_psi,
506 epsilon_degrees,
507 );
508
509 let mut alpha_deltas = [AlphaDelta {
511 alpha: 0.0,
512 delta: 0.0,
513 }; 3];
514 for (i, alpha_delta) in alpha_deltas.iter_mut().enumerate() {
515 let current_jd = jd_midnight.add_days((i as f64) - 1.0);
516 let current_jme = current_jd.julian_ephemeris_millennium();
517 let ad = calculate_alpha_delta(current_jme, delta_psi_epsilon.delta_psi, epsilon_degrees);
518 *alpha_delta = ad;
519 }
520
521 let m0 = (alpha_deltas[1].alpha - longitude - nu_degrees) / 360.0;
523
524 let polar_type = check_polar_conditions_type(latitude, elevation_angle, alpha_deltas[1].delta);
526
527 let (m_values, _h0_degrees) =
529 calculate_approximate_times(m0, latitude, elevation_angle, alpha_deltas[1].delta);
530
531 let (t_frac, r_frac, s_frac) = calculate_final_time_fractions(
533 m_values,
534 nu_degrees,
535 delta_t,
536 latitude,
537 longitude,
538 elevation_angle,
539 alpha_deltas,
540 );
541
542 let transit_hours = crate::HoursUtc::from_hours(t_frac * 24.0);
544 let sunrise_hours = crate::HoursUtc::from_hours(r_frac * 24.0);
545 let sunset_hours = crate::HoursUtc::from_hours(s_frac * 24.0);
546
547 match polar_type {
549 Some(PolarType::AllDay) => Ok(crate::SunriseResult::AllDay {
550 transit: transit_hours,
551 }),
552 Some(PolarType::AllNight) => Ok(crate::SunriseResult::AllNight {
553 transit: transit_hours,
554 }),
555 None => Ok(crate::SunriseResult::RegularDay {
556 sunrise: sunrise_hours,
557 transit: transit_hours,
558 sunset: sunset_hours,
559 }),
560 }
561}
562
563#[cfg(feature = "chrono")]
565fn calculate_sunrise_sunset_spa_algorithm<Tz: TimeZone>(
566 day: DateTime<Tz>,
567 latitude: f64,
568 longitude: f64,
569 delta_t: f64,
570 elevation_angle: f64,
571) -> Result<crate::SunriseResult<DateTime<Tz>>> {
572 let day_start = truncate_to_day_start(&day);
573
574 let (nu_degrees, delta_psi_epsilon, epsilon_degrees) =
576 calculate_sidereal_time_and_nutation(day_start.clone(), delta_t)?;
577
578 let alpha_deltas = calculate_alpha_deltas_for_three_days(
580 day_start,
581 delta_t,
582 delta_psi_epsilon,
583 epsilon_degrees,
584 )?;
585
586 let m0 = (alpha_deltas[1].alpha - longitude - nu_degrees) / 360.0;
588
589 let polar_type = check_polar_conditions_type(latitude, elevation_angle, alpha_deltas[1].delta);
591
592 let (m_values, _h0_degrees) =
594 calculate_approximate_times(m0, latitude, elevation_angle, alpha_deltas[1].delta);
595
596 let final_result = calculate_final_times(FinalTimeParams {
598 day,
599 m_values,
600 nu_degrees,
601 delta_t,
602 latitude,
603 longitude,
604 elevation_angle,
605 alpha_deltas,
606 });
607
608 match polar_type {
610 Some(PolarType::AllDay) => {
611 if let crate::SunriseResult::RegularDay { transit, .. } = final_result {
612 Ok(crate::SunriseResult::AllDay { transit })
613 } else {
614 Ok(final_result)
615 }
616 }
617 Some(PolarType::AllNight) => {
618 if let crate::SunriseResult::RegularDay { transit, .. } = final_result {
619 Ok(crate::SunriseResult::AllNight { transit })
620 } else {
621 Ok(final_result)
622 }
623 }
624 None => Ok(final_result),
625 }
626}
627#[cfg(feature = "chrono")]
628#[allow(clippy::needless_pass_by_value)]
629fn calculate_sidereal_time_and_nutation<Tz: TimeZone>(
630 day_start: DateTime<Tz>,
631 delta_t: f64,
632) -> Result<(f64, DeltaPsiEpsilon, f64)> {
633 let jd = JulianDate::from_datetime(&day_start, delta_t)?;
634 let jce_day = jd.julian_ephemeris_century();
635 let x_terms = calculate_nutation_terms(jce_day);
636 let delta_psi_epsilon = calculate_delta_psi_epsilon(jce_day, &x_terms);
637 let epsilon_degrees =
638 calculate_true_obliquity_of_ecliptic(&jd, delta_psi_epsilon.delta_epsilon);
639 let nu_degrees = calculate_apparent_sidereal_time_at_greenwich(
640 &jd,
641 delta_psi_epsilon.delta_psi,
642 epsilon_degrees,
643 );
644 Ok((nu_degrees, delta_psi_epsilon, epsilon_degrees))
645}
646
647#[cfg(feature = "chrono")]
648#[allow(clippy::needless_pass_by_value)]
649fn calculate_alpha_deltas_for_three_days<Tz: TimeZone>(
650 day_start: DateTime<Tz>,
651 delta_t: f64,
652 delta_psi_epsilon: DeltaPsiEpsilon,
653 epsilon_degrees: f64,
654) -> Result<[AlphaDelta; 3]> {
655 let mut alpha_deltas = [AlphaDelta {
656 alpha: 0.0,
657 delta: 0.0,
658 }; 3];
659 for (i, alpha_delta) in alpha_deltas.iter_mut().enumerate() {
660 let current_jd = JulianDate::from_datetime(&day_start, delta_t)?.add_days((i as f64) - 1.0);
661 let current_jme = current_jd.julian_ephemeris_millennium();
662 let ad = calculate_alpha_delta(current_jme, delta_psi_epsilon.delta_psi, epsilon_degrees);
663 *alpha_delta = ad;
664 }
665 Ok(alpha_deltas)
666}
667
668#[derive(Debug, Clone, Copy)]
675enum PolarType {
676 AllDay,
677 AllNight,
678}
679
680fn check_polar_conditions_type(
682 latitude: f64,
683 elevation_angle: f64,
684 delta1: f64,
685) -> Option<PolarType> {
686 let phi = degrees_to_radians(latitude);
687 let elevation_rad = degrees_to_radians(elevation_angle);
688 let delta1_rad = degrees_to_radians(delta1);
689
690 let acos_arg =
691 mul_add(sin(phi), -sin(delta1_rad), sin(elevation_rad)) / (cos(phi) * cos(delta1_rad));
692
693 if acos_arg < -1.0 {
694 Some(PolarType::AllDay)
695 } else if acos_arg > 1.0 {
696 Some(PolarType::AllNight)
697 } else {
698 None
699 }
700}
701
702#[cfg(feature = "chrono")]
704fn check_polar_conditions<Tz: TimeZone>(
705 day: DateTime<Tz>,
706 m0: f64,
707 latitude: f64,
708 elevation_angle: f64,
709 delta1: f64,
710) -> Option<crate::SunriseResult<DateTime<Tz>>> {
711 check_polar_conditions_type(latitude, elevation_angle, delta1).map(|polar_type| {
712 let transit = add_fraction_of_day(day, m0);
713 match polar_type {
714 PolarType::AllDay => crate::SunriseResult::AllDay { transit },
715 PolarType::AllNight => crate::SunriseResult::AllNight { transit },
716 }
717 })
718}
719
720fn calculate_approximate_times(
722 m0: f64,
723 latitude: f64,
724 elevation_angle: f64,
725 delta1: f64,
726) -> ([f64; 3], f64) {
727 let phi = degrees_to_radians(latitude);
728 let delta1_rad = degrees_to_radians(delta1);
729 let elevation_rad = degrees_to_radians(elevation_angle);
730
731 let acos_arg =
732 mul_add(sin(phi), -sin(delta1_rad), sin(elevation_rad)) / (cos(phi) * cos(delta1_rad));
733 let h0 = acos(acos_arg);
734 let h0_degrees = radians_to_degrees(h0).min(180.0);
735
736 let mut m = [0.0; 3];
737 m[0] = normalize_to_unit_range(m0);
738 m[1] = normalize_to_unit_range(m0 - h0_degrees / 360.0);
739 m[2] = normalize_to_unit_range(m0 + h0_degrees / 360.0);
740
741 (m, h0_degrees)
742}
743
744fn calculate_final_time_fractions(
747 m_values: [f64; 3],
748 nu_degrees: f64,
749 delta_t: f64,
750 latitude: f64,
751 longitude: f64,
752 elevation_angle: f64,
753 alpha_deltas: [AlphaDelta; 3],
754) -> (f64, f64, f64) {
755 let mut nu = [0.0; 3];
757 for (i, nu_item) in nu.iter_mut().enumerate() {
758 *nu_item = mul_add(360.985647f64, m_values[i], nu_degrees);
759 }
760
761 let mut n = [0.0; 3];
763 for (i, n_item) in n.iter_mut().enumerate() {
764 *n_item = m_values[i] + delta_t / 86400.0;
765 }
766
767 let alpha_delta_primes = calculate_interpolated_alpha_deltas(&alpha_deltas, &n);
769
770 let mut h_prime = [0.0; 3];
772 for i in 0..3 {
773 let h_prime_i = nu[i] + longitude - alpha_delta_primes[i].alpha;
774 h_prime[i] = limit_h_prime(h_prime_i);
775 }
776
777 let phi = degrees_to_radians(latitude);
779 let mut h = [0.0; 3];
780 for i in 0..3 {
781 let delta_prime_rad = degrees_to_radians(alpha_delta_primes[i].delta);
782 h[i] = radians_to_degrees(asin(mul_add(
783 sin(phi),
784 sin(delta_prime_rad),
785 cos(phi) * cos(delta_prime_rad) * cos(degrees_to_radians(h_prime[i])),
786 )));
787 }
788
789 let t = m_values[0] - h_prime[0] / 360.0;
791 let r = m_values[1]
792 + (h[1] - elevation_angle)
793 / (360.0
794 * cos(degrees_to_radians(alpha_delta_primes[1].delta))
795 * cos(phi)
796 * sin(degrees_to_radians(h_prime[1])));
797 let s = m_values[2]
798 + (h[2] - elevation_angle)
799 / (360.0
800 * cos(degrees_to_radians(alpha_delta_primes[2].delta))
801 * cos(phi)
802 * sin(degrees_to_radians(h_prime[2])));
803
804 (t, r, s)
805}
806
807#[cfg(feature = "chrono")]
809fn calculate_final_times<Tz: TimeZone>(
810 params: FinalTimeParams<Tz>,
811) -> crate::SunriseResult<DateTime<Tz>> {
812 let mut nu = [0.0; 3];
814 for (i, nu_item) in nu.iter_mut().enumerate() {
815 *nu_item = mul_add(360.985647f64, params.m_values[i], params.nu_degrees);
816 }
817
818 let mut n = [0.0; 3];
820 for (i, n_item) in n.iter_mut().enumerate() {
821 *n_item = params.m_values[i] + params.delta_t / 86400.0;
822 }
823
824 let alpha_delta_primes = calculate_interpolated_alpha_deltas(¶ms.alpha_deltas, &n);
826
827 let mut h_prime = [0.0; 3];
829 for i in 0..3 {
830 let h_prime_i = nu[i] + params.longitude - alpha_delta_primes[i].alpha;
831 h_prime[i] = limit_h_prime(h_prime_i);
832 }
833
834 let phi = degrees_to_radians(params.latitude);
836 let mut h = [0.0; 3];
837 for i in 0..3 {
838 let delta_prime_rad = degrees_to_radians(alpha_delta_primes[i].delta);
839 h[i] = radians_to_degrees(asin(mul_add(
840 sin(phi),
841 sin(delta_prime_rad),
842 cos(phi) * cos(delta_prime_rad) * cos(degrees_to_radians(h_prime[i])),
843 )));
844 }
845
846 let t = params.m_values[0] - h_prime[0] / 360.0;
848 let r = params.m_values[1]
849 + (h[1] - params.elevation_angle)
850 / (360.0
851 * cos(degrees_to_radians(alpha_delta_primes[1].delta))
852 * cos(phi)
853 * sin(degrees_to_radians(h_prime[1])));
854 let s = params.m_values[2]
855 + (h[2] - params.elevation_angle)
856 / (360.0
857 * cos(degrees_to_radians(alpha_delta_primes[2].delta))
858 * cos(phi)
859 * sin(degrees_to_radians(h_prime[2])));
860
861 let sunrise = add_fraction_of_day(params.day.clone(), r);
862 let transit = add_fraction_of_day(params.day.clone(), t);
863 let sunset = add_fraction_of_day(params.day, s);
864
865 crate::SunriseResult::RegularDay {
866 sunrise,
867 transit,
868 sunset,
869 }
870}
871
872fn calculate_interpolated_alpha_deltas(
874 alpha_deltas: &[AlphaDelta; 3],
875 n: &[f64; 3],
876) -> [AlphaDelta; 3] {
877 let a = limit_if_necessary(alpha_deltas[1].alpha - alpha_deltas[0].alpha);
878 let a_prime = limit_if_necessary(alpha_deltas[1].delta - alpha_deltas[0].delta);
879
880 let b = limit_if_necessary(alpha_deltas[2].alpha - alpha_deltas[1].alpha);
881 let b_prime = limit_if_necessary(alpha_deltas[2].delta - alpha_deltas[1].delta);
882
883 let c = b - a;
884 let c_prime = b_prime - a_prime;
885
886 let mut alpha_delta_primes = [AlphaDelta {
887 alpha: 0.0,
888 delta: 0.0,
889 }; 3];
890 for i in 0..3 {
891 alpha_delta_primes[i].alpha =
892 alpha_deltas[1].alpha + (n[i] * (mul_add(c, n[i], a + b))) / 2.0;
893 alpha_delta_primes[i].delta =
894 alpha_deltas[1].delta + (n[i] * (mul_add(c_prime, n[i], a_prime + b_prime))) / 2.0;
895 }
896 alpha_delta_primes
897}
898
899#[derive(Debug, Clone, Copy)]
900struct AlphaDelta {
901 alpha: f64,
902 delta: f64,
903}
904
905#[derive(Debug, Clone)]
907#[cfg(feature = "chrono")]
908struct FinalTimeParams<Tz: TimeZone> {
909 day: DateTime<Tz>,
910 m_values: [f64; 3],
911 nu_degrees: f64,
912 delta_t: f64,
913 latitude: f64,
914 longitude: f64,
915 elevation_angle: f64,
916 alpha_deltas: [AlphaDelta; 3],
917}
918
919#[cfg(feature = "chrono")]
957#[cfg_attr(docsrs, doc(cfg(feature = "chrono")))]
958pub fn sunrise_sunset_for_horizon<Tz: TimeZone>(
959 date: DateTime<Tz>,
960 latitude: f64,
961 longitude: f64,
962 delta_t: f64,
963 horizon: Horizon,
964) -> Result<crate::SunriseResult<DateTime<Tz>>> {
965 sunrise_sunset(
966 date,
967 latitude,
968 longitude,
969 delta_t,
970 horizon.elevation_angle(),
971 )
972}
973
974fn calculate_alpha_delta(jme: f64, delta_psi: f64, epsilon_degrees: f64) -> AlphaDelta {
977 let b_terms = calculate_lbr_terms(jme, TERMS_B);
981 let b_degrees = lbr_to_normalized_degrees(jme, &b_terms, TERMS_B.len());
982
983 let r_terms = calculate_lbr_terms(jme, TERMS_R);
985 let r = calculate_lbr_polynomial(jme, &r_terms, TERMS_R.len());
986 assert!(
987 r != 0.0,
988 "Earth radius vector is zero - astronomical impossibility"
989 );
990
991 let l_terms = calculate_lbr_terms(jme, TERMS_L);
993 let l_degrees = lbr_to_normalized_degrees(jme, &l_terms, TERMS_L.len());
994
995 let theta_degrees = normalize_degrees_0_to_360(l_degrees + 180.0);
997
998 let beta_degrees = -b_degrees;
1000 let beta = degrees_to_radians(beta_degrees);
1001 let epsilon = degrees_to_radians(epsilon_degrees);
1002
1003 let delta_tau = ABERRATION_CONSTANT / (SECONDS_PER_HOUR * r);
1005
1006 let lambda_degrees = theta_degrees + delta_psi + delta_tau;
1008 let lambda = degrees_to_radians(lambda_degrees);
1009
1010 let alpha_degrees = calculate_geocentric_sun_right_ascension(beta, epsilon, lambda);
1012 let delta_degrees =
1013 radians_to_degrees(calculate_geocentric_sun_declination(beta, epsilon, lambda));
1014
1015 AlphaDelta {
1016 alpha: alpha_degrees,
1017 delta: delta_degrees,
1018 }
1019}
1020
1021fn normalize_to_unit_range(val: f64) -> f64 {
1023 let divided = val;
1024 let limited = divided - floor(divided);
1025 if limited < 0.0 {
1026 limited + 1.0
1027 } else {
1028 limited
1029 }
1030}
1031
1032#[cfg(feature = "chrono")]
1033fn truncate_to_day_start<Tz: TimeZone>(datetime: &DateTime<Tz>) -> DateTime<Tz> {
1034 let tz = datetime.timezone();
1035 let local_midnight = datetime
1036 .date_naive()
1037 .and_hms_opt(0, 0, 0)
1038 .expect("midnight is always valid");
1039
1040 match tz.from_local_datetime(&local_midnight) {
1041 LocalResult::Single(dt) => dt,
1042 LocalResult::Ambiguous(earliest, _) => earliest,
1043 LocalResult::None => {
1044 let utc_midnight = datetime
1046 .with_timezone(&Utc)
1047 .date_naive()
1048 .and_hms_opt(0, 0, 0)
1049 .expect("midnight is always valid")
1050 .and_utc();
1051
1052 tz.from_utc_datetime(&utc_midnight.naive_utc())
1053 }
1054 }
1055}
1056
1057#[cfg(feature = "chrono")]
1058#[allow(clippy::needless_pass_by_value)]
1059fn add_fraction_of_day<Tz: TimeZone>(day: DateTime<Tz>, fraction: f64) -> DateTime<Tz> {
1060 const MS_PER_DAY: i32 = 24 * 60 * 60 * 1000; let millis_plus = (f64::from(MS_PER_DAY) * fraction) as i32; let day_start = truncate_to_day_start(&day);
1068
1069 day_start + chrono::Duration::milliseconds(i64::from(millis_plus))
1070}
1071
1072fn limit_if_necessary(val: f64) -> f64 {
1074 if val.abs() > 2.0 {
1075 normalize_to_unit_range(val)
1076 } else {
1077 val
1078 }
1079}
1080
1081fn limit_h_prime(h_prime: f64) -> f64 {
1083 let normalized = h_prime / 360.0;
1084 let limited = 360.0 * (normalized - floor(normalized));
1085
1086 if limited < -180.0 {
1087 limited + 360.0
1088 } else if limited > 180.0 {
1089 limited - 360.0
1090 } else {
1091 limited
1092 }
1093}
1094
1095#[cfg(feature = "chrono")]
1144#[cfg_attr(docsrs, doc(cfg(feature = "chrono")))]
1145pub fn sunrise_sunset_multiple<Tz, H>(
1146 date: DateTime<Tz>,
1147 latitude: f64,
1148 longitude: f64,
1149 delta_t: f64,
1150 horizons: H,
1151) -> impl Iterator<Item = Result<(Horizon, crate::SunriseResult<DateTime<Tz>>)>>
1152where
1153 Tz: TimeZone,
1154 H: IntoIterator<Item = Horizon>,
1155{
1156 let precomputed = (|| -> Result<_> {
1158 check_coordinates(latitude, longitude)?;
1159
1160 let day_start = truncate_to_day_start(&date);
1161 let (nu_degrees, delta_psi_epsilon, epsilon_degrees) =
1162 calculate_sidereal_time_and_nutation(day_start.clone(), delta_t)?;
1163 let alpha_deltas = calculate_alpha_deltas_for_three_days(
1164 day_start,
1165 delta_t,
1166 delta_psi_epsilon,
1167 epsilon_degrees,
1168 )?;
1169
1170 Ok((nu_degrees, alpha_deltas))
1171 })();
1172
1173 horizons.into_iter().map(move |horizon| match &precomputed {
1174 Ok((nu_degrees, alpha_deltas)) => {
1175 let result = calculate_sunrise_sunset_spa_algorithm_with_precomputed(
1176 date.clone(),
1177 latitude,
1178 longitude,
1179 delta_t,
1180 horizon.elevation_angle(),
1181 *nu_degrees,
1182 *alpha_deltas,
1183 );
1184 Ok((horizon, result))
1185 }
1186 Err(e) => Err(e.clone()),
1187 })
1188}
1189#[cfg(feature = "chrono")]
1190#[allow(clippy::needless_pass_by_value)]
1191fn calculate_sunrise_sunset_spa_algorithm_with_precomputed<Tz: TimeZone>(
1192 day: DateTime<Tz>,
1193 latitude: f64,
1194 longitude: f64,
1195 delta_t: f64,
1196 elevation_angle: f64,
1197 nu_degrees: f64,
1198 alpha_deltas: [AlphaDelta; 3],
1199) -> crate::SunriseResult<DateTime<Tz>> {
1200 let day_start = truncate_to_day_start(&day);
1201
1202 let m0 = (alpha_deltas[1].alpha - longitude - nu_degrees) / 360.0;
1204 if let Some(polar_result) = check_polar_conditions(
1205 day_start.clone(),
1206 m0,
1207 latitude,
1208 elevation_angle,
1209 alpha_deltas[1].delta,
1210 ) {
1211 return polar_result;
1212 }
1213
1214 let (m_values, _h0_degrees) =
1216 calculate_approximate_times(m0, latitude, elevation_angle, alpha_deltas[1].delta);
1217
1218 calculate_final_times(FinalTimeParams {
1220 day: day_start,
1221 m_values,
1222 nu_degrees,
1223 delta_t,
1224 latitude,
1225 longitude,
1226 elevation_angle,
1227 alpha_deltas,
1228 })
1229}
1230
1231#[cfg(feature = "chrono")]
1269#[cfg_attr(docsrs, doc(cfg(feature = "chrono")))]
1270#[allow(clippy::needless_pass_by_value)]
1271pub fn spa_time_dependent_parts<Tz: TimeZone>(
1272 datetime: DateTime<Tz>,
1273 delta_t: f64,
1274) -> Result<SpaTimeDependent> {
1275 let jd = JulianDate::from_datetime(&datetime, delta_t)?;
1276 spa_time_dependent_from_julian(jd)
1277}
1278
1279pub fn spa_time_dependent_from_julian(jd: JulianDate) -> Result<SpaTimeDependent> {
1289 let jme = jd.julian_ephemeris_millennium();
1290 let jce = jd.julian_ephemeris_century();
1291
1292 let l_terms = calculate_lbr_terms(jme, TERMS_L);
1294 let l_degrees = lbr_to_normalized_degrees(jme, &l_terms, TERMS_L.len());
1295
1296 let b_terms = calculate_lbr_terms(jme, TERMS_B);
1298 let b_degrees = lbr_to_normalized_degrees(jme, &b_terms, TERMS_B.len());
1299
1300 let r_terms = calculate_lbr_terms(jme, TERMS_R);
1302 let r = calculate_lbr_polynomial(jme, &r_terms, TERMS_R.len());
1303
1304 assert!(
1306 r != 0.0,
1307 "Earth radius vector is zero - astronomical impossibility"
1308 );
1309
1310 let theta_degrees = normalize_degrees_0_to_360(l_degrees + 180.0);
1312 let beta_degrees = -b_degrees;
1314
1315 let x_terms = calculate_nutation_terms(jce);
1317 let delta_psi_epsilon = calculate_delta_psi_epsilon(jce, &x_terms);
1318
1319 let epsilon_degrees =
1321 calculate_true_obliquity_of_ecliptic(&jd, delta_psi_epsilon.delta_epsilon);
1322
1323 let delta_tau = ABERRATION_CONSTANT / (SECONDS_PER_HOUR * r);
1325
1326 let lambda_degrees = theta_degrees + delta_psi_epsilon.delta_psi + delta_tau;
1328
1329 let nu_degrees = calculate_apparent_sidereal_time_at_greenwich(
1331 &jd,
1332 delta_psi_epsilon.delta_psi,
1333 epsilon_degrees,
1334 );
1335
1336 let beta = degrees_to_radians(beta_degrees);
1338 let epsilon = degrees_to_radians(epsilon_degrees);
1339 let lambda = degrees_to_radians(lambda_degrees);
1340 let alpha_degrees = calculate_geocentric_sun_right_ascension(beta, epsilon, lambda);
1341
1342 let delta_degrees =
1344 radians_to_degrees(calculate_geocentric_sun_declination(beta, epsilon, lambda));
1345
1346 Ok(SpaTimeDependent {
1347 theta_degrees,
1348 beta_degrees,
1349 r,
1350 delta_psi: delta_psi_epsilon.delta_psi,
1351 epsilon_degrees,
1352 lambda_degrees,
1353 nu_degrees,
1354 alpha_degrees,
1355 delta_degrees,
1356 })
1357}
1358
1359pub fn spa_with_time_dependent_parts(
1397 latitude: f64,
1398 longitude: f64,
1399 elevation: f64,
1400 refraction: Option<RefractionCorrection>,
1401 time_dependent: &SpaTimeDependent,
1402) -> Result<SolarPosition> {
1403 check_coordinates(latitude, longitude)?;
1404
1405 let nu_degrees = time_dependent.nu_degrees;
1408
1409 let h_degrees =
1411 normalize_degrees_0_to_360(nu_degrees + longitude - time_dependent.alpha_degrees);
1412 let h = degrees_to_radians(h_degrees);
1413
1414 let xi_degrees = 8.794 / (3600.0 * time_dependent.r);
1416 let xi = degrees_to_radians(xi_degrees);
1417 let phi = degrees_to_radians(latitude);
1418 let delta = degrees_to_radians(time_dependent.delta_degrees);
1419
1420 let u = atan(EARTH_FLATTENING_FACTOR * tan(phi));
1421 let y = mul_add(
1422 EARTH_FLATTENING_FACTOR,
1423 sin(u),
1424 (elevation / EARTH_RADIUS_METERS) * sin(phi),
1425 );
1426 let x = mul_add(elevation / EARTH_RADIUS_METERS, cos(phi), cos(u));
1427
1428 let delta_alpha_prime_degrees = radians_to_degrees(atan2(
1429 -x * sin(xi) * sin(h),
1430 mul_add(x * sin(xi), -cos(h), cos(delta)),
1431 ));
1432
1433 let delta_prime = radians_to_degrees(atan2(
1434 mul_add(y, -sin(xi), sin(delta)) * cos(degrees_to_radians(delta_alpha_prime_degrees)),
1435 mul_add(x * sin(xi), -cos(h), cos(delta)),
1436 ));
1437
1438 let h_prime_degrees = h_degrees - delta_alpha_prime_degrees;
1440
1441 let zenith_angle = radians_to_degrees(acos(mul_add(
1443 sin(degrees_to_radians(latitude)),
1444 sin(degrees_to_radians(delta_prime)),
1445 cos(degrees_to_radians(latitude))
1446 * cos(degrees_to_radians(delta_prime))
1447 * cos(degrees_to_radians(h_prime_degrees)),
1448 )));
1449
1450 let azimuth = normalize_degrees_0_to_360(
1452 180.0
1453 + radians_to_degrees(atan2(
1454 sin(degrees_to_radians(h_prime_degrees)),
1455 cos(degrees_to_radians(h_prime_degrees)) * sin(degrees_to_radians(latitude))
1456 - tan(degrees_to_radians(delta_prime)) * cos(degrees_to_radians(latitude)),
1457 )),
1458 );
1459
1460 let elevation_angle = 90.0 - zenith_angle;
1462 let final_zenith = refraction.map_or(zenith_angle, |correction| {
1463 if elevation_angle > SUNRISE_SUNSET_ANGLE {
1464 let pressure = correction.pressure();
1465 let temperature = correction.temperature();
1466 zenith_angle
1468 - (pressure / 1010.0) * (283.0 / (273.0 + temperature)) * 1.02
1469 / (60.0
1470 * tan(degrees_to_radians(
1471 elevation_angle + 10.3 / (elevation_angle + 5.11),
1472 )))
1473 } else {
1474 zenith_angle
1475 }
1476 });
1477
1478 SolarPosition::new(azimuth, final_zenith)
1479}
1480
1481#[cfg(all(test, feature = "chrono", feature = "std"))]
1482mod tests {
1483 use super::*;
1484 use chrono::{DateTime, FixedOffset, NaiveTime};
1485 use std::collections::HashSet;
1486
1487 #[test]
1488 fn test_spa_basic_functionality() {
1489 let datetime = "2023-06-21T12:00:00Z"
1490 .parse::<DateTime<FixedOffset>>()
1491 .unwrap();
1492
1493 let result = solar_position(
1494 datetime,
1495 37.7749, -122.4194,
1497 0.0,
1498 69.0,
1499 Some(RefractionCorrection::new(1013.25, 15.0).unwrap()),
1500 );
1501
1502 assert!(result.is_ok());
1503 let position = result.unwrap();
1504 assert!(position.azimuth() >= 0.0 && position.azimuth() <= 360.0);
1505 assert!(position.zenith_angle() >= 0.0 && position.zenith_angle() <= 180.0);
1506 }
1507
1508 #[test]
1509 fn test_sunrise_sunset_multiple() {
1510 let datetime = "2023-06-21T12:00:00Z"
1511 .parse::<DateTime<FixedOffset>>()
1512 .unwrap();
1513 let horizons = [
1514 Horizon::SunriseSunset,
1515 Horizon::CivilTwilight,
1516 Horizon::NauticalTwilight,
1517 ];
1518
1519 let results: Result<Vec<_>> = sunrise_sunset_multiple(
1520 datetime,
1521 37.7749, -122.4194, 69.0, horizons.iter().copied(),
1525 )
1526 .collect();
1527
1528 let results = results.unwrap();
1529
1530 assert_eq!(results.len(), 3);
1532
1533 let returned_horizons: HashSet<_> = results.iter().map(|(h, _)| *h).collect();
1535 for expected_horizon in horizons {
1536 assert!(returned_horizons.contains(&expected_horizon));
1537 }
1538
1539 for (horizon, bulk_result) in &results {
1541 let individual_result =
1542 sunrise_sunset_for_horizon(datetime, 37.7749, -122.4194, 69.0, *horizon).unwrap();
1543
1544 match (&individual_result, bulk_result) {
1546 (
1547 crate::SunriseResult::RegularDay {
1548 sunrise: s1,
1549 transit: t1,
1550 sunset: ss1,
1551 },
1552 crate::SunriseResult::RegularDay {
1553 sunrise: s2,
1554 transit: t2,
1555 sunset: ss2,
1556 },
1557 ) => {
1558 assert_eq!(s1, s2);
1559 assert_eq!(t1, t2);
1560 assert_eq!(ss1, ss2);
1561 }
1562 (
1563 crate::SunriseResult::AllDay { transit: t1 },
1564 crate::SunriseResult::AllDay { transit: t2 },
1565 )
1566 | (
1567 crate::SunriseResult::AllNight { transit: t1 },
1568 crate::SunriseResult::AllNight { transit: t2 },
1569 ) => {
1570 assert_eq!(t1, t2);
1571 }
1572 _ => panic!("Bulk and individual results differ in type for {horizon:?}"),
1573 }
1574 }
1575 }
1576
1577 #[test]
1578 fn test_spa_no_refraction() {
1579 let datetime = "2023-06-21T12:00:00Z"
1580 .parse::<DateTime<FixedOffset>>()
1581 .unwrap();
1582
1583 let result = solar_position(datetime, 37.7749, -122.4194, 0.0, 69.0, None);
1584
1585 assert!(result.is_ok());
1586 let position = result.unwrap();
1587 assert!(position.azimuth() >= 0.0 && position.azimuth() <= 360.0);
1588 assert!(position.zenith_angle() >= 0.0 && position.zenith_angle() <= 180.0);
1589 }
1590
1591 #[test]
1592 fn test_spa_coordinate_validation() {
1593 let datetime = "2023-06-21T12:00:00Z"
1594 .parse::<DateTime<FixedOffset>>()
1595 .unwrap();
1596
1597 assert!(solar_position(
1599 datetime,
1600 95.0,
1601 0.0,
1602 0.0,
1603 0.0,
1604 Some(RefractionCorrection::new(1013.25, 15.0).unwrap())
1605 )
1606 .is_err());
1607
1608 assert!(solar_position(
1610 datetime,
1611 0.0,
1612 185.0,
1613 0.0,
1614 0.0,
1615 Some(RefractionCorrection::new(1013.25, 15.0).unwrap())
1616 )
1617 .is_err());
1618 }
1619
1620 #[test]
1621 fn test_sunrise_sunset_basic() {
1622 let date = "2023-06-21T00:00:00Z"
1623 .parse::<DateTime<FixedOffset>>()
1624 .unwrap();
1625
1626 let result = sunrise_sunset(date, 37.7749, -122.4194, 69.0, -0.833);
1627 assert!(result.is_ok());
1628
1629 let result =
1630 sunrise_sunset_for_horizon(date, 37.7749, -122.4194, 69.0, Horizon::SunriseSunset);
1631 assert!(result.is_ok());
1632 }
1633
1634 #[test]
1635 fn test_horizon_enum() {
1636 assert_eq!(Horizon::SunriseSunset.elevation_angle(), -0.83337);
1637 assert_eq!(Horizon::CivilTwilight.elevation_angle(), -6.0);
1638 assert_eq!(Horizon::Custom(-10.5).elevation_angle(), -10.5);
1639 }
1640
1641 #[test]
1642 fn truncate_to_day_start_keeps_local_midnight() {
1643 let tz = FixedOffset::east_opt(2 * 3600).unwrap();
1644 let midnight = tz.with_ymd_and_hms(2024, 3, 30, 0, 0, 0).unwrap();
1645
1646 let truncated = truncate_to_day_start(&midnight);
1647
1648 assert_eq!(truncated.date_naive(), midnight.date_naive());
1649 assert_eq!(truncated.time(), NaiveTime::from_hms_opt(0, 0, 0).unwrap());
1650 }
1651}