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, normalize_degrees_0_to_360,
16 polynomial, radians_to_degrees, sin, tan,
17};
18use crate::time::JulianDate;
19use crate::{Horizon, RefractionCorrection, Result, SolarPosition};
20use chrono::{DateTime, TimeZone};
21
22pub mod coefficients;
23use coefficients::{
24 NUTATION_COEFFS, OBLIQUITY_COEFFS, TERMS_B, TERMS_L, TERMS_PE, TERMS_R, TERMS_Y,
25};
26
27const SUNRISE_SUNSET_ANGLE: f64 = -0.83337;
29
30const ABERRATION_CONSTANT: f64 = -20.4898;
32
33const EARTH_FLATTENING_FACTOR: f64 = 0.99664719;
35
36const EARTH_RADIUS_METERS: f64 = 6378140.0;
38
39const SECONDS_PER_HOUR: f64 = 3600.0;
41
42#[allow(clippy::needless_pass_by_value)]
89pub fn solar_position<Tz: TimeZone>(
90 datetime: DateTime<Tz>,
91 latitude: f64,
92 longitude: f64,
93 elevation: f64,
94 delta_t: f64,
95 refraction: Option<RefractionCorrection>,
96) -> Result<SolarPosition> {
97 let time_dependent = spa_time_dependent_parts(datetime.clone(), delta_t)?;
99 spa_with_time_dependent_parts(
100 datetime,
101 latitude,
102 longitude,
103 elevation,
104 delta_t,
105 refraction,
106 &time_dependent,
107 )
108}
109
110#[derive(Debug, Clone)]
113pub struct SpaTimeDependent {
114 pub theta_degrees: f64,
116 pub beta_degrees: f64,
118 pub r: f64,
120 pub delta_psi: f64,
122 pub epsilon_degrees: f64,
124 pub lambda_degrees: f64,
126 pub nu_degrees: f64,
128 pub alpha_degrees: f64,
130 pub delta_degrees: f64,
132}
133
134#[derive(Debug, Clone, Copy)]
135struct DeltaPsiEpsilon {
136 delta_psi: f64,
137 delta_epsilon: f64,
138}
139
140fn calculate_lbr_terms(jme: f64, term_coeffs: &[&[&[f64; 3]]]) -> Vec<f64> {
142 let mut lbr_terms = vec![0.0; term_coeffs.len()];
143
144 for (i, term_set) in term_coeffs.iter().enumerate() {
145 let mut lbr_sum = 0.0;
146 for term in *term_set {
147 lbr_sum += term[0] * cos(term[2].mul_add(jme, term[1]));
148 }
149 lbr_terms[i] = lbr_sum;
150 }
151
152 lbr_terms
153}
154
155fn calculate_lbr_polynomial(jme: f64, terms: &[f64]) -> f64 {
157 polynomial(terms, jme) / 1e8
158}
159
160fn calculate_nutation_terms(jce: f64) -> [f64; 5] {
162 [
165 polynomial(NUTATION_COEFFS[0], jce),
166 polynomial(NUTATION_COEFFS[1], jce),
167 polynomial(NUTATION_COEFFS[2], jce),
168 polynomial(NUTATION_COEFFS[3], jce),
169 polynomial(NUTATION_COEFFS[4], jce),
170 ]
171}
172
173fn calculate_delta_psi_epsilon(jce: f64, x: &[f64]) -> DeltaPsiEpsilon {
175 let mut delta_psi = 0.0;
176 let mut delta_epsilon = 0.0;
177
178 for (i, pe_term) in TERMS_PE.iter().enumerate() {
179 let xj_yterm_sum = degrees_to_radians(calculate_xj_yterm_sum(i, x));
180
181 let delta_psi_contrib = pe_term[1].mul_add(jce, pe_term[0]) * sin(xj_yterm_sum);
183 let delta_epsilon_contrib = pe_term[3].mul_add(jce, pe_term[2]) * cos(xj_yterm_sum);
184
185 delta_psi += delta_psi_contrib;
186 delta_epsilon += delta_epsilon_contrib;
187 }
188
189 DeltaPsiEpsilon {
190 delta_psi: delta_psi / 36_000_000.0,
191 delta_epsilon: delta_epsilon / 36_000_000.0,
192 }
193}
194
195fn calculate_xj_yterm_sum(i: usize, x: &[f64]) -> f64 {
197 let mut sum = 0.0;
198 for (j, &x_val) in x.iter().enumerate() {
199 sum += x_val * f64::from(TERMS_Y[i][j]);
200 }
201 sum
202}
203
204fn calculate_true_obliquity_of_ecliptic(jd: &JulianDate, delta_epsilon: f64) -> f64 {
206 let epsilon0 = polynomial(OBLIQUITY_COEFFS, jd.julian_ephemeris_millennium() / 10.0);
207 epsilon0 / 3600.0 + delta_epsilon
208}
209
210fn calculate_apparent_sidereal_time_at_greenwich(
212 jd: &JulianDate,
213 delta_psi: f64,
214 epsilon_degrees: f64,
215) -> f64 {
216 let nu0_degrees = normalize_degrees_0_to_360(jd.julian_century().powi(2).mul_add(
217 0.000387933 - jd.julian_century() / 38710000.0,
218 360.98564736629f64.mul_add(jd.julian_date() - 2451545.0, 280.46061837),
219 ));
220
221 delta_psi.mul_add(cos(degrees_to_radians(epsilon_degrees)), nu0_degrees)
222}
223
224fn calculate_geocentric_sun_right_ascension(
226 beta_rad: f64,
227 epsilon_rad: f64,
228 lambda_rad: f64,
229) -> f64 {
230 let alpha = atan2(
231 sin(lambda_rad).mul_add(cos(epsilon_rad), -(tan(beta_rad) * sin(epsilon_rad))),
232 cos(lambda_rad),
233 );
234 normalize_degrees_0_to_360(radians_to_degrees(alpha))
235}
236
237fn calculate_geocentric_sun_declination(beta_rad: f64, epsilon_rad: f64, lambda_rad: f64) -> f64 {
239 asin(sin(beta_rad).mul_add(
240 cos(epsilon_rad),
241 cos(beta_rad) * sin(epsilon_rad) * sin(lambda_rad),
242 ))
243}
244
245#[allow(clippy::needless_pass_by_value)]
283pub fn sunrise_sunset<Tz: TimeZone>(
284 date: DateTime<Tz>,
285 latitude: f64,
286 longitude: f64,
287 delta_t: f64,
288 elevation_angle: f64,
289) -> Result<crate::SunriseResult<DateTime<Tz>>> {
290 check_coordinates(latitude, longitude)?;
291
292 let day_start = date
294 .timezone()
295 .from_local_datetime(&date.date_naive().and_hms_opt(0, 0, 0).unwrap())
296 .unwrap();
297
298 calculate_sunrise_sunset_spa_algorithm(day_start, latitude, longitude, delta_t, elevation_angle)
300}
301
302fn calculate_sunrise_sunset_spa_algorithm<Tz: TimeZone>(
304 day: DateTime<Tz>,
305 latitude: f64,
306 longitude: f64,
307 delta_t: f64,
308 elevation_angle: f64,
309) -> Result<crate::SunriseResult<DateTime<Tz>>> {
310 let day_start = day
311 .timezone()
312 .from_local_datetime(&day.date_naive().and_hms_opt(0, 0, 0).unwrap())
313 .unwrap();
314
315 let (nu_degrees, delta_psi_epsilon, epsilon_degrees) =
317 calculate_sidereal_time_and_nutation(day_start.clone());
318
319 let alpha_deltas =
321 calculate_alpha_deltas_for_three_days(day_start, delta_psi_epsilon, epsilon_degrees)?;
322
323 let m0 = (alpha_deltas[1].alpha - longitude - nu_degrees) / 360.0;
325
326 let polar_type = check_polar_conditions_type(latitude, elevation_angle, alpha_deltas[1].delta);
328
329 let (m_values, _h0_degrees) =
331 calculate_approximate_times(m0, latitude, elevation_angle, alpha_deltas[1].delta);
332
333 let final_result = calculate_final_times(FinalTimeParams {
335 day,
336 m_values,
337 nu_degrees,
338 delta_t,
339 latitude,
340 longitude,
341 elevation_angle,
342 alpha_deltas,
343 });
344
345 match polar_type {
347 Some(PolarType::AllDay) => {
348 if let crate::SunriseResult::RegularDay { transit, .. } = final_result {
349 Ok(crate::SunriseResult::AllDay { transit })
350 } else {
351 Ok(final_result)
352 }
353 }
354 Some(PolarType::AllNight) => {
355 if let crate::SunriseResult::RegularDay { transit, .. } = final_result {
356 Ok(crate::SunriseResult::AllNight { transit })
357 } else {
358 Ok(final_result)
359 }
360 }
361 None => Ok(final_result),
362 }
363}
364
365#[allow(clippy::needless_pass_by_value)]
367fn calculate_sidereal_time_and_nutation<Tz: TimeZone>(
368 day_start: DateTime<Tz>,
369) -> (f64, DeltaPsiEpsilon, f64) {
370 let jd = JulianDate::from_datetime(&day_start, 0.0).unwrap();
371 let jce_day = jd.julian_ephemeris_century();
372 let x_terms = calculate_nutation_terms(jce_day);
373 let delta_psi_epsilon = calculate_delta_psi_epsilon(jce_day, &x_terms);
374 let epsilon_degrees =
375 calculate_true_obliquity_of_ecliptic(&jd, delta_psi_epsilon.delta_epsilon);
376 let nu_degrees = calculate_apparent_sidereal_time_at_greenwich(
377 &jd,
378 delta_psi_epsilon.delta_psi,
379 epsilon_degrees,
380 );
381 (nu_degrees, delta_psi_epsilon, epsilon_degrees)
382}
383
384#[allow(clippy::needless_pass_by_value)]
386fn calculate_alpha_deltas_for_three_days<Tz: TimeZone>(
387 day_start: DateTime<Tz>,
388 delta_psi_epsilon: DeltaPsiEpsilon,
389 epsilon_degrees: f64,
390) -> Result<[AlphaDelta; 3]> {
391 let mut alpha_deltas = [AlphaDelta {
392 alpha: 0.0,
393 delta: 0.0,
394 }; 3];
395 for (i, alpha_delta) in alpha_deltas.iter_mut().enumerate() {
396 let current_jd = JulianDate::from_datetime(&day_start, 0.0)?.add_days((i as f64) - 1.0);
397 let current_jme = current_jd.julian_ephemeris_millennium();
398 let ad = calculate_alpha_delta(current_jme, delta_psi_epsilon.delta_psi, epsilon_degrees);
399 *alpha_delta = ad;
400 }
401 Ok(alpha_deltas)
402}
403
404fn check_polar_conditions<Tz: TimeZone>(
406 day: DateTime<Tz>,
407 m0: f64,
408 latitude: f64,
409 elevation_angle: f64,
410 delta1: f64,
411) -> Option<crate::SunriseResult<DateTime<Tz>>> {
412 let phi = degrees_to_radians(latitude);
413 let delta1_rad = degrees_to_radians(delta1);
414 let elevation_rad = degrees_to_radians(elevation_angle);
415
416 let acos_arg =
417 sin(phi).mul_add(-sin(delta1_rad), sin(elevation_rad)) / (cos(phi) * cos(delta1_rad));
418
419 if acos_arg < -1.0 {
420 let transit = add_fraction_of_day(day, m0);
421 Some(crate::SunriseResult::AllDay { transit })
422 } else if acos_arg > 1.0 {
423 let transit = add_fraction_of_day(day, m0);
424 Some(crate::SunriseResult::AllNight { transit })
425 } else {
426 None
427 }
428}
429
430#[derive(Debug, Clone, Copy)]
432enum PolarType {
433 AllDay,
434 AllNight,
435}
436
437fn check_polar_conditions_type(
439 latitude: f64,
440 elevation_angle: f64,
441 delta1: f64,
442) -> Option<PolarType> {
443 let phi = degrees_to_radians(latitude);
444 let elevation_rad = degrees_to_radians(elevation_angle);
445 let delta1_rad = degrees_to_radians(delta1);
446
447 let acos_arg =
448 sin(phi).mul_add(-sin(delta1_rad), sin(elevation_rad)) / (cos(phi) * cos(delta1_rad));
449
450 if acos_arg < -1.0 {
451 Some(PolarType::AllDay)
452 } else if acos_arg > 1.0 {
453 Some(PolarType::AllNight)
454 } else {
455 None
456 }
457}
458
459fn calculate_approximate_times(
461 m0: f64,
462 latitude: f64,
463 elevation_angle: f64,
464 delta1: f64,
465) -> ([f64; 3], f64) {
466 let phi = degrees_to_radians(latitude);
467 let delta1_rad = degrees_to_radians(delta1);
468 let elevation_rad = degrees_to_radians(elevation_angle);
469
470 let acos_arg =
471 sin(phi).mul_add(-sin(delta1_rad), sin(elevation_rad)) / (cos(phi) * cos(delta1_rad));
472 let h0 = acos(acos_arg);
473 let h0_degrees = radians_to_degrees(h0).min(180.0);
474
475 let mut m = [0.0; 3];
476 m[0] = normalize_to_unit_range(m0);
477 m[1] = normalize_to_unit_range(m0 - h0_degrees / 360.0);
478 m[2] = normalize_to_unit_range(m0 + h0_degrees / 360.0);
479
480 (m, h0_degrees)
481}
482
483fn calculate_final_times<Tz: TimeZone>(
485 params: FinalTimeParams<Tz>,
486) -> crate::SunriseResult<DateTime<Tz>> {
487 let mut nu = [0.0; 3];
489 for (i, nu_item) in nu.iter_mut().enumerate() {
490 *nu_item = 360.985647f64.mul_add(params.m_values[i], params.nu_degrees);
491 }
492
493 let mut n = [0.0; 3];
495 for (i, n_item) in n.iter_mut().enumerate() {
496 *n_item = params.m_values[i] + params.delta_t / 86400.0;
497 }
498
499 let alpha_delta_primes = calculate_interpolated_alpha_deltas(¶ms.alpha_deltas, &n);
501
502 let mut h_prime = [0.0; 3];
504 for i in 0..3 {
505 let h_prime_i = nu[i] + params.longitude - alpha_delta_primes[i].alpha;
506 h_prime[i] = limit_h_prime(h_prime_i);
507 }
508
509 let phi = degrees_to_radians(params.latitude);
511 let mut h = [0.0; 3];
512 for i in 0..3 {
513 let delta_prime_rad = degrees_to_radians(alpha_delta_primes[i].delta);
514 h[i] = radians_to_degrees(asin(sin(phi).mul_add(
515 sin(delta_prime_rad),
516 cos(phi) * cos(delta_prime_rad) * cos(degrees_to_radians(h_prime[i])),
517 )));
518 }
519
520 let t = params.m_values[0] - h_prime[0] / 360.0;
522 let r = params.m_values[1]
523 + (h[1] - params.elevation_angle)
524 / (360.0
525 * cos(degrees_to_radians(alpha_delta_primes[1].delta))
526 * cos(phi)
527 * sin(degrees_to_radians(h_prime[1])));
528 let s = params.m_values[2]
529 + (h[2] - params.elevation_angle)
530 / (360.0
531 * cos(degrees_to_radians(alpha_delta_primes[2].delta))
532 * cos(phi)
533 * sin(degrees_to_radians(h_prime[2])));
534
535 let sunrise = add_fraction_of_day(params.day.clone(), r);
536 let transit = add_fraction_of_day(params.day.clone(), t);
537 let sunset = add_fraction_of_day(params.day, s);
538
539 crate::SunriseResult::RegularDay {
540 sunrise,
541 transit,
542 sunset,
543 }
544}
545
546fn calculate_interpolated_alpha_deltas(
548 alpha_deltas: &[AlphaDelta; 3],
549 n: &[f64; 3],
550) -> [AlphaDelta; 3] {
551 let a = limit_if_necessary(alpha_deltas[1].alpha - alpha_deltas[0].alpha);
552 let a_prime = limit_if_necessary(alpha_deltas[1].delta - alpha_deltas[0].delta);
553
554 let b = limit_if_necessary(alpha_deltas[2].alpha - alpha_deltas[1].alpha);
555 let b_prime = limit_if_necessary(alpha_deltas[2].delta - alpha_deltas[1].delta);
556
557 let c = b - a;
558 let c_prime = b_prime - a_prime;
559
560 let mut alpha_delta_primes = [AlphaDelta {
561 alpha: 0.0,
562 delta: 0.0,
563 }; 3];
564 for i in 0..3 {
565 alpha_delta_primes[i].alpha =
566 alpha_deltas[1].alpha + (n[i] * (c.mul_add(n[i], a + b))) / 2.0;
567 alpha_delta_primes[i].delta =
568 alpha_deltas[1].delta + (n[i] * (c_prime.mul_add(n[i], a_prime + b_prime))) / 2.0;
569 }
570 alpha_delta_primes
571}
572
573#[derive(Debug, Clone, Copy)]
574struct AlphaDelta {
575 alpha: f64,
576 delta: f64,
577}
578
579#[derive(Debug, Clone)]
581struct FinalTimeParams<Tz: TimeZone> {
582 day: DateTime<Tz>,
583 m_values: [f64; 3],
584 nu_degrees: f64,
585 delta_t: f64,
586 latitude: f64,
587 longitude: f64,
588 elevation_angle: f64,
589 alpha_deltas: [AlphaDelta; 3],
590}
591
592pub fn sunrise_sunset_for_horizon<Tz: TimeZone>(
627 date: DateTime<Tz>,
628 latitude: f64,
629 longitude: f64,
630 delta_t: f64,
631 horizon: Horizon,
632) -> Result<crate::SunriseResult<DateTime<Tz>>> {
633 sunrise_sunset(
634 date,
635 latitude,
636 longitude,
637 delta_t,
638 horizon.elevation_angle(),
639 )
640}
641
642fn calculate_alpha_delta(jme: f64, delta_psi: f64, epsilon_degrees: f64) -> AlphaDelta {
645 let b_terms = calculate_lbr_terms(jme, TERMS_B);
649 let b_degrees =
650 normalize_degrees_0_to_360(radians_to_degrees(calculate_lbr_polynomial(jme, &b_terms)));
651
652 let r_terms = calculate_lbr_terms(jme, TERMS_R);
654 let r = calculate_lbr_polynomial(jme, &r_terms);
655 assert!(
656 r != 0.0,
657 "Earth radius vector is zero - astronomical impossibility"
658 );
659
660 let l_terms = calculate_lbr_terms(jme, TERMS_L);
662 let l_degrees =
663 normalize_degrees_0_to_360(radians_to_degrees(calculate_lbr_polynomial(jme, &l_terms)));
664
665 let theta_degrees = normalize_degrees_0_to_360(l_degrees + 180.0);
667
668 let beta_degrees = -b_degrees;
670 let beta = degrees_to_radians(beta_degrees);
671 let epsilon = degrees_to_radians(epsilon_degrees);
672
673 let delta_tau = ABERRATION_CONSTANT / (SECONDS_PER_HOUR * r);
675
676 let lambda_degrees = theta_degrees + delta_psi + delta_tau;
678 let lambda = degrees_to_radians(lambda_degrees);
679
680 let alpha_degrees = calculate_geocentric_sun_right_ascension(beta, epsilon, lambda);
682 let delta_degrees =
683 radians_to_degrees(calculate_geocentric_sun_declination(beta, epsilon, lambda));
684
685 AlphaDelta {
686 alpha: alpha_degrees,
687 delta: delta_degrees,
688 }
689}
690
691fn normalize_to_unit_range(val: f64) -> f64 {
693 let divided = val;
694 let limited = divided - divided.floor();
695 if limited < 0.0 {
696 limited + 1.0
697 } else {
698 limited
699 }
700}
701
702#[allow(clippy::needless_pass_by_value)]
704fn add_fraction_of_day<Tz: TimeZone>(day: DateTime<Tz>, fraction: f64) -> DateTime<Tz> {
705 const MS_PER_DAY: i32 = 24 * 60 * 60 * 1000; let millis_plus = (f64::from(MS_PER_DAY) * fraction) as i32; let day_start = day
713 .timezone()
714 .from_local_datetime(&day.date_naive().and_hms_opt(0, 0, 0).unwrap())
715 .unwrap();
716
717 day_start + chrono::Duration::milliseconds(i64::from(millis_plus))
718}
719
720fn limit_if_necessary(val: f64) -> f64 {
722 if val.abs() > 2.0 {
723 normalize_to_unit_range(val)
724 } else {
725 val
726 }
727}
728
729fn limit_h_prime(h_prime: f64) -> f64 {
731 let normalized = h_prime / 360.0;
732 let limited = 360.0 * (normalized - floor(normalized));
733
734 if limited < -180.0 {
735 limited + 360.0
736 } else if limited > 180.0 {
737 limited - 360.0
738 } else {
739 limited
740 }
741}
742
743pub fn sunrise_sunset_multiple<Tz, H>(
789 date: DateTime<Tz>,
790 latitude: f64,
791 longitude: f64,
792 delta_t: f64,
793 horizons: H,
794) -> impl Iterator<Item = Result<(Horizon, crate::SunriseResult<DateTime<Tz>>)>>
795where
796 Tz: TimeZone,
797 H: IntoIterator<Item = Horizon>,
798{
799 let precomputed = (|| -> Result<_> {
801 check_coordinates(latitude, longitude)?;
802
803 let day_start = date
804 .timezone()
805 .from_local_datetime(&date.date_naive().and_hms_opt(0, 0, 0).unwrap())
806 .unwrap();
807 let (nu_degrees, delta_psi_epsilon, epsilon_degrees) =
808 calculate_sidereal_time_and_nutation(day_start.clone());
809 let alpha_deltas =
810 calculate_alpha_deltas_for_three_days(day_start, delta_psi_epsilon, epsilon_degrees)?;
811
812 Ok((nu_degrees, alpha_deltas))
813 })();
814
815 horizons.into_iter().map(move |horizon| match &precomputed {
816 Ok((nu_degrees, alpha_deltas)) => {
817 let result = calculate_sunrise_sunset_spa_algorithm_with_precomputed(
818 date.clone(),
819 latitude,
820 longitude,
821 delta_t,
822 horizon.elevation_angle(),
823 *nu_degrees,
824 *alpha_deltas,
825 );
826 Ok((horizon, result))
827 }
828 Err(e) => Err(e.clone()),
829 })
830}
831
832#[allow(clippy::needless_pass_by_value)]
833fn calculate_sunrise_sunset_spa_algorithm_with_precomputed<Tz: TimeZone>(
834 day: DateTime<Tz>,
835 latitude: f64,
836 longitude: f64,
837 delta_t: f64,
838 elevation_angle: f64,
839 nu_degrees: f64,
840 alpha_deltas: [AlphaDelta; 3],
841) -> crate::SunriseResult<DateTime<Tz>> {
842 let day_start = day
844 .timezone()
845 .from_local_datetime(&day.date_naive().and_hms_opt(0, 0, 0).unwrap())
846 .unwrap();
847
848 let m0 = (alpha_deltas[1].alpha - longitude - nu_degrees) / 360.0;
850 if let Some(polar_result) = check_polar_conditions(
851 day_start.clone(),
852 m0,
853 latitude,
854 elevation_angle,
855 alpha_deltas[1].delta,
856 ) {
857 return polar_result;
858 }
859
860 let (m_values, _h0_degrees) =
862 calculate_approximate_times(m0, latitude, elevation_angle, alpha_deltas[1].delta);
863
864 calculate_final_times(FinalTimeParams {
866 day: day_start,
867 m_values,
868 nu_degrees,
869 delta_t,
870 latitude,
871 longitude,
872 elevation_angle,
873 alpha_deltas,
874 })
875}
876
877#[allow(clippy::needless_pass_by_value)]
910pub fn spa_time_dependent_parts<Tz: TimeZone>(
911 datetime: DateTime<Tz>,
912 delta_t: f64,
913) -> Result<SpaTimeDependent> {
914 use crate::time::JulianDate;
915
916 let utc_datetime = datetime.with_timezone(&chrono::Utc);
918 let jd = JulianDate::from_datetime(&utc_datetime, delta_t)?;
919 let jme = jd.julian_ephemeris_millennium();
920 let jce = jd.julian_ephemeris_century();
921
922 let l_terms = calculate_lbr_terms(jme, TERMS_L);
924 let l_degrees =
925 normalize_degrees_0_to_360(radians_to_degrees(calculate_lbr_polynomial(jme, &l_terms)));
926
927 let b_terms = calculate_lbr_terms(jme, TERMS_B);
929 let b_degrees =
930 normalize_degrees_0_to_360(radians_to_degrees(calculate_lbr_polynomial(jme, &b_terms)));
931
932 let r_terms = calculate_lbr_terms(jme, TERMS_R);
934 let r = calculate_lbr_polynomial(jme, &r_terms);
935
936 assert!(
938 r != 0.0,
939 "Earth radius vector is zero - astronomical impossibility"
940 );
941
942 let theta_degrees = normalize_degrees_0_to_360(l_degrees + 180.0);
944 let beta_degrees = -b_degrees;
946
947 let x_terms = calculate_nutation_terms(jce);
949 let delta_psi_epsilon = calculate_delta_psi_epsilon(jce, &x_terms);
950
951 let epsilon_degrees =
953 calculate_true_obliquity_of_ecliptic(&jd, delta_psi_epsilon.delta_epsilon);
954
955 let delta_tau = ABERRATION_CONSTANT / (SECONDS_PER_HOUR * r);
957
958 let lambda_degrees = theta_degrees + delta_psi_epsilon.delta_psi + delta_tau;
960
961 let nu_degrees = calculate_apparent_sidereal_time_at_greenwich(
963 &jd,
964 delta_psi_epsilon.delta_psi,
965 epsilon_degrees,
966 );
967
968 let beta = degrees_to_radians(beta_degrees);
970 let epsilon = degrees_to_radians(epsilon_degrees);
971 let lambda = degrees_to_radians(lambda_degrees);
972 let alpha_degrees = calculate_geocentric_sun_right_ascension(beta, epsilon, lambda);
973
974 let delta_degrees =
976 radians_to_degrees(calculate_geocentric_sun_declination(beta, epsilon, lambda));
977
978 Ok(SpaTimeDependent {
979 theta_degrees,
980 beta_degrees,
981 r,
982 delta_psi: delta_psi_epsilon.delta_psi,
983 epsilon_degrees,
984 lambda_degrees,
985 nu_degrees,
986 alpha_degrees,
987 delta_degrees,
988 })
989}
990
991#[allow(clippy::needless_pass_by_value)]
1011pub fn spa_with_time_dependent_parts<Tz: TimeZone>(
1012 datetime: DateTime<Tz>,
1013 latitude: f64,
1014 longitude: f64,
1015 elevation: f64,
1016 delta_t: f64,
1017 refraction: Option<RefractionCorrection>,
1018 time_dependent: &SpaTimeDependent,
1019) -> Result<SolarPosition> {
1020 check_coordinates(latitude, longitude)?;
1021
1022 let jd = JulianDate::from_datetime(&datetime, delta_t)?; let nu_degrees = calculate_apparent_sidereal_time_at_greenwich(
1026 &jd,
1027 time_dependent.delta_psi,
1028 time_dependent.epsilon_degrees,
1029 );
1030
1031 let h_degrees =
1033 normalize_degrees_0_to_360(nu_degrees + longitude - time_dependent.alpha_degrees);
1034 let h = degrees_to_radians(h_degrees);
1035
1036 let xi_degrees = 8.794 / (3600.0 * time_dependent.r);
1038 let xi = degrees_to_radians(xi_degrees);
1039 let phi = degrees_to_radians(latitude);
1040 let delta = degrees_to_radians(time_dependent.delta_degrees);
1041
1042 let u = atan(EARTH_FLATTENING_FACTOR * tan(phi));
1043 let y = EARTH_FLATTENING_FACTOR.mul_add(sin(u), (elevation / EARTH_RADIUS_METERS) * sin(phi));
1044 let x = (elevation / EARTH_RADIUS_METERS).mul_add(cos(phi), cos(u));
1045
1046 let delta_alpha_prime_degrees = radians_to_degrees(atan2(
1047 -x * sin(xi) * sin(h),
1048 (x * sin(xi)).mul_add(-cos(h), cos(delta)),
1049 ));
1050
1051 let delta_prime = radians_to_degrees(atan2(
1052 y.mul_add(-sin(xi), sin(delta)) * cos(degrees_to_radians(delta_alpha_prime_degrees)),
1053 (x * sin(xi)).mul_add(-cos(h), cos(delta)),
1054 ));
1055
1056 let h_prime_degrees = h_degrees - delta_alpha_prime_degrees;
1058
1059 let zenith_angle = radians_to_degrees(acos(sin(degrees_to_radians(latitude)).mul_add(
1061 sin(degrees_to_radians(delta_prime)),
1062 cos(degrees_to_radians(latitude))
1063 * cos(degrees_to_radians(delta_prime))
1064 * cos(degrees_to_radians(h_prime_degrees)),
1065 )));
1066
1067 let azimuth = normalize_degrees_0_to_360(
1069 180.0
1070 + radians_to_degrees(atan2(
1071 sin(degrees_to_radians(h_prime_degrees)),
1072 cos(degrees_to_radians(h_prime_degrees)) * sin(degrees_to_radians(latitude))
1073 - tan(degrees_to_radians(delta_prime)) * cos(degrees_to_radians(latitude)),
1074 )),
1075 );
1076
1077 let elevation_angle = 90.0 - zenith_angle;
1079 let final_zenith = refraction.map_or(zenith_angle, |correction| {
1080 if elevation_angle > SUNRISE_SUNSET_ANGLE {
1081 let pressure = correction.pressure();
1082 let temperature = correction.temperature();
1083 zenith_angle
1085 - (pressure / 1010.0) * (283.0 / (273.0 + temperature)) * 1.02
1086 / (60.0
1087 * tan(degrees_to_radians(
1088 elevation_angle + 10.3 / (elevation_angle + 5.11),
1089 )))
1090 } else {
1091 zenith_angle
1092 }
1093 });
1094
1095 SolarPosition::new(azimuth, final_zenith)
1096}
1097
1098#[cfg(test)]
1099mod tests {
1100 use super::*;
1101 use chrono::{DateTime, FixedOffset};
1102
1103 #[test]
1104 fn test_spa_basic_functionality() {
1105 let datetime = "2023-06-21T12:00:00Z"
1106 .parse::<DateTime<FixedOffset>>()
1107 .unwrap();
1108
1109 let result = solar_position(
1110 datetime,
1111 37.7749, -122.4194,
1113 0.0,
1114 69.0,
1115 Some(RefractionCorrection::new(1013.25, 15.0).unwrap()),
1116 );
1117
1118 assert!(result.is_ok());
1119 let position = result.unwrap();
1120 assert!(position.azimuth() >= 0.0 && position.azimuth() <= 360.0);
1121 assert!(position.zenith_angle() >= 0.0 && position.zenith_angle() <= 180.0);
1122 }
1123
1124 #[test]
1125 fn test_sunrise_sunset_multiple() {
1126 let datetime = "2023-06-21T12:00:00Z"
1127 .parse::<DateTime<FixedOffset>>()
1128 .unwrap();
1129 let horizons = [
1130 Horizon::SunriseSunset,
1131 Horizon::CivilTwilight,
1132 Horizon::NauticalTwilight,
1133 ];
1134
1135 let results: std::result::Result<Vec<_>, _> = sunrise_sunset_multiple(
1136 datetime,
1137 37.7749, -122.4194, 69.0, horizons.iter().copied(),
1141 )
1142 .collect();
1143
1144 let results = results.unwrap();
1145
1146 assert_eq!(results.len(), 3);
1148
1149 let returned_horizons: std::collections::HashSet<_> =
1151 results.iter().map(|(h, _)| *h).collect();
1152 for expected_horizon in horizons {
1153 assert!(returned_horizons.contains(&expected_horizon));
1154 }
1155
1156 for (horizon, bulk_result) in &results {
1158 let individual_result =
1159 sunrise_sunset_for_horizon(datetime, 37.7749, -122.4194, 69.0, *horizon).unwrap();
1160
1161 match (&individual_result, bulk_result) {
1163 (
1164 crate::SunriseResult::RegularDay {
1165 sunrise: s1,
1166 transit: t1,
1167 sunset: ss1,
1168 },
1169 crate::SunriseResult::RegularDay {
1170 sunrise: s2,
1171 transit: t2,
1172 sunset: ss2,
1173 },
1174 ) => {
1175 assert_eq!(s1, s2);
1176 assert_eq!(t1, t2);
1177 assert_eq!(ss1, ss2);
1178 }
1179 (
1180 crate::SunriseResult::AllDay { transit: t1 },
1181 crate::SunriseResult::AllDay { transit: t2 },
1182 )
1183 | (
1184 crate::SunriseResult::AllNight { transit: t1 },
1185 crate::SunriseResult::AllNight { transit: t2 },
1186 ) => {
1187 assert_eq!(t1, t2);
1188 }
1189 _ => panic!("Bulk and individual results differ in type for {horizon:?}"),
1190 }
1191 }
1192 }
1193
1194 #[test]
1195 fn test_spa_no_refraction() {
1196 let datetime = "2023-06-21T12:00:00Z"
1197 .parse::<DateTime<FixedOffset>>()
1198 .unwrap();
1199
1200 let result = solar_position(datetime, 37.7749, -122.4194, 0.0, 69.0, None);
1201
1202 assert!(result.is_ok());
1203 let position = result.unwrap();
1204 assert!(position.azimuth() >= 0.0 && position.azimuth() <= 360.0);
1205 assert!(position.zenith_angle() >= 0.0 && position.zenith_angle() <= 180.0);
1206 }
1207
1208 #[test]
1209 fn test_spa_coordinate_validation() {
1210 let datetime = "2023-06-21T12:00:00Z"
1211 .parse::<DateTime<FixedOffset>>()
1212 .unwrap();
1213
1214 assert!(
1216 solar_position(
1217 datetime,
1218 95.0,
1219 0.0,
1220 0.0,
1221 0.0,
1222 Some(RefractionCorrection::new(1013.25, 15.0).unwrap())
1223 )
1224 .is_err()
1225 );
1226
1227 assert!(
1229 solar_position(
1230 datetime,
1231 0.0,
1232 185.0,
1233 0.0,
1234 0.0,
1235 Some(RefractionCorrection::new(1013.25, 15.0).unwrap())
1236 )
1237 .is_err()
1238 );
1239 }
1240
1241 #[test]
1242 fn test_sunrise_sunset_basic() {
1243 let date = "2023-06-21T00:00:00Z"
1244 .parse::<DateTime<FixedOffset>>()
1245 .unwrap();
1246
1247 let result = sunrise_sunset(date, 37.7749, -122.4194, 69.0, -0.833);
1248 assert!(result.is_ok());
1249
1250 let result =
1251 sunrise_sunset_for_horizon(date, 37.7749, -122.4194, 69.0, Horizon::SunriseSunset);
1252 assert!(result.is_ok());
1253 }
1254
1255 #[test]
1256 fn test_horizon_enum() {
1257 assert_eq!(Horizon::SunriseSunset.elevation_angle(), -0.83337);
1258 assert_eq!(Horizon::CivilTwilight.elevation_angle(), -6.0);
1259 assert_eq!(Horizon::Custom(-10.5).elevation_angle(), -10.5);
1260 }
1261}