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, TimeZone};
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#[allow(clippy::needless_pass_by_value)]
94pub fn solar_position<Tz: TimeZone>(
95 datetime: DateTime<Tz>,
96 latitude: f64,
97 longitude: f64,
98 elevation: f64,
99 delta_t: f64,
100 refraction: Option<RefractionCorrection>,
101) -> Result<SolarPosition> {
102 let jd = JulianDate::from_datetime(&datetime, delta_t)?;
103 solar_position_from_julian(jd, latitude, longitude, elevation, refraction)
104}
105
106pub fn solar_position_from_julian(
142 jd: JulianDate,
143 latitude: f64,
144 longitude: f64,
145 elevation: f64,
146 refraction: Option<RefractionCorrection>,
147) -> Result<SolarPosition> {
148 let time_dependent = spa_time_dependent_from_julian(jd)?;
149 spa_with_time_dependent_parts(latitude, longitude, elevation, refraction, &time_dependent)
150}
151
152#[derive(Debug, Clone)]
157#[allow(dead_code)]
158pub struct SpaTimeDependent {
159 pub(crate) theta_degrees: f64,
161 pub(crate) beta_degrees: f64,
163 pub(crate) r: f64,
165 pub(crate) delta_psi: f64,
167 pub(crate) epsilon_degrees: f64,
169 pub(crate) lambda_degrees: f64,
171 pub(crate) nu_degrees: f64,
173 pub(crate) alpha_degrees: f64,
175 pub(crate) delta_degrees: f64,
177}
178
179#[derive(Debug, Clone, Copy)]
180struct DeltaPsiEpsilon {
181 delta_psi: f64,
182 delta_epsilon: f64,
183}
184
185fn calculate_lbr_terms(jme: f64, term_coeffs: &[&[&[f64; 3]]]) -> [f64; 6] {
187 let mut lbr_terms = [0.0; 6];
190
191 for (i, term_set) in term_coeffs.iter().enumerate().take(6) {
192 let mut lbr_sum = 0.0;
193 for term in *term_set {
194 lbr_sum += term[0] * cos(mul_add(term[2], jme, term[1]));
195 }
196 lbr_terms[i] = lbr_sum;
197 }
198
199 lbr_terms
200}
201
202fn calculate_lbr_polynomial(jme: f64, terms: &[f64], num_terms: usize) -> f64 {
204 polynomial(&terms[..num_terms], jme) / 1e8
205}
206
207fn lbr_to_normalized_degrees(jme: f64, terms: &[f64], num_terms: usize) -> f64 {
209 normalize_degrees_0_to_360(radians_to_degrees(calculate_lbr_polynomial(
210 jme, terms, num_terms,
211 )))
212}
213
214fn calculate_nutation_terms(jce: f64) -> [f64; 5] {
216 [
219 polynomial(NUTATION_COEFFS[0], jce),
220 polynomial(NUTATION_COEFFS[1], jce),
221 polynomial(NUTATION_COEFFS[2], jce),
222 polynomial(NUTATION_COEFFS[3], jce),
223 polynomial(NUTATION_COEFFS[4], jce),
224 ]
225}
226
227fn calculate_delta_psi_epsilon(jce: f64, x: &[f64]) -> DeltaPsiEpsilon {
229 let mut delta_psi = 0.0;
230 let mut delta_epsilon = 0.0;
231
232 for (i, pe_term) in TERMS_PE.iter().enumerate() {
233 let xj_yterm_sum = degrees_to_radians(calculate_xj_yterm_sum(i, x));
234
235 let delta_psi_contrib = mul_add(pe_term[1], jce, pe_term[0]) * sin(xj_yterm_sum);
237 let delta_epsilon_contrib = mul_add(pe_term[3], jce, pe_term[2]) * cos(xj_yterm_sum);
238
239 delta_psi += delta_psi_contrib;
240 delta_epsilon += delta_epsilon_contrib;
241 }
242
243 DeltaPsiEpsilon {
244 delta_psi: delta_psi / 36_000_000.0,
245 delta_epsilon: delta_epsilon / 36_000_000.0,
246 }
247}
248
249fn calculate_xj_yterm_sum(i: usize, x: &[f64]) -> f64 {
251 let mut sum = 0.0;
252 for (j, &x_val) in x.iter().enumerate() {
253 sum += x_val * f64::from(TERMS_Y[i][j]);
254 }
255 sum
256}
257
258fn calculate_true_obliquity_of_ecliptic(jd: &JulianDate, delta_epsilon: f64) -> f64 {
260 let epsilon0 = polynomial(OBLIQUITY_COEFFS, jd.julian_ephemeris_millennium() / 10.0);
261 epsilon0 / 3600.0 + delta_epsilon
262}
263
264fn calculate_apparent_sidereal_time_at_greenwich(
266 jd: &JulianDate,
267 delta_psi: f64,
268 epsilon_degrees: f64,
269) -> f64 {
270 let nu0_degrees = normalize_degrees_0_to_360(mul_add(
271 powi(jd.julian_century(), 2),
272 0.000387933 - jd.julian_century() / 38710000.0,
273 mul_add(
274 360.98564736629f64,
275 jd.julian_date() - 2451545.0,
276 280.46061837,
277 ),
278 ));
279
280 mul_add(
281 delta_psi,
282 cos(degrees_to_radians(epsilon_degrees)),
283 nu0_degrees,
284 )
285}
286
287fn calculate_geocentric_sun_right_ascension(
289 beta_rad: f64,
290 epsilon_rad: f64,
291 lambda_rad: f64,
292) -> f64 {
293 let alpha = atan2(
294 mul_add(
295 sin(lambda_rad),
296 cos(epsilon_rad),
297 -(tan(beta_rad) * sin(epsilon_rad)),
298 ),
299 cos(lambda_rad),
300 );
301 normalize_degrees_0_to_360(radians_to_degrees(alpha))
302}
303
304fn calculate_geocentric_sun_declination(beta_rad: f64, epsilon_rad: f64, lambda_rad: f64) -> f64 {
306 asin(mul_add(
307 sin(beta_rad),
308 cos(epsilon_rad),
309 cos(beta_rad) * sin(epsilon_rad) * sin(lambda_rad),
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
364 let jd_midnight = JulianDate::from_utc(year, month, day, 0, 0, 0.0, 0.0)?;
366
367 calculate_sunrise_sunset_core(jd_midnight, latitude, longitude, delta_t, elevation_angle)
369}
370
371pub fn sunrise_sunset_utc_for_horizon(
412 year: i32,
413 month: u32,
414 day: u32,
415 latitude: f64,
416 longitude: f64,
417 delta_t: f64,
418 horizon: crate::Horizon,
419) -> Result<crate::SunriseResult<crate::HoursUtc>> {
420 sunrise_sunset_utc(
421 year,
422 month,
423 day,
424 latitude,
425 longitude,
426 delta_t,
427 horizon.elevation_angle(),
428 )
429}
430
431#[cfg(feature = "chrono")]
468#[allow(clippy::needless_pass_by_value)]
469pub fn sunrise_sunset<Tz: TimeZone>(
470 date: DateTime<Tz>,
471 latitude: f64,
472 longitude: f64,
473 delta_t: f64,
474 elevation_angle: f64,
475) -> Result<crate::SunriseResult<DateTime<Tz>>> {
476 check_coordinates(latitude, longitude)?;
477
478 let day_start = truncate_to_day_start(&date);
479
480 calculate_sunrise_sunset_spa_algorithm(day_start, latitude, longitude, delta_t, elevation_angle)
482}
483
484#[allow(clippy::unnecessary_wraps)]
488fn calculate_sunrise_sunset_core(
489 jd_midnight: JulianDate,
490 latitude: f64,
491 longitude: f64,
492 delta_t: f64,
493 elevation_angle: f64,
494) -> Result<crate::SunriseResult<crate::HoursUtc>> {
495 let jce_day = jd_midnight.julian_ephemeris_century();
497 let x_terms = calculate_nutation_terms(jce_day);
498 let delta_psi_epsilon = calculate_delta_psi_epsilon(jce_day, &x_terms);
499 let epsilon_degrees =
500 calculate_true_obliquity_of_ecliptic(&jd_midnight, delta_psi_epsilon.delta_epsilon);
501 let nu_degrees = calculate_apparent_sidereal_time_at_greenwich(
502 &jd_midnight,
503 delta_psi_epsilon.delta_psi,
504 epsilon_degrees,
505 );
506
507 let mut alpha_deltas = [AlphaDelta {
509 alpha: 0.0,
510 delta: 0.0,
511 }; 3];
512 for (i, alpha_delta) in alpha_deltas.iter_mut().enumerate() {
513 let current_jd = jd_midnight.add_days((i as f64) - 1.0);
514 let current_jme = current_jd.julian_ephemeris_millennium();
515 let ad = calculate_alpha_delta(current_jme, delta_psi_epsilon.delta_psi, epsilon_degrees);
516 *alpha_delta = ad;
517 }
518
519 let m0 = (alpha_deltas[1].alpha - longitude - nu_degrees) / 360.0;
521
522 let polar_type = check_polar_conditions_type(latitude, elevation_angle, alpha_deltas[1].delta);
524
525 let (m_values, _h0_degrees) =
527 calculate_approximate_times(m0, latitude, elevation_angle, alpha_deltas[1].delta);
528
529 let (t_frac, r_frac, s_frac) = calculate_final_time_fractions(
531 m_values,
532 nu_degrees,
533 delta_t,
534 latitude,
535 longitude,
536 elevation_angle,
537 alpha_deltas,
538 );
539
540 let transit_hours = crate::HoursUtc::from_hours(t_frac * 24.0);
542 let sunrise_hours = crate::HoursUtc::from_hours(r_frac * 24.0);
543 let sunset_hours = crate::HoursUtc::from_hours(s_frac * 24.0);
544
545 match polar_type {
547 Some(PolarType::AllDay) => Ok(crate::SunriseResult::AllDay {
548 transit: transit_hours,
549 }),
550 Some(PolarType::AllNight) => Ok(crate::SunriseResult::AllNight {
551 transit: transit_hours,
552 }),
553 None => Ok(crate::SunriseResult::RegularDay {
554 sunrise: sunrise_hours,
555 transit: transit_hours,
556 sunset: sunset_hours,
557 }),
558 }
559}
560
561#[cfg(feature = "chrono")]
563fn calculate_sunrise_sunset_spa_algorithm<Tz: TimeZone>(
564 day: DateTime<Tz>,
565 latitude: f64,
566 longitude: f64,
567 delta_t: f64,
568 elevation_angle: f64,
569) -> Result<crate::SunriseResult<DateTime<Tz>>> {
570 let day_start = truncate_to_day_start(&day);
571
572 let (nu_degrees, delta_psi_epsilon, epsilon_degrees) =
574 calculate_sidereal_time_and_nutation(day_start.clone());
575
576 let alpha_deltas =
578 calculate_alpha_deltas_for_three_days(day_start, delta_psi_epsilon, epsilon_degrees)?;
579
580 let m0 = (alpha_deltas[1].alpha - longitude - nu_degrees) / 360.0;
582
583 let polar_type = check_polar_conditions_type(latitude, elevation_angle, alpha_deltas[1].delta);
585
586 let (m_values, _h0_degrees) =
588 calculate_approximate_times(m0, latitude, elevation_angle, alpha_deltas[1].delta);
589
590 let final_result = calculate_final_times(FinalTimeParams {
592 day,
593 m_values,
594 nu_degrees,
595 delta_t,
596 latitude,
597 longitude,
598 elevation_angle,
599 alpha_deltas,
600 });
601
602 match polar_type {
604 Some(PolarType::AllDay) => {
605 if let crate::SunriseResult::RegularDay { transit, .. } = final_result {
606 Ok(crate::SunriseResult::AllDay { transit })
607 } else {
608 Ok(final_result)
609 }
610 }
611 Some(PolarType::AllNight) => {
612 if let crate::SunriseResult::RegularDay { transit, .. } = final_result {
613 Ok(crate::SunriseResult::AllNight { transit })
614 } else {
615 Ok(final_result)
616 }
617 }
618 None => Ok(final_result),
619 }
620}
621#[cfg(feature = "chrono")]
622#[allow(clippy::needless_pass_by_value)]
623fn calculate_sidereal_time_and_nutation<Tz: TimeZone>(
624 day_start: DateTime<Tz>,
625) -> (f64, DeltaPsiEpsilon, f64) {
626 let jd = JulianDate::from_datetime(&day_start, 0.0).unwrap();
627 let jce_day = jd.julian_ephemeris_century();
628 let x_terms = calculate_nutation_terms(jce_day);
629 let delta_psi_epsilon = calculate_delta_psi_epsilon(jce_day, &x_terms);
630 let epsilon_degrees =
631 calculate_true_obliquity_of_ecliptic(&jd, delta_psi_epsilon.delta_epsilon);
632 let nu_degrees = calculate_apparent_sidereal_time_at_greenwich(
633 &jd,
634 delta_psi_epsilon.delta_psi,
635 epsilon_degrees,
636 );
637 (nu_degrees, delta_psi_epsilon, epsilon_degrees)
638}
639
640#[cfg(feature = "chrono")]
641#[allow(clippy::needless_pass_by_value)]
642fn calculate_alpha_deltas_for_three_days<Tz: TimeZone>(
643 day_start: DateTime<Tz>,
644 delta_psi_epsilon: DeltaPsiEpsilon,
645 epsilon_degrees: f64,
646) -> Result<[AlphaDelta; 3]> {
647 let mut alpha_deltas = [AlphaDelta {
648 alpha: 0.0,
649 delta: 0.0,
650 }; 3];
651 for (i, alpha_delta) in alpha_deltas.iter_mut().enumerate() {
652 let current_jd = JulianDate::from_datetime(&day_start, 0.0)?.add_days((i as f64) - 1.0);
653 let current_jme = current_jd.julian_ephemeris_millennium();
654 let ad = calculate_alpha_delta(current_jme, delta_psi_epsilon.delta_psi, epsilon_degrees);
655 *alpha_delta = ad;
656 }
657 Ok(alpha_deltas)
658}
659
660#[derive(Debug, Clone, Copy)]
667enum PolarType {
668 AllDay,
669 AllNight,
670}
671
672fn check_polar_conditions_type(
674 latitude: f64,
675 elevation_angle: f64,
676 delta1: f64,
677) -> Option<PolarType> {
678 let phi = degrees_to_radians(latitude);
679 let elevation_rad = degrees_to_radians(elevation_angle);
680 let delta1_rad = degrees_to_radians(delta1);
681
682 let acos_arg =
683 mul_add(sin(phi), -sin(delta1_rad), sin(elevation_rad)) / (cos(phi) * cos(delta1_rad));
684
685 if acos_arg < -1.0 {
686 Some(PolarType::AllDay)
687 } else if acos_arg > 1.0 {
688 Some(PolarType::AllNight)
689 } else {
690 None
691 }
692}
693
694#[cfg(feature = "chrono")]
696fn check_polar_conditions<Tz: TimeZone>(
697 day: DateTime<Tz>,
698 m0: f64,
699 latitude: f64,
700 elevation_angle: f64,
701 delta1: f64,
702) -> Option<crate::SunriseResult<DateTime<Tz>>> {
703 check_polar_conditions_type(latitude, elevation_angle, delta1).map(|polar_type| {
704 let transit = add_fraction_of_day(day, m0);
705 match polar_type {
706 PolarType::AllDay => crate::SunriseResult::AllDay { transit },
707 PolarType::AllNight => crate::SunriseResult::AllNight { transit },
708 }
709 })
710}
711
712fn calculate_approximate_times(
714 m0: f64,
715 latitude: f64,
716 elevation_angle: f64,
717 delta1: f64,
718) -> ([f64; 3], f64) {
719 let phi = degrees_to_radians(latitude);
720 let delta1_rad = degrees_to_radians(delta1);
721 let elevation_rad = degrees_to_radians(elevation_angle);
722
723 let acos_arg =
724 mul_add(sin(phi), -sin(delta1_rad), sin(elevation_rad)) / (cos(phi) * cos(delta1_rad));
725 let h0 = acos(acos_arg);
726 let h0_degrees = radians_to_degrees(h0).min(180.0);
727
728 let mut m = [0.0; 3];
729 m[0] = normalize_to_unit_range(m0);
730 m[1] = normalize_to_unit_range(m0 - h0_degrees / 360.0);
731 m[2] = normalize_to_unit_range(m0 + h0_degrees / 360.0);
732
733 (m, h0_degrees)
734}
735
736fn calculate_final_time_fractions(
739 m_values: [f64; 3],
740 nu_degrees: f64,
741 delta_t: f64,
742 latitude: f64,
743 longitude: f64,
744 elevation_angle: f64,
745 alpha_deltas: [AlphaDelta; 3],
746) -> (f64, f64, f64) {
747 let mut nu = [0.0; 3];
749 for (i, nu_item) in nu.iter_mut().enumerate() {
750 *nu_item = mul_add(360.985647f64, m_values[i], nu_degrees);
751 }
752
753 let mut n = [0.0; 3];
755 for (i, n_item) in n.iter_mut().enumerate() {
756 *n_item = m_values[i] + delta_t / 86400.0;
757 }
758
759 let alpha_delta_primes = calculate_interpolated_alpha_deltas(&alpha_deltas, &n);
761
762 let mut h_prime = [0.0; 3];
764 for i in 0..3 {
765 let h_prime_i = nu[i] + longitude - alpha_delta_primes[i].alpha;
766 h_prime[i] = limit_h_prime(h_prime_i);
767 }
768
769 let phi = degrees_to_radians(latitude);
771 let mut h = [0.0; 3];
772 for i in 0..3 {
773 let delta_prime_rad = degrees_to_radians(alpha_delta_primes[i].delta);
774 h[i] = radians_to_degrees(asin(mul_add(
775 sin(phi),
776 sin(delta_prime_rad),
777 cos(phi) * cos(delta_prime_rad) * cos(degrees_to_radians(h_prime[i])),
778 )));
779 }
780
781 let t = m_values[0] - h_prime[0] / 360.0;
783 let r = m_values[1]
784 + (h[1] - elevation_angle)
785 / (360.0
786 * cos(degrees_to_radians(alpha_delta_primes[1].delta))
787 * cos(phi)
788 * sin(degrees_to_radians(h_prime[1])));
789 let s = m_values[2]
790 + (h[2] - elevation_angle)
791 / (360.0
792 * cos(degrees_to_radians(alpha_delta_primes[2].delta))
793 * cos(phi)
794 * sin(degrees_to_radians(h_prime[2])));
795
796 (t, r, s)
797}
798
799#[cfg(feature = "chrono")]
801fn calculate_final_times<Tz: TimeZone>(
802 params: FinalTimeParams<Tz>,
803) -> crate::SunriseResult<DateTime<Tz>> {
804 let mut nu = [0.0; 3];
806 for (i, nu_item) in nu.iter_mut().enumerate() {
807 *nu_item = mul_add(360.985647f64, params.m_values[i], params.nu_degrees);
808 }
809
810 let mut n = [0.0; 3];
812 for (i, n_item) in n.iter_mut().enumerate() {
813 *n_item = params.m_values[i] + params.delta_t / 86400.0;
814 }
815
816 let alpha_delta_primes = calculate_interpolated_alpha_deltas(¶ms.alpha_deltas, &n);
818
819 let mut h_prime = [0.0; 3];
821 for i in 0..3 {
822 let h_prime_i = nu[i] + params.longitude - alpha_delta_primes[i].alpha;
823 h_prime[i] = limit_h_prime(h_prime_i);
824 }
825
826 let phi = degrees_to_radians(params.latitude);
828 let mut h = [0.0; 3];
829 for i in 0..3 {
830 let delta_prime_rad = degrees_to_radians(alpha_delta_primes[i].delta);
831 h[i] = radians_to_degrees(asin(mul_add(
832 sin(phi),
833 sin(delta_prime_rad),
834 cos(phi) * cos(delta_prime_rad) * cos(degrees_to_radians(h_prime[i])),
835 )));
836 }
837
838 let t = params.m_values[0] - h_prime[0] / 360.0;
840 let r = params.m_values[1]
841 + (h[1] - params.elevation_angle)
842 / (360.0
843 * cos(degrees_to_radians(alpha_delta_primes[1].delta))
844 * cos(phi)
845 * sin(degrees_to_radians(h_prime[1])));
846 let s = params.m_values[2]
847 + (h[2] - params.elevation_angle)
848 / (360.0
849 * cos(degrees_to_radians(alpha_delta_primes[2].delta))
850 * cos(phi)
851 * sin(degrees_to_radians(h_prime[2])));
852
853 let sunrise = add_fraction_of_day(params.day.clone(), r);
854 let transit = add_fraction_of_day(params.day.clone(), t);
855 let sunset = add_fraction_of_day(params.day, s);
856
857 crate::SunriseResult::RegularDay {
858 sunrise,
859 transit,
860 sunset,
861 }
862}
863
864fn calculate_interpolated_alpha_deltas(
866 alpha_deltas: &[AlphaDelta; 3],
867 n: &[f64; 3],
868) -> [AlphaDelta; 3] {
869 let a = limit_if_necessary(alpha_deltas[1].alpha - alpha_deltas[0].alpha);
870 let a_prime = limit_if_necessary(alpha_deltas[1].delta - alpha_deltas[0].delta);
871
872 let b = limit_if_necessary(alpha_deltas[2].alpha - alpha_deltas[1].alpha);
873 let b_prime = limit_if_necessary(alpha_deltas[2].delta - alpha_deltas[1].delta);
874
875 let c = b - a;
876 let c_prime = b_prime - a_prime;
877
878 let mut alpha_delta_primes = [AlphaDelta {
879 alpha: 0.0,
880 delta: 0.0,
881 }; 3];
882 for i in 0..3 {
883 alpha_delta_primes[i].alpha =
884 alpha_deltas[1].alpha + (n[i] * (mul_add(c, n[i], a + b))) / 2.0;
885 alpha_delta_primes[i].delta =
886 alpha_deltas[1].delta + (n[i] * (mul_add(c_prime, n[i], a_prime + b_prime))) / 2.0;
887 }
888 alpha_delta_primes
889}
890
891#[derive(Debug, Clone, Copy)]
892struct AlphaDelta {
893 alpha: f64,
894 delta: f64,
895}
896
897#[derive(Debug, Clone)]
899#[cfg(feature = "chrono")]
900struct FinalTimeParams<Tz: TimeZone> {
901 day: DateTime<Tz>,
902 m_values: [f64; 3],
903 nu_degrees: f64,
904 delta_t: f64,
905 latitude: f64,
906 longitude: f64,
907 elevation_angle: f64,
908 alpha_deltas: [AlphaDelta; 3],
909}
910
911#[cfg(feature = "chrono")]
949pub fn sunrise_sunset_for_horizon<Tz: TimeZone>(
950 date: DateTime<Tz>,
951 latitude: f64,
952 longitude: f64,
953 delta_t: f64,
954 horizon: Horizon,
955) -> Result<crate::SunriseResult<DateTime<Tz>>> {
956 sunrise_sunset(
957 date,
958 latitude,
959 longitude,
960 delta_t,
961 horizon.elevation_angle(),
962 )
963}
964
965fn calculate_alpha_delta(jme: f64, delta_psi: f64, epsilon_degrees: f64) -> AlphaDelta {
968 let b_terms = calculate_lbr_terms(jme, TERMS_B);
972 let b_degrees = lbr_to_normalized_degrees(jme, &b_terms, TERMS_B.len());
973
974 let r_terms = calculate_lbr_terms(jme, TERMS_R);
976 let r = calculate_lbr_polynomial(jme, &r_terms, TERMS_R.len());
977 assert!(
978 r != 0.0,
979 "Earth radius vector is zero - astronomical impossibility"
980 );
981
982 let l_terms = calculate_lbr_terms(jme, TERMS_L);
984 let l_degrees = lbr_to_normalized_degrees(jme, &l_terms, TERMS_L.len());
985
986 let theta_degrees = normalize_degrees_0_to_360(l_degrees + 180.0);
988
989 let beta_degrees = -b_degrees;
991 let beta = degrees_to_radians(beta_degrees);
992 let epsilon = degrees_to_radians(epsilon_degrees);
993
994 let delta_tau = ABERRATION_CONSTANT / (SECONDS_PER_HOUR * r);
996
997 let lambda_degrees = theta_degrees + delta_psi + delta_tau;
999 let lambda = degrees_to_radians(lambda_degrees);
1000
1001 let alpha_degrees = calculate_geocentric_sun_right_ascension(beta, epsilon, lambda);
1003 let delta_degrees =
1004 radians_to_degrees(calculate_geocentric_sun_declination(beta, epsilon, lambda));
1005
1006 AlphaDelta {
1007 alpha: alpha_degrees,
1008 delta: delta_degrees,
1009 }
1010}
1011
1012fn normalize_to_unit_range(val: f64) -> f64 {
1014 let divided = val;
1015 let limited = divided - floor(divided);
1016 if limited < 0.0 {
1017 limited + 1.0
1018 } else {
1019 limited
1020 }
1021}
1022
1023#[cfg(feature = "chrono")]
1024fn truncate_to_day_start<Tz: TimeZone>(datetime: &DateTime<Tz>) -> DateTime<Tz> {
1025 datetime
1026 .timezone()
1027 .from_local_datetime(
1028 &datetime
1029 .date_naive()
1030 .and_hms_opt(0, 0, 0)
1031 .expect("midnight is always valid"),
1032 )
1033 .single()
1034 .expect("midnight should have single timezone representation")
1035}
1036
1037#[cfg(feature = "chrono")]
1038#[allow(clippy::needless_pass_by_value)]
1039fn add_fraction_of_day<Tz: TimeZone>(day: DateTime<Tz>, fraction: f64) -> DateTime<Tz> {
1040 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);
1048
1049 day_start + chrono::Duration::milliseconds(i64::from(millis_plus))
1050}
1051
1052fn limit_if_necessary(val: f64) -> f64 {
1054 if val.abs() > 2.0 {
1055 normalize_to_unit_range(val)
1056 } else {
1057 val
1058 }
1059}
1060
1061fn limit_h_prime(h_prime: f64) -> f64 {
1063 let normalized = h_prime / 360.0;
1064 let limited = 360.0 * (normalized - floor(normalized));
1065
1066 if limited < -180.0 {
1067 limited + 360.0
1068 } else if limited > 180.0 {
1069 limited - 360.0
1070 } else {
1071 limited
1072 }
1073}
1074
1075#[cfg(feature = "chrono")]
1124pub fn sunrise_sunset_multiple<Tz, H>(
1125 date: DateTime<Tz>,
1126 latitude: f64,
1127 longitude: f64,
1128 delta_t: f64,
1129 horizons: H,
1130) -> impl Iterator<Item = Result<(Horizon, crate::SunriseResult<DateTime<Tz>>)>>
1131where
1132 Tz: TimeZone,
1133 H: IntoIterator<Item = Horizon>,
1134{
1135 let precomputed = (|| -> Result<_> {
1137 check_coordinates(latitude, longitude)?;
1138
1139 let day_start = truncate_to_day_start(&date);
1140 let (nu_degrees, delta_psi_epsilon, epsilon_degrees) =
1141 calculate_sidereal_time_and_nutation(day_start.clone());
1142 let alpha_deltas =
1143 calculate_alpha_deltas_for_three_days(day_start, delta_psi_epsilon, epsilon_degrees)?;
1144
1145 Ok((nu_degrees, alpha_deltas))
1146 })();
1147
1148 horizons.into_iter().map(move |horizon| match &precomputed {
1149 Ok((nu_degrees, alpha_deltas)) => {
1150 let result = calculate_sunrise_sunset_spa_algorithm_with_precomputed(
1151 date.clone(),
1152 latitude,
1153 longitude,
1154 delta_t,
1155 horizon.elevation_angle(),
1156 *nu_degrees,
1157 *alpha_deltas,
1158 );
1159 Ok((horizon, result))
1160 }
1161 Err(e) => Err(e.clone()),
1162 })
1163}
1164#[cfg(feature = "chrono")]
1165#[allow(clippy::needless_pass_by_value)]
1166fn calculate_sunrise_sunset_spa_algorithm_with_precomputed<Tz: TimeZone>(
1167 day: DateTime<Tz>,
1168 latitude: f64,
1169 longitude: f64,
1170 delta_t: f64,
1171 elevation_angle: f64,
1172 nu_degrees: f64,
1173 alpha_deltas: [AlphaDelta; 3],
1174) -> crate::SunriseResult<DateTime<Tz>> {
1175 let day_start = truncate_to_day_start(&day);
1176
1177 let m0 = (alpha_deltas[1].alpha - longitude - nu_degrees) / 360.0;
1179 if let Some(polar_result) = check_polar_conditions(
1180 day_start.clone(),
1181 m0,
1182 latitude,
1183 elevation_angle,
1184 alpha_deltas[1].delta,
1185 ) {
1186 return polar_result;
1187 }
1188
1189 let (m_values, _h0_degrees) =
1191 calculate_approximate_times(m0, latitude, elevation_angle, alpha_deltas[1].delta);
1192
1193 calculate_final_times(FinalTimeParams {
1195 day: day_start,
1196 m_values,
1197 nu_degrees,
1198 delta_t,
1199 latitude,
1200 longitude,
1201 elevation_angle,
1202 alpha_deltas,
1203 })
1204}
1205
1206#[cfg(feature = "chrono")]
1244#[allow(clippy::needless_pass_by_value)]
1245pub fn spa_time_dependent_parts<Tz: TimeZone>(
1246 datetime: DateTime<Tz>,
1247 delta_t: f64,
1248) -> Result<SpaTimeDependent> {
1249 let jd = JulianDate::from_datetime(&datetime, delta_t)?;
1250 spa_time_dependent_from_julian(jd)
1251}
1252
1253pub fn spa_time_dependent_from_julian(jd: JulianDate) -> Result<SpaTimeDependent> {
1263 let jme = jd.julian_ephemeris_millennium();
1264 let jce = jd.julian_ephemeris_century();
1265
1266 let l_terms = calculate_lbr_terms(jme, TERMS_L);
1268 let l_degrees = lbr_to_normalized_degrees(jme, &l_terms, TERMS_L.len());
1269
1270 let b_terms = calculate_lbr_terms(jme, TERMS_B);
1272 let b_degrees = lbr_to_normalized_degrees(jme, &b_terms, TERMS_B.len());
1273
1274 let r_terms = calculate_lbr_terms(jme, TERMS_R);
1276 let r = calculate_lbr_polynomial(jme, &r_terms, TERMS_R.len());
1277
1278 assert!(
1280 r != 0.0,
1281 "Earth radius vector is zero - astronomical impossibility"
1282 );
1283
1284 let theta_degrees = normalize_degrees_0_to_360(l_degrees + 180.0);
1286 let beta_degrees = -b_degrees;
1288
1289 let x_terms = calculate_nutation_terms(jce);
1291 let delta_psi_epsilon = calculate_delta_psi_epsilon(jce, &x_terms);
1292
1293 let epsilon_degrees =
1295 calculate_true_obliquity_of_ecliptic(&jd, delta_psi_epsilon.delta_epsilon);
1296
1297 let delta_tau = ABERRATION_CONSTANT / (SECONDS_PER_HOUR * r);
1299
1300 let lambda_degrees = theta_degrees + delta_psi_epsilon.delta_psi + delta_tau;
1302
1303 let nu_degrees = calculate_apparent_sidereal_time_at_greenwich(
1305 &jd,
1306 delta_psi_epsilon.delta_psi,
1307 epsilon_degrees,
1308 );
1309
1310 let beta = degrees_to_radians(beta_degrees);
1312 let epsilon = degrees_to_radians(epsilon_degrees);
1313 let lambda = degrees_to_radians(lambda_degrees);
1314 let alpha_degrees = calculate_geocentric_sun_right_ascension(beta, epsilon, lambda);
1315
1316 let delta_degrees =
1318 radians_to_degrees(calculate_geocentric_sun_declination(beta, epsilon, lambda));
1319
1320 Ok(SpaTimeDependent {
1321 theta_degrees,
1322 beta_degrees,
1323 r,
1324 delta_psi: delta_psi_epsilon.delta_psi,
1325 epsilon_degrees,
1326 lambda_degrees,
1327 nu_degrees,
1328 alpha_degrees,
1329 delta_degrees,
1330 })
1331}
1332
1333pub fn spa_with_time_dependent_parts(
1371 latitude: f64,
1372 longitude: f64,
1373 elevation: f64,
1374 refraction: Option<RefractionCorrection>,
1375 time_dependent: &SpaTimeDependent,
1376) -> Result<SolarPosition> {
1377 check_coordinates(latitude, longitude)?;
1378
1379 let nu_degrees = time_dependent.nu_degrees;
1382
1383 let h_degrees =
1385 normalize_degrees_0_to_360(nu_degrees + longitude - time_dependent.alpha_degrees);
1386 let h = degrees_to_radians(h_degrees);
1387
1388 let xi_degrees = 8.794 / (3600.0 * time_dependent.r);
1390 let xi = degrees_to_radians(xi_degrees);
1391 let phi = degrees_to_radians(latitude);
1392 let delta = degrees_to_radians(time_dependent.delta_degrees);
1393
1394 let u = atan(EARTH_FLATTENING_FACTOR * tan(phi));
1395 let y = mul_add(
1396 EARTH_FLATTENING_FACTOR,
1397 sin(u),
1398 (elevation / EARTH_RADIUS_METERS) * sin(phi),
1399 );
1400 let x = mul_add(elevation / EARTH_RADIUS_METERS, cos(phi), cos(u));
1401
1402 let delta_alpha_prime_degrees = radians_to_degrees(atan2(
1403 -x * sin(xi) * sin(h),
1404 mul_add(x * sin(xi), -cos(h), cos(delta)),
1405 ));
1406
1407 let delta_prime = radians_to_degrees(atan2(
1408 mul_add(y, -sin(xi), sin(delta)) * cos(degrees_to_radians(delta_alpha_prime_degrees)),
1409 mul_add(x * sin(xi), -cos(h), cos(delta)),
1410 ));
1411
1412 let h_prime_degrees = h_degrees - delta_alpha_prime_degrees;
1414
1415 let zenith_angle = radians_to_degrees(acos(mul_add(
1417 sin(degrees_to_radians(latitude)),
1418 sin(degrees_to_radians(delta_prime)),
1419 cos(degrees_to_radians(latitude))
1420 * cos(degrees_to_radians(delta_prime))
1421 * cos(degrees_to_radians(h_prime_degrees)),
1422 )));
1423
1424 let azimuth = normalize_degrees_0_to_360(
1426 180.0
1427 + radians_to_degrees(atan2(
1428 sin(degrees_to_radians(h_prime_degrees)),
1429 cos(degrees_to_radians(h_prime_degrees)) * sin(degrees_to_radians(latitude))
1430 - tan(degrees_to_radians(delta_prime)) * cos(degrees_to_radians(latitude)),
1431 )),
1432 );
1433
1434 let elevation_angle = 90.0 - zenith_angle;
1436 let final_zenith = refraction.map_or(zenith_angle, |correction| {
1437 if elevation_angle > SUNRISE_SUNSET_ANGLE {
1438 let pressure = correction.pressure();
1439 let temperature = correction.temperature();
1440 zenith_angle
1442 - (pressure / 1010.0) * (283.0 / (273.0 + temperature)) * 1.02
1443 / (60.0
1444 * tan(degrees_to_radians(
1445 elevation_angle + 10.3 / (elevation_angle + 5.11),
1446 )))
1447 } else {
1448 zenith_angle
1449 }
1450 });
1451
1452 SolarPosition::new(azimuth, final_zenith)
1453}
1454
1455#[cfg(all(test, feature = "chrono", feature = "std"))]
1456mod tests {
1457 use super::*;
1458 use chrono::{DateTime, FixedOffset};
1459 use std::collections::HashSet;
1460
1461 #[test]
1462 fn test_spa_basic_functionality() {
1463 let datetime = "2023-06-21T12:00:00Z"
1464 .parse::<DateTime<FixedOffset>>()
1465 .unwrap();
1466
1467 let result = solar_position(
1468 datetime,
1469 37.7749, -122.4194,
1471 0.0,
1472 69.0,
1473 Some(RefractionCorrection::new(1013.25, 15.0).unwrap()),
1474 );
1475
1476 assert!(result.is_ok());
1477 let position = result.unwrap();
1478 assert!(position.azimuth() >= 0.0 && position.azimuth() <= 360.0);
1479 assert!(position.zenith_angle() >= 0.0 && position.zenith_angle() <= 180.0);
1480 }
1481
1482 #[test]
1483 fn test_sunrise_sunset_multiple() {
1484 let datetime = "2023-06-21T12:00:00Z"
1485 .parse::<DateTime<FixedOffset>>()
1486 .unwrap();
1487 let horizons = [
1488 Horizon::SunriseSunset,
1489 Horizon::CivilTwilight,
1490 Horizon::NauticalTwilight,
1491 ];
1492
1493 let results: Result<Vec<_>> = sunrise_sunset_multiple(
1494 datetime,
1495 37.7749, -122.4194, 69.0, horizons.iter().copied(),
1499 )
1500 .collect();
1501
1502 let results = results.unwrap();
1503
1504 assert_eq!(results.len(), 3);
1506
1507 let returned_horizons: HashSet<_> = results.iter().map(|(h, _)| *h).collect();
1509 for expected_horizon in horizons {
1510 assert!(returned_horizons.contains(&expected_horizon));
1511 }
1512
1513 for (horizon, bulk_result) in &results {
1515 let individual_result =
1516 sunrise_sunset_for_horizon(datetime, 37.7749, -122.4194, 69.0, *horizon).unwrap();
1517
1518 match (&individual_result, bulk_result) {
1520 (
1521 crate::SunriseResult::RegularDay {
1522 sunrise: s1,
1523 transit: t1,
1524 sunset: ss1,
1525 },
1526 crate::SunriseResult::RegularDay {
1527 sunrise: s2,
1528 transit: t2,
1529 sunset: ss2,
1530 },
1531 ) => {
1532 assert_eq!(s1, s2);
1533 assert_eq!(t1, t2);
1534 assert_eq!(ss1, ss2);
1535 }
1536 (
1537 crate::SunriseResult::AllDay { transit: t1 },
1538 crate::SunriseResult::AllDay { transit: t2 },
1539 )
1540 | (
1541 crate::SunriseResult::AllNight { transit: t1 },
1542 crate::SunriseResult::AllNight { transit: t2 },
1543 ) => {
1544 assert_eq!(t1, t2);
1545 }
1546 _ => panic!("Bulk and individual results differ in type for {horizon:?}"),
1547 }
1548 }
1549 }
1550
1551 #[test]
1552 fn test_spa_no_refraction() {
1553 let datetime = "2023-06-21T12:00:00Z"
1554 .parse::<DateTime<FixedOffset>>()
1555 .unwrap();
1556
1557 let result = solar_position(datetime, 37.7749, -122.4194, 0.0, 69.0, None);
1558
1559 assert!(result.is_ok());
1560 let position = result.unwrap();
1561 assert!(position.azimuth() >= 0.0 && position.azimuth() <= 360.0);
1562 assert!(position.zenith_angle() >= 0.0 && position.zenith_angle() <= 180.0);
1563 }
1564
1565 #[test]
1566 fn test_spa_coordinate_validation() {
1567 let datetime = "2023-06-21T12:00:00Z"
1568 .parse::<DateTime<FixedOffset>>()
1569 .unwrap();
1570
1571 assert!(solar_position(
1573 datetime,
1574 95.0,
1575 0.0,
1576 0.0,
1577 0.0,
1578 Some(RefractionCorrection::new(1013.25, 15.0).unwrap())
1579 )
1580 .is_err());
1581
1582 assert!(solar_position(
1584 datetime,
1585 0.0,
1586 185.0,
1587 0.0,
1588 0.0,
1589 Some(RefractionCorrection::new(1013.25, 15.0).unwrap())
1590 )
1591 .is_err());
1592 }
1593
1594 #[test]
1595 fn test_sunrise_sunset_basic() {
1596 let date = "2023-06-21T00:00:00Z"
1597 .parse::<DateTime<FixedOffset>>()
1598 .unwrap();
1599
1600 let result = sunrise_sunset(date, 37.7749, -122.4194, 69.0, -0.833);
1601 assert!(result.is_ok());
1602
1603 let result =
1604 sunrise_sunset_for_horizon(date, 37.7749, -122.4194, 69.0, Horizon::SunriseSunset);
1605 assert!(result.is_ok());
1606 }
1607
1608 #[test]
1609 fn test_horizon_enum() {
1610 assert_eq!(Horizon::SunriseSunset.elevation_angle(), -0.83337);
1611 assert_eq!(Horizon::CivilTwilight.elevation_angle(), -6.0);
1612 assert_eq!(Horizon::Custom(-10.5).elevation_angle(), -10.5);
1613 }
1614}