1#![allow(clippy::similar_names)]
10#![allow(clippy::many_single_char_names)]
11#![allow(clippy::unreadable_literal)]
12
13use crate::error::check_coordinates;
14#[cfg(feature = "chrono")]
15use crate::math::floor;
16use crate::math::{
17 acos, asin, atan, atan2, cos, degrees_to_radians, mul_add, normalize_degrees_0_to_360,
18 polynomial, powi, radians_to_degrees, sin, tan,
19};
20use crate::time::JulianDate;
21#[cfg(feature = "chrono")]
22use crate::Horizon;
23use crate::{RefractionCorrection, Result, SolarPosition};
24
25pub mod coefficients;
26use coefficients::{
27 NUTATION_COEFFS, OBLIQUITY_COEFFS, TERMS_B, TERMS_L, TERMS_PE, TERMS_R, TERMS_Y,
28};
29
30#[cfg(feature = "chrono")]
31use chrono::{DateTime, TimeZone};
32
33const SUNRISE_SUNSET_ANGLE: f64 = -0.83337;
35
36const ABERRATION_CONSTANT: f64 = -20.4898;
38
39const EARTH_FLATTENING_FACTOR: f64 = 0.99664719;
41
42const EARTH_RADIUS_METERS: f64 = 6378140.0;
44
45const SECONDS_PER_HOUR: f64 = 3600.0;
47
48#[cfg(feature = "chrono")]
95#[allow(clippy::needless_pass_by_value)]
96pub fn solar_position<Tz: TimeZone>(
97 datetime: DateTime<Tz>,
98 latitude: f64,
99 longitude: f64,
100 elevation: f64,
101 delta_t: f64,
102 refraction: Option<RefractionCorrection>,
103) -> Result<SolarPosition> {
104 let jd = JulianDate::from_datetime(&datetime, delta_t)?;
105 solar_position_from_julian(jd, latitude, longitude, elevation, refraction)
106}
107
108pub fn solar_position_from_julian(
144 jd: JulianDate,
145 latitude: f64,
146 longitude: f64,
147 elevation: f64,
148 refraction: Option<RefractionCorrection>,
149) -> Result<SolarPosition> {
150 let time_dependent = spa_time_dependent_from_julian(jd)?;
151 spa_with_time_dependent_parts(latitude, longitude, elevation, refraction, &time_dependent)
152}
153
154#[derive(Debug, Clone)]
159#[allow(dead_code)]
160pub struct SpaTimeDependent {
161 pub(crate) theta_degrees: f64,
163 pub(crate) beta_degrees: f64,
165 pub(crate) r: f64,
167 pub(crate) delta_psi: f64,
169 pub(crate) epsilon_degrees: f64,
171 pub(crate) lambda_degrees: f64,
173 pub(crate) nu_degrees: f64,
175 pub(crate) alpha_degrees: f64,
177 pub(crate) delta_degrees: f64,
179}
180
181#[derive(Debug, Clone, Copy)]
182struct DeltaPsiEpsilon {
183 delta_psi: f64,
184 delta_epsilon: f64,
185}
186
187fn calculate_lbr_terms(jme: f64, term_coeffs: &[&[&[f64; 3]]]) -> [f64; 6] {
189 let mut lbr_terms = [0.0; 6];
192
193 for (i, term_set) in term_coeffs.iter().enumerate().take(6) {
194 let mut lbr_sum = 0.0;
195 for term in *term_set {
196 lbr_sum += term[0] * cos(mul_add(term[2], jme, term[1]));
197 }
198 lbr_terms[i] = lbr_sum;
199 }
200
201 lbr_terms
202}
203
204fn calculate_lbr_polynomial(jme: f64, terms: &[f64], num_terms: usize) -> f64 {
206 polynomial(&terms[..num_terms], jme) / 1e8
207}
208
209fn lbr_to_normalized_degrees(jme: f64, terms: &[f64], num_terms: usize) -> f64 {
211 normalize_degrees_0_to_360(radians_to_degrees(calculate_lbr_polynomial(
212 jme, terms, num_terms,
213 )))
214}
215
216fn calculate_nutation_terms(jce: f64) -> [f64; 5] {
218 [
221 polynomial(NUTATION_COEFFS[0], jce),
222 polynomial(NUTATION_COEFFS[1], jce),
223 polynomial(NUTATION_COEFFS[2], jce),
224 polynomial(NUTATION_COEFFS[3], jce),
225 polynomial(NUTATION_COEFFS[4], jce),
226 ]
227}
228
229fn calculate_delta_psi_epsilon(jce: f64, x: &[f64]) -> DeltaPsiEpsilon {
231 let mut delta_psi = 0.0;
232 let mut delta_epsilon = 0.0;
233
234 for (i, pe_term) in TERMS_PE.iter().enumerate() {
235 let xj_yterm_sum = degrees_to_radians(calculate_xj_yterm_sum(i, x));
236
237 let delta_psi_contrib = mul_add(pe_term[1], jce, pe_term[0]) * sin(xj_yterm_sum);
239 let delta_epsilon_contrib = mul_add(pe_term[3], jce, pe_term[2]) * cos(xj_yterm_sum);
240
241 delta_psi += delta_psi_contrib;
242 delta_epsilon += delta_epsilon_contrib;
243 }
244
245 DeltaPsiEpsilon {
246 delta_psi: delta_psi / 36_000_000.0,
247 delta_epsilon: delta_epsilon / 36_000_000.0,
248 }
249}
250
251fn calculate_xj_yterm_sum(i: usize, x: &[f64]) -> f64 {
253 let mut sum = 0.0;
254 for (j, &x_val) in x.iter().enumerate() {
255 sum += x_val * f64::from(TERMS_Y[i][j]);
256 }
257 sum
258}
259
260fn calculate_true_obliquity_of_ecliptic(jd: &JulianDate, delta_epsilon: f64) -> f64 {
262 let epsilon0 = polynomial(OBLIQUITY_COEFFS, jd.julian_ephemeris_millennium() / 10.0);
263 epsilon0 / 3600.0 + delta_epsilon
264}
265
266fn calculate_apparent_sidereal_time_at_greenwich(
268 jd: &JulianDate,
269 delta_psi: f64,
270 epsilon_degrees: f64,
271) -> f64 {
272 let nu0_degrees = normalize_degrees_0_to_360(mul_add(
273 powi(jd.julian_century(), 2),
274 0.000387933 - jd.julian_century() / 38710000.0,
275 mul_add(
276 360.98564736629f64,
277 jd.julian_date() - 2451545.0,
278 280.46061837,
279 ),
280 ));
281
282 mul_add(
283 delta_psi,
284 cos(degrees_to_radians(epsilon_degrees)),
285 nu0_degrees,
286 )
287}
288
289fn calculate_geocentric_sun_right_ascension(
291 beta_rad: f64,
292 epsilon_rad: f64,
293 lambda_rad: f64,
294) -> f64 {
295 let alpha = atan2(
296 mul_add(
297 sin(lambda_rad),
298 cos(epsilon_rad),
299 -(tan(beta_rad) * sin(epsilon_rad)),
300 ),
301 cos(lambda_rad),
302 );
303 normalize_degrees_0_to_360(radians_to_degrees(alpha))
304}
305
306fn calculate_geocentric_sun_declination(beta_rad: f64, epsilon_rad: f64, lambda_rad: f64) -> f64 {
308 asin(mul_add(
309 sin(beta_rad),
310 cos(epsilon_rad),
311 cos(beta_rad) * sin(epsilon_rad) * sin(lambda_rad),
312 ))
313}
314
315#[cfg(feature = "chrono")]
352#[allow(clippy::needless_pass_by_value)]
353pub fn sunrise_sunset<Tz: TimeZone>(
354 date: DateTime<Tz>,
355 latitude: f64,
356 longitude: f64,
357 delta_t: f64,
358 elevation_angle: f64,
359) -> Result<crate::SunriseResult<DateTime<Tz>>> {
360 check_coordinates(latitude, longitude)?;
361
362 let day_start = truncate_to_day_start(&date);
363
364 calculate_sunrise_sunset_spa_algorithm(day_start, latitude, longitude, delta_t, elevation_angle)
366}
367
368#[cfg(feature = "chrono")]
370fn calculate_sunrise_sunset_spa_algorithm<Tz: TimeZone>(
371 day: DateTime<Tz>,
372 latitude: f64,
373 longitude: f64,
374 delta_t: f64,
375 elevation_angle: f64,
376) -> Result<crate::SunriseResult<DateTime<Tz>>> {
377 let day_start = truncate_to_day_start(&day);
378
379 let (nu_degrees, delta_psi_epsilon, epsilon_degrees) =
381 calculate_sidereal_time_and_nutation(day_start.clone());
382
383 let alpha_deltas =
385 calculate_alpha_deltas_for_three_days(day_start, delta_psi_epsilon, epsilon_degrees)?;
386
387 let m0 = (alpha_deltas[1].alpha - longitude - nu_degrees) / 360.0;
389
390 let polar_type = check_polar_conditions_type(latitude, elevation_angle, alpha_deltas[1].delta);
392
393 let (m_values, _h0_degrees) =
395 calculate_approximate_times(m0, latitude, elevation_angle, alpha_deltas[1].delta);
396
397 let final_result = calculate_final_times(FinalTimeParams {
399 day,
400 m_values,
401 nu_degrees,
402 delta_t,
403 latitude,
404 longitude,
405 elevation_angle,
406 alpha_deltas,
407 });
408
409 match polar_type {
411 Some(PolarType::AllDay) => {
412 if let crate::SunriseResult::RegularDay { transit, .. } = final_result {
413 Ok(crate::SunriseResult::AllDay { transit })
414 } else {
415 Ok(final_result)
416 }
417 }
418 Some(PolarType::AllNight) => {
419 if let crate::SunriseResult::RegularDay { transit, .. } = final_result {
420 Ok(crate::SunriseResult::AllNight { transit })
421 } else {
422 Ok(final_result)
423 }
424 }
425 None => Ok(final_result),
426 }
427}
428#[cfg(feature = "chrono")]
429#[allow(clippy::needless_pass_by_value)]
430fn calculate_sidereal_time_and_nutation<Tz: TimeZone>(
431 day_start: DateTime<Tz>,
432) -> (f64, DeltaPsiEpsilon, f64) {
433 let jd = JulianDate::from_datetime(&day_start, 0.0).unwrap();
434 let jce_day = jd.julian_ephemeris_century();
435 let x_terms = calculate_nutation_terms(jce_day);
436 let delta_psi_epsilon = calculate_delta_psi_epsilon(jce_day, &x_terms);
437 let epsilon_degrees =
438 calculate_true_obliquity_of_ecliptic(&jd, delta_psi_epsilon.delta_epsilon);
439 let nu_degrees = calculate_apparent_sidereal_time_at_greenwich(
440 &jd,
441 delta_psi_epsilon.delta_psi,
442 epsilon_degrees,
443 );
444 (nu_degrees, delta_psi_epsilon, epsilon_degrees)
445}
446
447#[cfg(feature = "chrono")]
448#[allow(clippy::needless_pass_by_value)]
449fn calculate_alpha_deltas_for_three_days<Tz: TimeZone>(
450 day_start: DateTime<Tz>,
451 delta_psi_epsilon: DeltaPsiEpsilon,
452 epsilon_degrees: f64,
453) -> Result<[AlphaDelta; 3]> {
454 let mut alpha_deltas = [AlphaDelta {
455 alpha: 0.0,
456 delta: 0.0,
457 }; 3];
458 for (i, alpha_delta) in alpha_deltas.iter_mut().enumerate() {
459 let current_jd = JulianDate::from_datetime(&day_start, 0.0)?.add_days((i as f64) - 1.0);
460 let current_jme = current_jd.julian_ephemeris_millennium();
461 let ad = calculate_alpha_delta(current_jme, delta_psi_epsilon.delta_psi, epsilon_degrees);
462 *alpha_delta = ad;
463 }
464 Ok(alpha_deltas)
465}
466
467#[cfg(feature = "chrono")]
474#[derive(Debug, Clone, Copy)]
475enum PolarType {
476 AllDay,
477 AllNight,
478}
479
480#[cfg(feature = "chrono")]
482fn check_polar_conditions_type(
483 latitude: f64,
484 elevation_angle: f64,
485 delta1: f64,
486) -> Option<PolarType> {
487 let phi = degrees_to_radians(latitude);
488 let elevation_rad = degrees_to_radians(elevation_angle);
489 let delta1_rad = degrees_to_radians(delta1);
490
491 let acos_arg =
492 mul_add(sin(phi), -sin(delta1_rad), sin(elevation_rad)) / (cos(phi) * cos(delta1_rad));
493
494 if acos_arg < -1.0 {
495 Some(PolarType::AllDay)
496 } else if acos_arg > 1.0 {
497 Some(PolarType::AllNight)
498 } else {
499 None
500 }
501}
502
503#[cfg(feature = "chrono")]
505fn check_polar_conditions<Tz: TimeZone>(
506 day: DateTime<Tz>,
507 m0: f64,
508 latitude: f64,
509 elevation_angle: f64,
510 delta1: f64,
511) -> Option<crate::SunriseResult<DateTime<Tz>>> {
512 check_polar_conditions_type(latitude, elevation_angle, delta1).map(|polar_type| {
513 let transit = add_fraction_of_day(day, m0);
514 match polar_type {
515 PolarType::AllDay => crate::SunriseResult::AllDay { transit },
516 PolarType::AllNight => crate::SunriseResult::AllNight { transit },
517 }
518 })
519}
520
521#[cfg(feature = "chrono")]
523fn calculate_approximate_times(
524 m0: f64,
525 latitude: f64,
526 elevation_angle: f64,
527 delta1: f64,
528) -> ([f64; 3], f64) {
529 let phi = degrees_to_radians(latitude);
530 let delta1_rad = degrees_to_radians(delta1);
531 let elevation_rad = degrees_to_radians(elevation_angle);
532
533 let acos_arg =
534 mul_add(sin(phi), -sin(delta1_rad), sin(elevation_rad)) / (cos(phi) * cos(delta1_rad));
535 let h0 = acos(acos_arg);
536 let h0_degrees = radians_to_degrees(h0).min(180.0);
537
538 let mut m = [0.0; 3];
539 m[0] = normalize_to_unit_range(m0);
540 m[1] = normalize_to_unit_range(m0 - h0_degrees / 360.0);
541 m[2] = normalize_to_unit_range(m0 + h0_degrees / 360.0);
542
543 (m, h0_degrees)
544}
545
546#[cfg(feature = "chrono")]
548fn calculate_final_times<Tz: TimeZone>(
549 params: FinalTimeParams<Tz>,
550) -> crate::SunriseResult<DateTime<Tz>> {
551 let mut nu = [0.0; 3];
553 for (i, nu_item) in nu.iter_mut().enumerate() {
554 *nu_item = mul_add(360.985647f64, params.m_values[i], params.nu_degrees);
555 }
556
557 let mut n = [0.0; 3];
559 for (i, n_item) in n.iter_mut().enumerate() {
560 *n_item = params.m_values[i] + params.delta_t / 86400.0;
561 }
562
563 let alpha_delta_primes = calculate_interpolated_alpha_deltas(¶ms.alpha_deltas, &n);
565
566 let mut h_prime = [0.0; 3];
568 for i in 0..3 {
569 let h_prime_i = nu[i] + params.longitude - alpha_delta_primes[i].alpha;
570 h_prime[i] = limit_h_prime(h_prime_i);
571 }
572
573 let phi = degrees_to_radians(params.latitude);
575 let mut h = [0.0; 3];
576 for i in 0..3 {
577 let delta_prime_rad = degrees_to_radians(alpha_delta_primes[i].delta);
578 h[i] = radians_to_degrees(asin(mul_add(
579 sin(phi),
580 sin(delta_prime_rad),
581 cos(phi) * cos(delta_prime_rad) * cos(degrees_to_radians(h_prime[i])),
582 )));
583 }
584
585 let t = params.m_values[0] - h_prime[0] / 360.0;
587 let r = params.m_values[1]
588 + (h[1] - params.elevation_angle)
589 / (360.0
590 * cos(degrees_to_radians(alpha_delta_primes[1].delta))
591 * cos(phi)
592 * sin(degrees_to_radians(h_prime[1])));
593 let s = params.m_values[2]
594 + (h[2] - params.elevation_angle)
595 / (360.0
596 * cos(degrees_to_radians(alpha_delta_primes[2].delta))
597 * cos(phi)
598 * sin(degrees_to_radians(h_prime[2])));
599
600 let sunrise = add_fraction_of_day(params.day.clone(), r);
601 let transit = add_fraction_of_day(params.day.clone(), t);
602 let sunset = add_fraction_of_day(params.day, s);
603
604 crate::SunriseResult::RegularDay {
605 sunrise,
606 transit,
607 sunset,
608 }
609}
610
611#[cfg(feature = "chrono")]
613fn calculate_interpolated_alpha_deltas(
614 alpha_deltas: &[AlphaDelta; 3],
615 n: &[f64; 3],
616) -> [AlphaDelta; 3] {
617 let a = limit_if_necessary(alpha_deltas[1].alpha - alpha_deltas[0].alpha);
618 let a_prime = limit_if_necessary(alpha_deltas[1].delta - alpha_deltas[0].delta);
619
620 let b = limit_if_necessary(alpha_deltas[2].alpha - alpha_deltas[1].alpha);
621 let b_prime = limit_if_necessary(alpha_deltas[2].delta - alpha_deltas[1].delta);
622
623 let c = b - a;
624 let c_prime = b_prime - a_prime;
625
626 let mut alpha_delta_primes = [AlphaDelta {
627 alpha: 0.0,
628 delta: 0.0,
629 }; 3];
630 for i in 0..3 {
631 alpha_delta_primes[i].alpha =
632 alpha_deltas[1].alpha + (n[i] * (mul_add(c, n[i], a + b))) / 2.0;
633 alpha_delta_primes[i].delta =
634 alpha_deltas[1].delta + (n[i] * (mul_add(c_prime, n[i], a_prime + b_prime))) / 2.0;
635 }
636 alpha_delta_primes
637}
638
639#[cfg(feature = "chrono")]
640#[derive(Debug, Clone, Copy)]
641struct AlphaDelta {
642 alpha: f64,
643 delta: f64,
644}
645
646#[derive(Debug, Clone)]
648#[cfg(feature = "chrono")]
649struct FinalTimeParams<Tz: TimeZone> {
650 day: DateTime<Tz>,
651 m_values: [f64; 3],
652 nu_degrees: f64,
653 delta_t: f64,
654 latitude: f64,
655 longitude: f64,
656 elevation_angle: f64,
657 alpha_deltas: [AlphaDelta; 3],
658}
659
660#[cfg(feature = "chrono")]
698pub fn sunrise_sunset_for_horizon<Tz: TimeZone>(
699 date: DateTime<Tz>,
700 latitude: f64,
701 longitude: f64,
702 delta_t: f64,
703 horizon: Horizon,
704) -> Result<crate::SunriseResult<DateTime<Tz>>> {
705 sunrise_sunset(
706 date,
707 latitude,
708 longitude,
709 delta_t,
710 horizon.elevation_angle(),
711 )
712}
713
714#[cfg(feature = "chrono")]
717fn calculate_alpha_delta(jme: f64, delta_psi: f64, epsilon_degrees: f64) -> AlphaDelta {
718 let b_terms = calculate_lbr_terms(jme, TERMS_B);
722 let b_degrees = lbr_to_normalized_degrees(jme, &b_terms, TERMS_B.len());
723
724 let r_terms = calculate_lbr_terms(jme, TERMS_R);
726 let r = calculate_lbr_polynomial(jme, &r_terms, TERMS_R.len());
727 assert!(
728 r != 0.0,
729 "Earth radius vector is zero - astronomical impossibility"
730 );
731
732 let l_terms = calculate_lbr_terms(jme, TERMS_L);
734 let l_degrees = lbr_to_normalized_degrees(jme, &l_terms, TERMS_L.len());
735
736 let theta_degrees = normalize_degrees_0_to_360(l_degrees + 180.0);
738
739 let beta_degrees = -b_degrees;
741 let beta = degrees_to_radians(beta_degrees);
742 let epsilon = degrees_to_radians(epsilon_degrees);
743
744 let delta_tau = ABERRATION_CONSTANT / (SECONDS_PER_HOUR * r);
746
747 let lambda_degrees = theta_degrees + delta_psi + delta_tau;
749 let lambda = degrees_to_radians(lambda_degrees);
750
751 let alpha_degrees = calculate_geocentric_sun_right_ascension(beta, epsilon, lambda);
753 let delta_degrees =
754 radians_to_degrees(calculate_geocentric_sun_declination(beta, epsilon, lambda));
755
756 AlphaDelta {
757 alpha: alpha_degrees,
758 delta: delta_degrees,
759 }
760}
761
762#[cfg(feature = "chrono")]
764fn normalize_to_unit_range(val: f64) -> f64 {
765 let divided = val;
766 let limited = divided - floor(divided);
767 if limited < 0.0 {
768 limited + 1.0
769 } else {
770 limited
771 }
772}
773
774#[cfg(feature = "chrono")]
775fn truncate_to_day_start<Tz: TimeZone>(datetime: &DateTime<Tz>) -> DateTime<Tz> {
776 datetime
777 .timezone()
778 .from_local_datetime(
779 &datetime
780 .date_naive()
781 .and_hms_opt(0, 0, 0)
782 .expect("midnight is always valid"),
783 )
784 .single()
785 .expect("midnight should have single timezone representation")
786}
787
788#[cfg(feature = "chrono")]
789#[allow(clippy::needless_pass_by_value)]
790fn add_fraction_of_day<Tz: TimeZone>(day: DateTime<Tz>, fraction: f64) -> DateTime<Tz> {
791 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);
799
800 day_start + chrono::Duration::milliseconds(i64::from(millis_plus))
801}
802
803#[cfg(feature = "chrono")]
805fn limit_if_necessary(val: f64) -> f64 {
806 if val.abs() > 2.0 {
807 normalize_to_unit_range(val)
808 } else {
809 val
810 }
811}
812
813#[cfg(feature = "chrono")]
815fn limit_h_prime(h_prime: f64) -> f64 {
816 let normalized = h_prime / 360.0;
817 let limited = 360.0 * (normalized - floor(normalized));
818
819 if limited < -180.0 {
820 limited + 360.0
821 } else if limited > 180.0 {
822 limited - 360.0
823 } else {
824 limited
825 }
826}
827
828#[cfg(feature = "chrono")]
877pub fn sunrise_sunset_multiple<Tz, H>(
878 date: DateTime<Tz>,
879 latitude: f64,
880 longitude: f64,
881 delta_t: f64,
882 horizons: H,
883) -> impl Iterator<Item = Result<(Horizon, crate::SunriseResult<DateTime<Tz>>)>>
884where
885 Tz: TimeZone,
886 H: IntoIterator<Item = Horizon>,
887{
888 let precomputed = (|| -> Result<_> {
890 check_coordinates(latitude, longitude)?;
891
892 let day_start = truncate_to_day_start(&date);
893 let (nu_degrees, delta_psi_epsilon, epsilon_degrees) =
894 calculate_sidereal_time_and_nutation(day_start.clone());
895 let alpha_deltas =
896 calculate_alpha_deltas_for_three_days(day_start, delta_psi_epsilon, epsilon_degrees)?;
897
898 Ok((nu_degrees, alpha_deltas))
899 })();
900
901 horizons.into_iter().map(move |horizon| match &precomputed {
902 Ok((nu_degrees, alpha_deltas)) => {
903 let result = calculate_sunrise_sunset_spa_algorithm_with_precomputed(
904 date.clone(),
905 latitude,
906 longitude,
907 delta_t,
908 horizon.elevation_angle(),
909 *nu_degrees,
910 *alpha_deltas,
911 );
912 Ok((horizon, result))
913 }
914 Err(e) => Err(e.clone()),
915 })
916}
917#[cfg(feature = "chrono")]
918#[allow(clippy::needless_pass_by_value)]
919fn calculate_sunrise_sunset_spa_algorithm_with_precomputed<Tz: TimeZone>(
920 day: DateTime<Tz>,
921 latitude: f64,
922 longitude: f64,
923 delta_t: f64,
924 elevation_angle: f64,
925 nu_degrees: f64,
926 alpha_deltas: [AlphaDelta; 3],
927) -> crate::SunriseResult<DateTime<Tz>> {
928 let day_start = truncate_to_day_start(&day);
929
930 let m0 = (alpha_deltas[1].alpha - longitude - nu_degrees) / 360.0;
932 if let Some(polar_result) = check_polar_conditions(
933 day_start.clone(),
934 m0,
935 latitude,
936 elevation_angle,
937 alpha_deltas[1].delta,
938 ) {
939 return polar_result;
940 }
941
942 let (m_values, _h0_degrees) =
944 calculate_approximate_times(m0, latitude, elevation_angle, alpha_deltas[1].delta);
945
946 calculate_final_times(FinalTimeParams {
948 day: day_start,
949 m_values,
950 nu_degrees,
951 delta_t,
952 latitude,
953 longitude,
954 elevation_angle,
955 alpha_deltas,
956 })
957}
958
959#[cfg(feature = "chrono")]
997#[allow(clippy::needless_pass_by_value)]
998pub fn spa_time_dependent_parts<Tz: TimeZone>(
999 datetime: DateTime<Tz>,
1000 delta_t: f64,
1001) -> Result<SpaTimeDependent> {
1002 let jd = JulianDate::from_datetime(&datetime, delta_t)?;
1003 spa_time_dependent_from_julian(jd)
1004}
1005
1006pub fn spa_time_dependent_from_julian(jd: JulianDate) -> Result<SpaTimeDependent> {
1016 let jme = jd.julian_ephemeris_millennium();
1017 let jce = jd.julian_ephemeris_century();
1018
1019 let l_terms = calculate_lbr_terms(jme, TERMS_L);
1021 let l_degrees = lbr_to_normalized_degrees(jme, &l_terms, TERMS_L.len());
1022
1023 let b_terms = calculate_lbr_terms(jme, TERMS_B);
1025 let b_degrees = lbr_to_normalized_degrees(jme, &b_terms, TERMS_B.len());
1026
1027 let r_terms = calculate_lbr_terms(jme, TERMS_R);
1029 let r = calculate_lbr_polynomial(jme, &r_terms, TERMS_R.len());
1030
1031 assert!(
1033 r != 0.0,
1034 "Earth radius vector is zero - astronomical impossibility"
1035 );
1036
1037 let theta_degrees = normalize_degrees_0_to_360(l_degrees + 180.0);
1039 let beta_degrees = -b_degrees;
1041
1042 let x_terms = calculate_nutation_terms(jce);
1044 let delta_psi_epsilon = calculate_delta_psi_epsilon(jce, &x_terms);
1045
1046 let epsilon_degrees =
1048 calculate_true_obliquity_of_ecliptic(&jd, delta_psi_epsilon.delta_epsilon);
1049
1050 let delta_tau = ABERRATION_CONSTANT / (SECONDS_PER_HOUR * r);
1052
1053 let lambda_degrees = theta_degrees + delta_psi_epsilon.delta_psi + delta_tau;
1055
1056 let nu_degrees = calculate_apparent_sidereal_time_at_greenwich(
1058 &jd,
1059 delta_psi_epsilon.delta_psi,
1060 epsilon_degrees,
1061 );
1062
1063 let beta = degrees_to_radians(beta_degrees);
1065 let epsilon = degrees_to_radians(epsilon_degrees);
1066 let lambda = degrees_to_radians(lambda_degrees);
1067 let alpha_degrees = calculate_geocentric_sun_right_ascension(beta, epsilon, lambda);
1068
1069 let delta_degrees =
1071 radians_to_degrees(calculate_geocentric_sun_declination(beta, epsilon, lambda));
1072
1073 Ok(SpaTimeDependent {
1074 theta_degrees,
1075 beta_degrees,
1076 r,
1077 delta_psi: delta_psi_epsilon.delta_psi,
1078 epsilon_degrees,
1079 lambda_degrees,
1080 nu_degrees,
1081 alpha_degrees,
1082 delta_degrees,
1083 })
1084}
1085
1086pub fn spa_with_time_dependent_parts(
1124 latitude: f64,
1125 longitude: f64,
1126 elevation: f64,
1127 refraction: Option<RefractionCorrection>,
1128 time_dependent: &SpaTimeDependent,
1129) -> Result<SolarPosition> {
1130 check_coordinates(latitude, longitude)?;
1131
1132 let nu_degrees = time_dependent.nu_degrees;
1135
1136 let h_degrees =
1138 normalize_degrees_0_to_360(nu_degrees + longitude - time_dependent.alpha_degrees);
1139 let h = degrees_to_radians(h_degrees);
1140
1141 let xi_degrees = 8.794 / (3600.0 * time_dependent.r);
1143 let xi = degrees_to_radians(xi_degrees);
1144 let phi = degrees_to_radians(latitude);
1145 let delta = degrees_to_radians(time_dependent.delta_degrees);
1146
1147 let u = atan(EARTH_FLATTENING_FACTOR * tan(phi));
1148 let y = mul_add(
1149 EARTH_FLATTENING_FACTOR,
1150 sin(u),
1151 (elevation / EARTH_RADIUS_METERS) * sin(phi),
1152 );
1153 let x = mul_add(elevation / EARTH_RADIUS_METERS, cos(phi), cos(u));
1154
1155 let delta_alpha_prime_degrees = radians_to_degrees(atan2(
1156 -x * sin(xi) * sin(h),
1157 mul_add(x * sin(xi), -cos(h), cos(delta)),
1158 ));
1159
1160 let delta_prime = radians_to_degrees(atan2(
1161 mul_add(y, -sin(xi), sin(delta)) * cos(degrees_to_radians(delta_alpha_prime_degrees)),
1162 mul_add(x * sin(xi), -cos(h), cos(delta)),
1163 ));
1164
1165 let h_prime_degrees = h_degrees - delta_alpha_prime_degrees;
1167
1168 let zenith_angle = radians_to_degrees(acos(mul_add(
1170 sin(degrees_to_radians(latitude)),
1171 sin(degrees_to_radians(delta_prime)),
1172 cos(degrees_to_radians(latitude))
1173 * cos(degrees_to_radians(delta_prime))
1174 * cos(degrees_to_radians(h_prime_degrees)),
1175 )));
1176
1177 let azimuth = normalize_degrees_0_to_360(
1179 180.0
1180 + radians_to_degrees(atan2(
1181 sin(degrees_to_radians(h_prime_degrees)),
1182 cos(degrees_to_radians(h_prime_degrees)) * sin(degrees_to_radians(latitude))
1183 - tan(degrees_to_radians(delta_prime)) * cos(degrees_to_radians(latitude)),
1184 )),
1185 );
1186
1187 let elevation_angle = 90.0 - zenith_angle;
1189 let final_zenith = refraction.map_or(zenith_angle, |correction| {
1190 if elevation_angle > SUNRISE_SUNSET_ANGLE {
1191 let pressure = correction.pressure();
1192 let temperature = correction.temperature();
1193 zenith_angle
1195 - (pressure / 1010.0) * (283.0 / (273.0 + temperature)) * 1.02
1196 / (60.0
1197 * tan(degrees_to_radians(
1198 elevation_angle + 10.3 / (elevation_angle + 5.11),
1199 )))
1200 } else {
1201 zenith_angle
1202 }
1203 });
1204
1205 SolarPosition::new(azimuth, final_zenith)
1206}
1207
1208#[cfg(all(test, feature = "chrono", feature = "std"))]
1209mod tests {
1210 use super::*;
1211 use chrono::{DateTime, FixedOffset};
1212 use std::collections::HashSet;
1213
1214 #[test]
1215 fn test_spa_basic_functionality() {
1216 let datetime = "2023-06-21T12:00:00Z"
1217 .parse::<DateTime<FixedOffset>>()
1218 .unwrap();
1219
1220 let result = solar_position(
1221 datetime,
1222 37.7749, -122.4194,
1224 0.0,
1225 69.0,
1226 Some(RefractionCorrection::new(1013.25, 15.0).unwrap()),
1227 );
1228
1229 assert!(result.is_ok());
1230 let position = result.unwrap();
1231 assert!(position.azimuth() >= 0.0 && position.azimuth() <= 360.0);
1232 assert!(position.zenith_angle() >= 0.0 && position.zenith_angle() <= 180.0);
1233 }
1234
1235 #[test]
1236 fn test_sunrise_sunset_multiple() {
1237 let datetime = "2023-06-21T12:00:00Z"
1238 .parse::<DateTime<FixedOffset>>()
1239 .unwrap();
1240 let horizons = [
1241 Horizon::SunriseSunset,
1242 Horizon::CivilTwilight,
1243 Horizon::NauticalTwilight,
1244 ];
1245
1246 let results: Result<Vec<_>> = sunrise_sunset_multiple(
1247 datetime,
1248 37.7749, -122.4194, 69.0, horizons.iter().copied(),
1252 )
1253 .collect();
1254
1255 let results = results.unwrap();
1256
1257 assert_eq!(results.len(), 3);
1259
1260 let returned_horizons: HashSet<_> = results.iter().map(|(h, _)| *h).collect();
1262 for expected_horizon in horizons {
1263 assert!(returned_horizons.contains(&expected_horizon));
1264 }
1265
1266 for (horizon, bulk_result) in &results {
1268 let individual_result =
1269 sunrise_sunset_for_horizon(datetime, 37.7749, -122.4194, 69.0, *horizon).unwrap();
1270
1271 match (&individual_result, bulk_result) {
1273 (
1274 crate::SunriseResult::RegularDay {
1275 sunrise: s1,
1276 transit: t1,
1277 sunset: ss1,
1278 },
1279 crate::SunriseResult::RegularDay {
1280 sunrise: s2,
1281 transit: t2,
1282 sunset: ss2,
1283 },
1284 ) => {
1285 assert_eq!(s1, s2);
1286 assert_eq!(t1, t2);
1287 assert_eq!(ss1, ss2);
1288 }
1289 (
1290 crate::SunriseResult::AllDay { transit: t1 },
1291 crate::SunriseResult::AllDay { transit: t2 },
1292 )
1293 | (
1294 crate::SunriseResult::AllNight { transit: t1 },
1295 crate::SunriseResult::AllNight { transit: t2 },
1296 ) => {
1297 assert_eq!(t1, t2);
1298 }
1299 _ => panic!("Bulk and individual results differ in type for {horizon:?}"),
1300 }
1301 }
1302 }
1303
1304 #[test]
1305 fn test_spa_no_refraction() {
1306 let datetime = "2023-06-21T12:00:00Z"
1307 .parse::<DateTime<FixedOffset>>()
1308 .unwrap();
1309
1310 let result = solar_position(datetime, 37.7749, -122.4194, 0.0, 69.0, None);
1311
1312 assert!(result.is_ok());
1313 let position = result.unwrap();
1314 assert!(position.azimuth() >= 0.0 && position.azimuth() <= 360.0);
1315 assert!(position.zenith_angle() >= 0.0 && position.zenith_angle() <= 180.0);
1316 }
1317
1318 #[test]
1319 fn test_spa_coordinate_validation() {
1320 let datetime = "2023-06-21T12:00:00Z"
1321 .parse::<DateTime<FixedOffset>>()
1322 .unwrap();
1323
1324 assert!(solar_position(
1326 datetime,
1327 95.0,
1328 0.0,
1329 0.0,
1330 0.0,
1331 Some(RefractionCorrection::new(1013.25, 15.0).unwrap())
1332 )
1333 .is_err());
1334
1335 assert!(solar_position(
1337 datetime,
1338 0.0,
1339 185.0,
1340 0.0,
1341 0.0,
1342 Some(RefractionCorrection::new(1013.25, 15.0).unwrap())
1343 )
1344 .is_err());
1345 }
1346
1347 #[test]
1348 fn test_sunrise_sunset_basic() {
1349 let date = "2023-06-21T00:00:00Z"
1350 .parse::<DateTime<FixedOffset>>()
1351 .unwrap();
1352
1353 let result = sunrise_sunset(date, 37.7749, -122.4194, 69.0, -0.833);
1354 assert!(result.is_ok());
1355
1356 let result =
1357 sunrise_sunset_for_horizon(date, 37.7749, -122.4194, 69.0, Horizon::SunriseSunset);
1358 assert!(result.is_ok());
1359 }
1360
1361 #[test]
1362 fn test_horizon_enum() {
1363 assert_eq!(Horizon::SunriseSunset.elevation_angle(), -0.83337);
1364 assert_eq!(Horizon::CivilTwilight.elevation_angle(), -6.0);
1365 assert_eq!(Horizon::Custom(-10.5).elevation_angle(), -10.5);
1366 }
1367}