1#![allow(clippy::similar_names)]
10#![allow(clippy::many_single_char_names)]
11#![allow(clippy::unreadable_literal)]
12
13#[cfg(feature = "std")]
14use crate::Horizon;
15use crate::error::check_coordinates;
16use crate::math::{
17 acos, asin, atan, atan2, cos, degrees_to_radians, floor, normalize_degrees_0_to_360,
18 polynomial, radians_to_degrees, sin, tan,
19};
20use crate::time::JulianDate;
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 = "std")]
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 = "std")]
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(term[2].mul_add(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 = pe_term[1].mul_add(jce, pe_term[0]) * sin(xj_yterm_sum);
237 let delta_epsilon_contrib = pe_term[3].mul_add(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(jd.julian_century().powi(2).mul_add(
271 0.000387933 - jd.julian_century() / 38710000.0,
272 360.98564736629f64.mul_add(jd.julian_date() - 2451545.0, 280.46061837),
273 ));
274
275 delta_psi.mul_add(cos(degrees_to_radians(epsilon_degrees)), nu0_degrees)
276}
277
278fn calculate_geocentric_sun_right_ascension(
280 beta_rad: f64,
281 epsilon_rad: f64,
282 lambda_rad: f64,
283) -> f64 {
284 let alpha = atan2(
285 sin(lambda_rad).mul_add(cos(epsilon_rad), -(tan(beta_rad) * sin(epsilon_rad))),
286 cos(lambda_rad),
287 );
288 normalize_degrees_0_to_360(radians_to_degrees(alpha))
289}
290
291fn calculate_geocentric_sun_declination(beta_rad: f64, epsilon_rad: f64, lambda_rad: f64) -> f64 {
293 asin(sin(beta_rad).mul_add(
294 cos(epsilon_rad),
295 cos(beta_rad) * sin(epsilon_rad) * sin(lambda_rad),
296 ))
297}
298
299#[cfg(feature = "std")]
337#[allow(clippy::needless_pass_by_value)]
338pub fn sunrise_sunset<Tz: TimeZone>(
339 date: DateTime<Tz>,
340 latitude: f64,
341 longitude: f64,
342 delta_t: f64,
343 elevation_angle: f64,
344) -> Result<crate::SunriseResult<DateTime<Tz>>> {
345 check_coordinates(latitude, longitude)?;
346
347 let day_start = truncate_to_day_start(&date);
348
349 calculate_sunrise_sunset_spa_algorithm(day_start, latitude, longitude, delta_t, elevation_angle)
351}
352
353#[cfg(feature = "std")]
355fn calculate_sunrise_sunset_spa_algorithm<Tz: TimeZone>(
356 day: DateTime<Tz>,
357 latitude: f64,
358 longitude: f64,
359 delta_t: f64,
360 elevation_angle: f64,
361) -> Result<crate::SunriseResult<DateTime<Tz>>> {
362 let day_start = truncate_to_day_start(&day);
363
364 let (nu_degrees, delta_psi_epsilon, epsilon_degrees) =
366 calculate_sidereal_time_and_nutation(day_start.clone());
367
368 let alpha_deltas =
370 calculate_alpha_deltas_for_three_days(day_start, delta_psi_epsilon, epsilon_degrees)?;
371
372 let m0 = (alpha_deltas[1].alpha - longitude - nu_degrees) / 360.0;
374
375 let polar_type = check_polar_conditions_type(latitude, elevation_angle, alpha_deltas[1].delta);
377
378 let (m_values, _h0_degrees) =
380 calculate_approximate_times(m0, latitude, elevation_angle, alpha_deltas[1].delta);
381
382 let final_result = calculate_final_times(FinalTimeParams {
384 day,
385 m_values,
386 nu_degrees,
387 delta_t,
388 latitude,
389 longitude,
390 elevation_angle,
391 alpha_deltas,
392 });
393
394 match polar_type {
396 Some(PolarType::AllDay) => {
397 if let crate::SunriseResult::RegularDay { transit, .. } = final_result {
398 Ok(crate::SunriseResult::AllDay { transit })
399 } else {
400 Ok(final_result)
401 }
402 }
403 Some(PolarType::AllNight) => {
404 if let crate::SunriseResult::RegularDay { transit, .. } = final_result {
405 Ok(crate::SunriseResult::AllNight { transit })
406 } else {
407 Ok(final_result)
408 }
409 }
410 None => Ok(final_result),
411 }
412}
413
414#[allow(clippy::needless_pass_by_value)]
416#[cfg(feature = "std")]
417fn calculate_sidereal_time_and_nutation<Tz: TimeZone>(
418 day_start: DateTime<Tz>,
419) -> (f64, DeltaPsiEpsilon, f64) {
420 let jd = JulianDate::from_datetime(&day_start, 0.0).unwrap();
421 let jce_day = jd.julian_ephemeris_century();
422 let x_terms = calculate_nutation_terms(jce_day);
423 let delta_psi_epsilon = calculate_delta_psi_epsilon(jce_day, &x_terms);
424 let epsilon_degrees =
425 calculate_true_obliquity_of_ecliptic(&jd, delta_psi_epsilon.delta_epsilon);
426 let nu_degrees = calculate_apparent_sidereal_time_at_greenwich(
427 &jd,
428 delta_psi_epsilon.delta_psi,
429 epsilon_degrees,
430 );
431 (nu_degrees, delta_psi_epsilon, epsilon_degrees)
432}
433
434#[allow(clippy::needless_pass_by_value)]
436#[cfg(feature = "std")]
437fn calculate_alpha_deltas_for_three_days<Tz: TimeZone>(
438 day_start: DateTime<Tz>,
439 delta_psi_epsilon: DeltaPsiEpsilon,
440 epsilon_degrees: f64,
441) -> Result<[AlphaDelta; 3]> {
442 let mut alpha_deltas = [AlphaDelta {
443 alpha: 0.0,
444 delta: 0.0,
445 }; 3];
446 for (i, alpha_delta) in alpha_deltas.iter_mut().enumerate() {
447 let current_jd = JulianDate::from_datetime(&day_start, 0.0)?.add_days((i as f64) - 1.0);
448 let current_jme = current_jd.julian_ephemeris_millennium();
449 let ad = calculate_alpha_delta(current_jme, delta_psi_epsilon.delta_psi, epsilon_degrees);
450 *alpha_delta = ad;
451 }
452 Ok(alpha_deltas)
453}
454
455#[cfg(feature = "std")]
457#[derive(Debug, Clone, Copy)]
458enum PolarType {
459 AllDay,
460 AllNight,
461}
462
463#[cfg(feature = "std")]
465fn check_polar_conditions_type(
466 latitude: f64,
467 elevation_angle: f64,
468 delta1: f64,
469) -> Option<PolarType> {
470 let phi = degrees_to_radians(latitude);
471 let elevation_rad = degrees_to_radians(elevation_angle);
472 let delta1_rad = degrees_to_radians(delta1);
473
474 let acos_arg =
475 sin(phi).mul_add(-sin(delta1_rad), sin(elevation_rad)) / (cos(phi) * cos(delta1_rad));
476
477 if acos_arg < -1.0 {
478 Some(PolarType::AllDay)
479 } else if acos_arg > 1.0 {
480 Some(PolarType::AllNight)
481 } else {
482 None
483 }
484}
485
486#[cfg(feature = "std")]
488fn check_polar_conditions<Tz: TimeZone>(
489 day: DateTime<Tz>,
490 m0: f64,
491 latitude: f64,
492 elevation_angle: f64,
493 delta1: f64,
494) -> Option<crate::SunriseResult<DateTime<Tz>>> {
495 check_polar_conditions_type(latitude, elevation_angle, delta1).map(|polar_type| {
496 let transit = add_fraction_of_day(day, m0);
497 match polar_type {
498 PolarType::AllDay => crate::SunriseResult::AllDay { transit },
499 PolarType::AllNight => crate::SunriseResult::AllNight { transit },
500 }
501 })
502}
503
504fn calculate_approximate_times(
506 m0: f64,
507 latitude: f64,
508 elevation_angle: f64,
509 delta1: f64,
510) -> ([f64; 3], f64) {
511 let phi = degrees_to_radians(latitude);
512 let delta1_rad = degrees_to_radians(delta1);
513 let elevation_rad = degrees_to_radians(elevation_angle);
514
515 let acos_arg =
516 sin(phi).mul_add(-sin(delta1_rad), sin(elevation_rad)) / (cos(phi) * cos(delta1_rad));
517 let h0 = acos(acos_arg);
518 let h0_degrees = radians_to_degrees(h0).min(180.0);
519
520 let mut m = [0.0; 3];
521 m[0] = normalize_to_unit_range(m0);
522 m[1] = normalize_to_unit_range(m0 - h0_degrees / 360.0);
523 m[2] = normalize_to_unit_range(m0 + h0_degrees / 360.0);
524
525 (m, h0_degrees)
526}
527
528#[cfg(feature = "std")]
530fn calculate_final_times<Tz: TimeZone>(
531 params: FinalTimeParams<Tz>,
532) -> crate::SunriseResult<DateTime<Tz>> {
533 let mut nu = [0.0; 3];
535 for (i, nu_item) in nu.iter_mut().enumerate() {
536 *nu_item = 360.985647f64.mul_add(params.m_values[i], params.nu_degrees);
537 }
538
539 let mut n = [0.0; 3];
541 for (i, n_item) in n.iter_mut().enumerate() {
542 *n_item = params.m_values[i] + params.delta_t / 86400.0;
543 }
544
545 let alpha_delta_primes = calculate_interpolated_alpha_deltas(¶ms.alpha_deltas, &n);
547
548 let mut h_prime = [0.0; 3];
550 for i in 0..3 {
551 let h_prime_i = nu[i] + params.longitude - alpha_delta_primes[i].alpha;
552 h_prime[i] = limit_h_prime(h_prime_i);
553 }
554
555 let phi = degrees_to_radians(params.latitude);
557 let mut h = [0.0; 3];
558 for i in 0..3 {
559 let delta_prime_rad = degrees_to_radians(alpha_delta_primes[i].delta);
560 h[i] = radians_to_degrees(asin(sin(phi).mul_add(
561 sin(delta_prime_rad),
562 cos(phi) * cos(delta_prime_rad) * cos(degrees_to_radians(h_prime[i])),
563 )));
564 }
565
566 let t = params.m_values[0] - h_prime[0] / 360.0;
568 let r = params.m_values[1]
569 + (h[1] - params.elevation_angle)
570 / (360.0
571 * cos(degrees_to_radians(alpha_delta_primes[1].delta))
572 * cos(phi)
573 * sin(degrees_to_radians(h_prime[1])));
574 let s = params.m_values[2]
575 + (h[2] - params.elevation_angle)
576 / (360.0
577 * cos(degrees_to_radians(alpha_delta_primes[2].delta))
578 * cos(phi)
579 * sin(degrees_to_radians(h_prime[2])));
580
581 let sunrise = add_fraction_of_day(params.day.clone(), r);
582 let transit = add_fraction_of_day(params.day.clone(), t);
583 let sunset = add_fraction_of_day(params.day, s);
584
585 crate::SunriseResult::RegularDay {
586 sunrise,
587 transit,
588 sunset,
589 }
590}
591
592fn calculate_interpolated_alpha_deltas(
594 alpha_deltas: &[AlphaDelta; 3],
595 n: &[f64; 3],
596) -> [AlphaDelta; 3] {
597 let a = limit_if_necessary(alpha_deltas[1].alpha - alpha_deltas[0].alpha);
598 let a_prime = limit_if_necessary(alpha_deltas[1].delta - alpha_deltas[0].delta);
599
600 let b = limit_if_necessary(alpha_deltas[2].alpha - alpha_deltas[1].alpha);
601 let b_prime = limit_if_necessary(alpha_deltas[2].delta - alpha_deltas[1].delta);
602
603 let c = b - a;
604 let c_prime = b_prime - a_prime;
605
606 let mut alpha_delta_primes = [AlphaDelta {
607 alpha: 0.0,
608 delta: 0.0,
609 }; 3];
610 for i in 0..3 {
611 alpha_delta_primes[i].alpha =
612 alpha_deltas[1].alpha + (n[i] * (c.mul_add(n[i], a + b))) / 2.0;
613 alpha_delta_primes[i].delta =
614 alpha_deltas[1].delta + (n[i] * (c_prime.mul_add(n[i], a_prime + b_prime))) / 2.0;
615 }
616 alpha_delta_primes
617}
618
619#[derive(Debug, Clone, Copy)]
620struct AlphaDelta {
621 alpha: f64,
622 delta: f64,
623}
624
625#[cfg(feature = "std")]
627#[derive(Debug, Clone)]
628struct FinalTimeParams<Tz: TimeZone> {
629 day: DateTime<Tz>,
630 m_values: [f64; 3],
631 nu_degrees: f64,
632 delta_t: f64,
633 latitude: f64,
634 longitude: f64,
635 elevation_angle: f64,
636 alpha_deltas: [AlphaDelta; 3],
637}
638
639#[cfg(feature = "std")]
677pub fn sunrise_sunset_for_horizon<Tz: TimeZone>(
678 date: DateTime<Tz>,
679 latitude: f64,
680 longitude: f64,
681 delta_t: f64,
682 horizon: Horizon,
683) -> Result<crate::SunriseResult<DateTime<Tz>>> {
684 sunrise_sunset(
685 date,
686 latitude,
687 longitude,
688 delta_t,
689 horizon.elevation_angle(),
690 )
691}
692
693fn calculate_alpha_delta(jme: f64, delta_psi: f64, epsilon_degrees: f64) -> AlphaDelta {
696 let b_terms = calculate_lbr_terms(jme, TERMS_B);
700 let b_degrees = lbr_to_normalized_degrees(jme, &b_terms, TERMS_B.len());
701
702 let r_terms = calculate_lbr_terms(jme, TERMS_R);
704 let r = calculate_lbr_polynomial(jme, &r_terms, TERMS_R.len());
705 assert!(
706 r != 0.0,
707 "Earth radius vector is zero - astronomical impossibility"
708 );
709
710 let l_terms = calculate_lbr_terms(jme, TERMS_L);
712 let l_degrees = lbr_to_normalized_degrees(jme, &l_terms, TERMS_L.len());
713
714 let theta_degrees = normalize_degrees_0_to_360(l_degrees + 180.0);
716
717 let beta_degrees = -b_degrees;
719 let beta = degrees_to_radians(beta_degrees);
720 let epsilon = degrees_to_radians(epsilon_degrees);
721
722 let delta_tau = ABERRATION_CONSTANT / (SECONDS_PER_HOUR * r);
724
725 let lambda_degrees = theta_degrees + delta_psi + delta_tau;
727 let lambda = degrees_to_radians(lambda_degrees);
728
729 let alpha_degrees = calculate_geocentric_sun_right_ascension(beta, epsilon, lambda);
731 let delta_degrees =
732 radians_to_degrees(calculate_geocentric_sun_declination(beta, epsilon, lambda));
733
734 AlphaDelta {
735 alpha: alpha_degrees,
736 delta: delta_degrees,
737 }
738}
739
740fn normalize_to_unit_range(val: f64) -> f64 {
742 let divided = val;
743 let limited = divided - divided.floor();
744 if limited < 0.0 {
745 limited + 1.0
746 } else {
747 limited
748 }
749}
750
751#[cfg(feature = "std")]
752fn truncate_to_day_start<Tz: TimeZone>(datetime: &DateTime<Tz>) -> DateTime<Tz> {
753 datetime
754 .timezone()
755 .from_local_datetime(
756 &datetime
757 .date_naive()
758 .and_hms_opt(0, 0, 0)
759 .expect("midnight is always valid"),
760 )
761 .single()
762 .expect("midnight should have single timezone representation")
763}
764
765#[allow(clippy::needless_pass_by_value)]
766#[cfg(feature = "std")]
767fn add_fraction_of_day<Tz: TimeZone>(day: DateTime<Tz>, fraction: f64) -> DateTime<Tz> {
768 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);
776
777 day_start + chrono::Duration::milliseconds(i64::from(millis_plus))
778}
779
780fn limit_if_necessary(val: f64) -> f64 {
782 if val.abs() > 2.0 {
783 normalize_to_unit_range(val)
784 } else {
785 val
786 }
787}
788
789fn limit_h_prime(h_prime: f64) -> f64 {
791 let normalized = h_prime / 360.0;
792 let limited = 360.0 * (normalized - floor(normalized));
793
794 if limited < -180.0 {
795 limited + 360.0
796 } else if limited > 180.0 {
797 limited - 360.0
798 } else {
799 limited
800 }
801}
802
803#[cfg(feature = "std")]
852pub fn sunrise_sunset_multiple<Tz, H>(
853 date: DateTime<Tz>,
854 latitude: f64,
855 longitude: f64,
856 delta_t: f64,
857 horizons: H,
858) -> impl Iterator<Item = Result<(Horizon, crate::SunriseResult<DateTime<Tz>>)>>
859where
860 Tz: TimeZone,
861 H: IntoIterator<Item = Horizon>,
862{
863 let precomputed = (|| -> Result<_> {
865 check_coordinates(latitude, longitude)?;
866
867 let day_start = truncate_to_day_start(&date);
868 let (nu_degrees, delta_psi_epsilon, epsilon_degrees) =
869 calculate_sidereal_time_and_nutation(day_start.clone());
870 let alpha_deltas =
871 calculate_alpha_deltas_for_three_days(day_start, delta_psi_epsilon, epsilon_degrees)?;
872
873 Ok((nu_degrees, alpha_deltas))
874 })();
875
876 horizons.into_iter().map(move |horizon| match &precomputed {
877 Ok((nu_degrees, alpha_deltas)) => {
878 let result = calculate_sunrise_sunset_spa_algorithm_with_precomputed(
879 date.clone(),
880 latitude,
881 longitude,
882 delta_t,
883 horizon.elevation_angle(),
884 *nu_degrees,
885 *alpha_deltas,
886 );
887 Ok((horizon, result))
888 }
889 Err(e) => Err(e.clone()),
890 })
891}
892
893#[cfg(feature = "std")]
894#[allow(clippy::needless_pass_by_value)]
895fn calculate_sunrise_sunset_spa_algorithm_with_precomputed<Tz: TimeZone>(
896 day: DateTime<Tz>,
897 latitude: f64,
898 longitude: f64,
899 delta_t: f64,
900 elevation_angle: f64,
901 nu_degrees: f64,
902 alpha_deltas: [AlphaDelta; 3],
903) -> crate::SunriseResult<DateTime<Tz>> {
904 let day_start = truncate_to_day_start(&day);
905
906 let m0 = (alpha_deltas[1].alpha - longitude - nu_degrees) / 360.0;
908 if let Some(polar_result) = check_polar_conditions(
909 day_start.clone(),
910 m0,
911 latitude,
912 elevation_angle,
913 alpha_deltas[1].delta,
914 ) {
915 return polar_result;
916 }
917
918 let (m_values, _h0_degrees) =
920 calculate_approximate_times(m0, latitude, elevation_angle, alpha_deltas[1].delta);
921
922 calculate_final_times(FinalTimeParams {
924 day: day_start,
925 m_values,
926 nu_degrees,
927 delta_t,
928 latitude,
929 longitude,
930 elevation_angle,
931 alpha_deltas,
932 })
933}
934
935#[cfg(feature = "std")]
974#[allow(clippy::needless_pass_by_value)]
975pub fn spa_time_dependent_parts<Tz: TimeZone>(
976 datetime: DateTime<Tz>,
977 delta_t: f64,
978) -> Result<SpaTimeDependent> {
979 let jd = JulianDate::from_datetime(&datetime, delta_t)?;
980 spa_time_dependent_from_julian(jd)
981}
982
983pub fn spa_time_dependent_from_julian(jd: JulianDate) -> Result<SpaTimeDependent> {
993 let jme = jd.julian_ephemeris_millennium();
994 let jce = jd.julian_ephemeris_century();
995
996 let l_terms = calculate_lbr_terms(jme, TERMS_L);
998 let l_degrees = lbr_to_normalized_degrees(jme, &l_terms, TERMS_L.len());
999
1000 let b_terms = calculate_lbr_terms(jme, TERMS_B);
1002 let b_degrees = lbr_to_normalized_degrees(jme, &b_terms, TERMS_B.len());
1003
1004 let r_terms = calculate_lbr_terms(jme, TERMS_R);
1006 let r = calculate_lbr_polynomial(jme, &r_terms, TERMS_R.len());
1007
1008 assert!(
1010 r != 0.0,
1011 "Earth radius vector is zero - astronomical impossibility"
1012 );
1013
1014 let theta_degrees = normalize_degrees_0_to_360(l_degrees + 180.0);
1016 let beta_degrees = -b_degrees;
1018
1019 let x_terms = calculate_nutation_terms(jce);
1021 let delta_psi_epsilon = calculate_delta_psi_epsilon(jce, &x_terms);
1022
1023 let epsilon_degrees =
1025 calculate_true_obliquity_of_ecliptic(&jd, delta_psi_epsilon.delta_epsilon);
1026
1027 let delta_tau = ABERRATION_CONSTANT / (SECONDS_PER_HOUR * r);
1029
1030 let lambda_degrees = theta_degrees + delta_psi_epsilon.delta_psi + delta_tau;
1032
1033 let nu_degrees = calculate_apparent_sidereal_time_at_greenwich(
1035 &jd,
1036 delta_psi_epsilon.delta_psi,
1037 epsilon_degrees,
1038 );
1039
1040 let beta = degrees_to_radians(beta_degrees);
1042 let epsilon = degrees_to_radians(epsilon_degrees);
1043 let lambda = degrees_to_radians(lambda_degrees);
1044 let alpha_degrees = calculate_geocentric_sun_right_ascension(beta, epsilon, lambda);
1045
1046 let delta_degrees =
1048 radians_to_degrees(calculate_geocentric_sun_declination(beta, epsilon, lambda));
1049
1050 Ok(SpaTimeDependent {
1051 theta_degrees,
1052 beta_degrees,
1053 r,
1054 delta_psi: delta_psi_epsilon.delta_psi,
1055 epsilon_degrees,
1056 lambda_degrees,
1057 nu_degrees,
1058 alpha_degrees,
1059 delta_degrees,
1060 })
1061}
1062
1063pub fn spa_with_time_dependent_parts(
1101 latitude: f64,
1102 longitude: f64,
1103 elevation: f64,
1104 refraction: Option<RefractionCorrection>,
1105 time_dependent: &SpaTimeDependent,
1106) -> Result<SolarPosition> {
1107 check_coordinates(latitude, longitude)?;
1108
1109 let nu_degrees = time_dependent.nu_degrees;
1112
1113 let h_degrees =
1115 normalize_degrees_0_to_360(nu_degrees + longitude - time_dependent.alpha_degrees);
1116 let h = degrees_to_radians(h_degrees);
1117
1118 let xi_degrees = 8.794 / (3600.0 * time_dependent.r);
1120 let xi = degrees_to_radians(xi_degrees);
1121 let phi = degrees_to_radians(latitude);
1122 let delta = degrees_to_radians(time_dependent.delta_degrees);
1123
1124 let u = atan(EARTH_FLATTENING_FACTOR * tan(phi));
1125 let y = EARTH_FLATTENING_FACTOR.mul_add(sin(u), (elevation / EARTH_RADIUS_METERS) * sin(phi));
1126 let x = (elevation / EARTH_RADIUS_METERS).mul_add(cos(phi), cos(u));
1127
1128 let delta_alpha_prime_degrees = radians_to_degrees(atan2(
1129 -x * sin(xi) * sin(h),
1130 (x * sin(xi)).mul_add(-cos(h), cos(delta)),
1131 ));
1132
1133 let delta_prime = radians_to_degrees(atan2(
1134 y.mul_add(-sin(xi), sin(delta)) * cos(degrees_to_radians(delta_alpha_prime_degrees)),
1135 (x * sin(xi)).mul_add(-cos(h), cos(delta)),
1136 ));
1137
1138 let h_prime_degrees = h_degrees - delta_alpha_prime_degrees;
1140
1141 let zenith_angle = radians_to_degrees(acos(sin(degrees_to_radians(latitude)).mul_add(
1143 sin(degrees_to_radians(delta_prime)),
1144 cos(degrees_to_radians(latitude))
1145 * cos(degrees_to_radians(delta_prime))
1146 * cos(degrees_to_radians(h_prime_degrees)),
1147 )));
1148
1149 let azimuth = normalize_degrees_0_to_360(
1151 180.0
1152 + radians_to_degrees(atan2(
1153 sin(degrees_to_radians(h_prime_degrees)),
1154 cos(degrees_to_radians(h_prime_degrees)) * sin(degrees_to_radians(latitude))
1155 - tan(degrees_to_radians(delta_prime)) * cos(degrees_to_radians(latitude)),
1156 )),
1157 );
1158
1159 let elevation_angle = 90.0 - zenith_angle;
1161 let final_zenith = refraction.map_or(zenith_angle, |correction| {
1162 if elevation_angle > SUNRISE_SUNSET_ANGLE {
1163 let pressure = correction.pressure();
1164 let temperature = correction.temperature();
1165 zenith_angle
1167 - (pressure / 1010.0) * (283.0 / (273.0 + temperature)) * 1.02
1168 / (60.0
1169 * tan(degrees_to_radians(
1170 elevation_angle + 10.3 / (elevation_angle + 5.11),
1171 )))
1172 } else {
1173 zenith_angle
1174 }
1175 });
1176
1177 SolarPosition::new(azimuth, final_zenith)
1178}
1179
1180#[cfg(test)]
1181mod tests {
1182 use super::*;
1183 use chrono::{DateTime, FixedOffset};
1184
1185 #[test]
1186 fn test_spa_basic_functionality() {
1187 let datetime = "2023-06-21T12:00:00Z"
1188 .parse::<DateTime<FixedOffset>>()
1189 .unwrap();
1190
1191 let result = solar_position(
1192 datetime,
1193 37.7749, -122.4194,
1195 0.0,
1196 69.0,
1197 Some(RefractionCorrection::new(1013.25, 15.0).unwrap()),
1198 );
1199
1200 assert!(result.is_ok());
1201 let position = result.unwrap();
1202 assert!(position.azimuth() >= 0.0 && position.azimuth() <= 360.0);
1203 assert!(position.zenith_angle() >= 0.0 && position.zenith_angle() <= 180.0);
1204 }
1205
1206 #[test]
1207 fn test_sunrise_sunset_multiple() {
1208 let datetime = "2023-06-21T12:00:00Z"
1209 .parse::<DateTime<FixedOffset>>()
1210 .unwrap();
1211 let horizons = [
1212 Horizon::SunriseSunset,
1213 Horizon::CivilTwilight,
1214 Horizon::NauticalTwilight,
1215 ];
1216
1217 let results: std::result::Result<Vec<_>, _> = sunrise_sunset_multiple(
1218 datetime,
1219 37.7749, -122.4194, 69.0, horizons.iter().copied(),
1223 )
1224 .collect();
1225
1226 let results = results.unwrap();
1227
1228 assert_eq!(results.len(), 3);
1230
1231 let returned_horizons: std::collections::HashSet<_> =
1233 results.iter().map(|(h, _)| *h).collect();
1234 for expected_horizon in horizons {
1235 assert!(returned_horizons.contains(&expected_horizon));
1236 }
1237
1238 for (horizon, bulk_result) in &results {
1240 let individual_result =
1241 sunrise_sunset_for_horizon(datetime, 37.7749, -122.4194, 69.0, *horizon).unwrap();
1242
1243 match (&individual_result, bulk_result) {
1245 (
1246 crate::SunriseResult::RegularDay {
1247 sunrise: s1,
1248 transit: t1,
1249 sunset: ss1,
1250 },
1251 crate::SunriseResult::RegularDay {
1252 sunrise: s2,
1253 transit: t2,
1254 sunset: ss2,
1255 },
1256 ) => {
1257 assert_eq!(s1, s2);
1258 assert_eq!(t1, t2);
1259 assert_eq!(ss1, ss2);
1260 }
1261 (
1262 crate::SunriseResult::AllDay { transit: t1 },
1263 crate::SunriseResult::AllDay { transit: t2 },
1264 )
1265 | (
1266 crate::SunriseResult::AllNight { transit: t1 },
1267 crate::SunriseResult::AllNight { transit: t2 },
1268 ) => {
1269 assert_eq!(t1, t2);
1270 }
1271 _ => panic!("Bulk and individual results differ in type for {horizon:?}"),
1272 }
1273 }
1274 }
1275
1276 #[test]
1277 fn test_spa_no_refraction() {
1278 let datetime = "2023-06-21T12:00:00Z"
1279 .parse::<DateTime<FixedOffset>>()
1280 .unwrap();
1281
1282 let result = solar_position(datetime, 37.7749, -122.4194, 0.0, 69.0, None);
1283
1284 assert!(result.is_ok());
1285 let position = result.unwrap();
1286 assert!(position.azimuth() >= 0.0 && position.azimuth() <= 360.0);
1287 assert!(position.zenith_angle() >= 0.0 && position.zenith_angle() <= 180.0);
1288 }
1289
1290 #[test]
1291 fn test_spa_coordinate_validation() {
1292 let datetime = "2023-06-21T12:00:00Z"
1293 .parse::<DateTime<FixedOffset>>()
1294 .unwrap();
1295
1296 assert!(
1298 solar_position(
1299 datetime,
1300 95.0,
1301 0.0,
1302 0.0,
1303 0.0,
1304 Some(RefractionCorrection::new(1013.25, 15.0).unwrap())
1305 )
1306 .is_err()
1307 );
1308
1309 assert!(
1311 solar_position(
1312 datetime,
1313 0.0,
1314 185.0,
1315 0.0,
1316 0.0,
1317 Some(RefractionCorrection::new(1013.25, 15.0).unwrap())
1318 )
1319 .is_err()
1320 );
1321 }
1322
1323 #[test]
1324 fn test_sunrise_sunset_basic() {
1325 let date = "2023-06-21T00:00:00Z"
1326 .parse::<DateTime<FixedOffset>>()
1327 .unwrap();
1328
1329 let result = sunrise_sunset(date, 37.7749, -122.4194, 69.0, -0.833);
1330 assert!(result.is_ok());
1331
1332 let result =
1333 sunrise_sunset_for_horizon(date, 37.7749, -122.4194, 69.0, Horizon::SunriseSunset);
1334 assert!(result.is_ok());
1335 }
1336
1337 #[test]
1338 fn test_horizon_enum() {
1339 assert_eq!(Horizon::SunriseSunset.elevation_angle(), -0.83337);
1340 assert_eq!(Horizon::CivilTwilight.elevation_angle(), -6.0);
1341 assert_eq!(Horizon::Custom(-10.5).elevation_angle(), -10.5);
1342 }
1343}