1#![allow(clippy::similar_names)]
7#![allow(clippy::many_single_char_names)]
8#![allow(clippy::unreadable_literal)]
9
10use crate::error::check_coordinates;
11use crate::math::{
12 acos, asin, atan, atan2, cos, degrees_to_radians, floor, normalize_degrees_0_to_360,
13 polynomial, radians_to_degrees, sin, tan,
14};
15use crate::time::JulianDate;
16use crate::{Error, Horizon, RefractionCorrection, Result, SolarPosition};
17use chrono::{DateTime, TimeZone};
18
19pub mod coefficients;
20use coefficients::{
21 NUTATION_COEFFS, OBLIQUITY_COEFFS, TERMS_B, TERMS_L, TERMS_PE, TERMS_R, TERMS_Y,
22};
23
24const SUNRISE_SUNSET_ANGLE: f64 = -0.83337;
26
27const ABERRATION_CONSTANT: f64 = -20.4898;
29
30const EARTH_FLATTENING_FACTOR: f64 = 0.99664719;
32
33const EARTH_RADIUS_METERS: f64 = 6378140.0;
35
36const SECONDS_PER_HOUR: f64 = 3600.0;
38
39#[allow(clippy::needless_pass_by_value)]
86pub fn solar_position<Tz: TimeZone>(
87 datetime: DateTime<Tz>,
88 latitude: f64,
89 longitude: f64,
90 elevation: f64,
91 delta_t: f64,
92 refraction: Option<RefractionCorrection>,
93) -> Result<SolarPosition> {
94 let time_dependent = spa_time_dependent_parts(datetime.clone(), delta_t)?;
96 spa_with_time_dependent_parts(
97 datetime,
98 latitude,
99 longitude,
100 elevation,
101 delta_t,
102 refraction,
103 &time_dependent,
104 )
105}
106
107#[derive(Debug, Clone)]
110pub struct SpaTimeDependent {
111 pub theta_degrees: f64,
113 pub beta_degrees: f64,
115 pub r: f64,
117 pub delta_psi: f64,
119 pub epsilon_degrees: f64,
121 pub lambda_degrees: f64,
123 pub nu_degrees: f64,
125 pub alpha_degrees: f64,
127 pub delta_degrees: f64,
129}
130
131#[derive(Debug, Clone, Copy)]
132struct DeltaPsiEpsilon {
133 delta_psi: f64,
134 delta_epsilon: f64,
135}
136
137fn calculate_lbr_terms(jme: f64, term_coeffs: &[&[&[f64; 3]]]) -> Vec<f64> {
139 let mut lbr_terms = vec![0.0; term_coeffs.len()];
140
141 for (i, term_set) in term_coeffs.iter().enumerate() {
142 let mut lbr_sum = 0.0;
143 for term in *term_set {
144 lbr_sum += term[0] * cos(term[2].mul_add(jme, term[1]));
145 }
146 lbr_terms[i] = lbr_sum;
147 }
148
149 lbr_terms
150}
151
152fn calculate_lbr_polynomial(jme: f64, terms: &[f64]) -> f64 {
154 polynomial(terms, jme) / 1e8
155}
156
157fn calculate_nutation_terms(jce: f64) -> Vec<f64> {
159 NUTATION_COEFFS
160 .iter()
161 .map(|coeffs| polynomial(coeffs, jce))
162 .collect()
163}
164
165fn calculate_delta_psi_epsilon(jce: f64, x: &[f64]) -> DeltaPsiEpsilon {
167 let mut delta_psi = 0.0;
168 let mut delta_epsilon = 0.0;
169
170 for (i, pe_term) in TERMS_PE.iter().enumerate() {
171 let xj_yterm_sum = degrees_to_radians(calculate_xj_yterm_sum(i, x));
172
173 let delta_psi_contrib = pe_term[1].mul_add(jce, pe_term[0]) * sin(xj_yterm_sum);
175 let delta_epsilon_contrib = pe_term[3].mul_add(jce, pe_term[2]) * cos(xj_yterm_sum);
176
177 delta_psi += delta_psi_contrib;
178 delta_epsilon += delta_epsilon_contrib;
179 }
180
181 DeltaPsiEpsilon {
182 delta_psi: delta_psi / 36_000_000.0,
183 delta_epsilon: delta_epsilon / 36_000_000.0,
184 }
185}
186
187fn calculate_xj_yterm_sum(i: usize, x: &[f64]) -> f64 {
189 let mut sum = 0.0;
190 for (j, &x_val) in x.iter().enumerate() {
191 sum += x_val * f64::from(TERMS_Y[i][j]);
192 }
193 sum
194}
195
196fn calculate_true_obliquity_of_ecliptic(jd: &JulianDate, delta_epsilon: f64) -> f64 {
198 let epsilon0 = polynomial(OBLIQUITY_COEFFS, jd.julian_ephemeris_millennium() / 10.0);
199 epsilon0 / 3600.0 + delta_epsilon
200}
201
202fn calculate_apparent_sidereal_time_at_greenwich(
204 jd: &JulianDate,
205 delta_psi: f64,
206 epsilon_degrees: f64,
207) -> f64 {
208 let nu0_degrees = normalize_degrees_0_to_360(jd.julian_century().powi(2).mul_add(
209 0.000387933 - jd.julian_century() / 38710000.0,
210 360.98564736629f64.mul_add(jd.julian_date() - 2451545.0, 280.46061837),
211 ));
212
213 delta_psi.mul_add(cos(degrees_to_radians(epsilon_degrees)), nu0_degrees)
214}
215
216fn calculate_geocentric_sun_right_ascension(
218 beta_rad: f64,
219 epsilon_rad: f64,
220 lambda_rad: f64,
221) -> f64 {
222 let alpha = atan2(
223 sin(lambda_rad).mul_add(cos(epsilon_rad), -(tan(beta_rad) * sin(epsilon_rad))),
224 cos(lambda_rad),
225 );
226 normalize_degrees_0_to_360(radians_to_degrees(alpha))
227}
228
229fn calculate_geocentric_sun_declination(beta_rad: f64, epsilon_rad: f64, lambda_rad: f64) -> f64 {
231 asin(sin(beta_rad).mul_add(
232 cos(epsilon_rad),
233 cos(beta_rad) * sin(epsilon_rad) * sin(lambda_rad),
234 ))
235}
236
237#[allow(clippy::needless_pass_by_value)]
275pub fn sunrise_sunset<Tz: TimeZone>(
276 date: DateTime<Tz>,
277 latitude: f64,
278 longitude: f64,
279 delta_t: f64,
280 elevation_angle: f64,
281) -> Result<crate::SunriseResult<DateTime<Tz>>> {
282 check_coordinates(latitude, longitude)?;
283
284 let day_start = date
286 .timezone()
287 .from_local_datetime(&date.date_naive().and_hms_opt(0, 0, 0).unwrap())
288 .unwrap();
289
290 calculate_sunrise_sunset_spa_algorithm(day_start, latitude, longitude, delta_t, elevation_angle)
292}
293
294fn calculate_sunrise_sunset_spa_algorithm<Tz: TimeZone>(
296 day: DateTime<Tz>,
297 latitude: f64,
298 longitude: f64,
299 delta_t: f64,
300 elevation_angle: f64,
301) -> Result<crate::SunriseResult<DateTime<Tz>>> {
302 let day_start = day
303 .timezone()
304 .from_local_datetime(&day.date_naive().and_hms_opt(0, 0, 0).unwrap())
305 .unwrap();
306
307 let (nu_degrees, delta_psi_epsilon, epsilon_degrees) =
309 calculate_sidereal_time_and_nutation(day_start.clone());
310
311 let alpha_deltas =
313 calculate_alpha_deltas_for_three_days(day_start, delta_psi_epsilon, epsilon_degrees)?;
314
315 let m0 = (alpha_deltas[1].alpha - longitude - nu_degrees) / 360.0;
317
318 let polar_type = check_polar_conditions_type(latitude, elevation_angle, alpha_deltas[1].delta);
320
321 let (m_values, _h0_degrees) =
323 calculate_approximate_times(m0, latitude, elevation_angle, alpha_deltas[1].delta);
324
325 let final_result = calculate_final_times(FinalTimeParams {
327 day,
328 m_values,
329 nu_degrees,
330 delta_t,
331 latitude,
332 longitude,
333 elevation_angle,
334 alpha_deltas,
335 });
336
337 match polar_type {
339 Some(PolarType::AllDay) => {
340 if let crate::SunriseResult::RegularDay { transit, .. } = final_result {
341 Ok(crate::SunriseResult::AllDay { transit })
342 } else {
343 Ok(final_result)
344 }
345 }
346 Some(PolarType::AllNight) => {
347 if let crate::SunriseResult::RegularDay { transit, .. } = final_result {
348 Ok(crate::SunriseResult::AllNight { transit })
349 } else {
350 Ok(final_result)
351 }
352 }
353 None => Ok(final_result),
354 }
355}
356
357#[allow(clippy::needless_pass_by_value)]
359fn calculate_sidereal_time_and_nutation<Tz: TimeZone>(
360 day_start: DateTime<Tz>,
361) -> (f64, DeltaPsiEpsilon, f64) {
362 let jd = JulianDate::from_datetime(&day_start, 0.0).unwrap();
363 let jce_day = jd.julian_ephemeris_century();
364 let x_terms = calculate_nutation_terms(jce_day);
365 let delta_psi_epsilon = calculate_delta_psi_epsilon(jce_day, &x_terms);
366 let epsilon_degrees =
367 calculate_true_obliquity_of_ecliptic(&jd, delta_psi_epsilon.delta_epsilon);
368 let nu_degrees = calculate_apparent_sidereal_time_at_greenwich(
369 &jd,
370 delta_psi_epsilon.delta_psi,
371 epsilon_degrees,
372 );
373 (nu_degrees, delta_psi_epsilon, epsilon_degrees)
374}
375
376#[allow(clippy::needless_pass_by_value)]
378fn calculate_alpha_deltas_for_three_days<Tz: TimeZone>(
379 day_start: DateTime<Tz>,
380 delta_psi_epsilon: DeltaPsiEpsilon,
381 epsilon_degrees: f64,
382) -> Result<[AlphaDelta; 3]> {
383 let mut alpha_deltas = [AlphaDelta {
384 alpha: 0.0,
385 delta: 0.0,
386 }; 3];
387 for (i, alpha_delta) in alpha_deltas.iter_mut().enumerate() {
388 let current_jd = JulianDate::from_datetime(&day_start, 0.0)?.add_days((i as f64) - 1.0);
389 let current_jme = current_jd.julian_ephemeris_millennium();
390 let ad = calculate_alpha_delta(current_jme, delta_psi_epsilon.delta_psi, epsilon_degrees);
391 *alpha_delta = ad;
392 }
393 Ok(alpha_deltas)
394}
395
396fn check_polar_conditions<Tz: TimeZone>(
398 day: DateTime<Tz>,
399 m0: f64,
400 latitude: f64,
401 elevation_angle: f64,
402 delta1: f64,
403) -> Option<crate::SunriseResult<DateTime<Tz>>> {
404 let phi = degrees_to_radians(latitude);
405 let delta1_rad = degrees_to_radians(delta1);
406 let elevation_rad = degrees_to_radians(elevation_angle);
407
408 let acos_arg =
409 sin(phi).mul_add(-sin(delta1_rad), sin(elevation_rad)) / (cos(phi) * cos(delta1_rad));
410
411 if acos_arg < -1.0 {
412 let transit = add_fraction_of_day(day, m0);
413 Some(crate::SunriseResult::AllDay { transit })
414 } else if acos_arg > 1.0 {
415 let transit = add_fraction_of_day(day, m0);
416 Some(crate::SunriseResult::AllNight { transit })
417 } else {
418 None
419 }
420}
421
422#[derive(Debug, Clone, Copy)]
424enum PolarType {
425 AllDay,
426 AllNight,
427}
428
429fn check_polar_conditions_type(
431 latitude: f64,
432 elevation_angle: f64,
433 delta1: f64,
434) -> Option<PolarType> {
435 let phi = degrees_to_radians(latitude);
436 let elevation_rad = degrees_to_radians(elevation_angle);
437 let delta1_rad = degrees_to_radians(delta1);
438
439 let acos_arg =
440 sin(phi).mul_add(-sin(delta1_rad), sin(elevation_rad)) / (cos(phi) * cos(delta1_rad));
441
442 if acos_arg < -1.0 {
443 Some(PolarType::AllDay)
444 } else if acos_arg > 1.0 {
445 Some(PolarType::AllNight)
446 } else {
447 None
448 }
449}
450
451fn calculate_approximate_times(
453 m0: f64,
454 latitude: f64,
455 elevation_angle: f64,
456 delta1: f64,
457) -> ([f64; 3], f64) {
458 let phi = degrees_to_radians(latitude);
459 let delta1_rad = degrees_to_radians(delta1);
460 let elevation_rad = degrees_to_radians(elevation_angle);
461
462 let acos_arg =
463 sin(phi).mul_add(-sin(delta1_rad), sin(elevation_rad)) / (cos(phi) * cos(delta1_rad));
464 let h0 = acos(acos_arg);
465 let h0_degrees = radians_to_degrees(h0).min(180.0);
466
467 let mut m = [0.0; 3];
468 m[0] = normalize_to_unit_range(m0);
469 m[1] = normalize_to_unit_range(m0 - h0_degrees / 360.0);
470 m[2] = normalize_to_unit_range(m0 + h0_degrees / 360.0);
471
472 (m, h0_degrees)
473}
474
475fn calculate_final_times<Tz: TimeZone>(
477 params: FinalTimeParams<Tz>,
478) -> crate::SunriseResult<DateTime<Tz>> {
479 let mut nu = [0.0; 3];
481 for (i, nu_item) in nu.iter_mut().enumerate() {
482 *nu_item = 360.985647f64.mul_add(params.m_values[i], params.nu_degrees);
483 }
484
485 let mut n = [0.0; 3];
487 for (i, n_item) in n.iter_mut().enumerate() {
488 *n_item = params.m_values[i] + params.delta_t / 86400.0;
489 }
490
491 let alpha_delta_primes = calculate_interpolated_alpha_deltas(¶ms.alpha_deltas, &n);
493
494 let mut h_prime = [0.0; 3];
496 for i in 0..3 {
497 let h_prime_i = nu[i] + params.longitude - alpha_delta_primes[i].alpha;
498 h_prime[i] = limit_h_prime(h_prime_i);
499 }
500
501 let phi = degrees_to_radians(params.latitude);
503 let mut h = [0.0; 3];
504 for i in 0..3 {
505 let delta_prime_rad = degrees_to_radians(alpha_delta_primes[i].delta);
506 h[i] = radians_to_degrees(asin(sin(phi).mul_add(
507 sin(delta_prime_rad),
508 cos(phi) * cos(delta_prime_rad) * cos(degrees_to_radians(h_prime[i])),
509 )));
510 }
511
512 let t = params.m_values[0] - h_prime[0] / 360.0;
514 let r = params.m_values[1]
515 + (h[1] - params.elevation_angle)
516 / (360.0
517 * cos(degrees_to_radians(alpha_delta_primes[1].delta))
518 * cos(phi)
519 * sin(degrees_to_radians(h_prime[1])));
520 let s = params.m_values[2]
521 + (h[2] - params.elevation_angle)
522 / (360.0
523 * cos(degrees_to_radians(alpha_delta_primes[2].delta))
524 * cos(phi)
525 * sin(degrees_to_radians(h_prime[2])));
526
527 let sunrise = add_fraction_of_day(params.day.clone(), r);
528 let transit = add_fraction_of_day(params.day.clone(), t);
529 let sunset = add_fraction_of_day(params.day, s);
530
531 crate::SunriseResult::RegularDay {
532 sunrise,
533 transit,
534 sunset,
535 }
536}
537
538fn calculate_interpolated_alpha_deltas(
540 alpha_deltas: &[AlphaDelta; 3],
541 n: &[f64; 3],
542) -> [AlphaDelta; 3] {
543 let a = limit_if_necessary(alpha_deltas[1].alpha - alpha_deltas[0].alpha);
544 let a_prime = limit_if_necessary(alpha_deltas[1].delta - alpha_deltas[0].delta);
545
546 let b = limit_if_necessary(alpha_deltas[2].alpha - alpha_deltas[1].alpha);
547 let b_prime = limit_if_necessary(alpha_deltas[2].delta - alpha_deltas[1].delta);
548
549 let c = b - a;
550 let c_prime = b_prime - a_prime;
551
552 let mut alpha_delta_primes = [AlphaDelta {
553 alpha: 0.0,
554 delta: 0.0,
555 }; 3];
556 for i in 0..3 {
557 alpha_delta_primes[i].alpha =
558 alpha_deltas[1].alpha + (n[i] * (c.mul_add(n[i], a + b))) / 2.0;
559 alpha_delta_primes[i].delta =
560 alpha_deltas[1].delta + (n[i] * (c_prime.mul_add(n[i], a_prime + b_prime))) / 2.0;
561 }
562 alpha_delta_primes
563}
564
565#[derive(Debug, Clone, Copy)]
566struct AlphaDelta {
567 alpha: f64,
568 delta: f64,
569}
570
571#[derive(Debug, Clone)]
573struct FinalTimeParams<Tz: TimeZone> {
574 day: DateTime<Tz>,
575 m_values: [f64; 3],
576 nu_degrees: f64,
577 delta_t: f64,
578 latitude: f64,
579 longitude: f64,
580 elevation_angle: f64,
581 alpha_deltas: [AlphaDelta; 3],
582}
583
584pub fn sunrise_sunset_for_horizon<Tz: TimeZone>(
619 date: DateTime<Tz>,
620 latitude: f64,
621 longitude: f64,
622 delta_t: f64,
623 horizon: Horizon,
624) -> Result<crate::SunriseResult<DateTime<Tz>>> {
625 sunrise_sunset(
626 date,
627 latitude,
628 longitude,
629 delta_t,
630 horizon.elevation_angle(),
631 )
632}
633
634fn calculate_alpha_delta(jme: f64, delta_psi: f64, epsilon_degrees: f64) -> AlphaDelta {
637 let b_terms = calculate_lbr_terms(jme, TERMS_B);
641 let b_degrees =
642 normalize_degrees_0_to_360(radians_to_degrees(calculate_lbr_polynomial(jme, &b_terms)));
643
644 let r_terms = calculate_lbr_terms(jme, TERMS_R);
646 let r = calculate_lbr_polynomial(jme, &r_terms);
647 assert!(r != 0.0);
648
649 let l_terms = calculate_lbr_terms(jme, TERMS_L);
651 let l_degrees =
652 normalize_degrees_0_to_360(radians_to_degrees(calculate_lbr_polynomial(jme, &l_terms)));
653
654 let theta_degrees = normalize_degrees_0_to_360(l_degrees + 180.0);
656
657 let beta_degrees = -b_degrees;
659 let beta = degrees_to_radians(beta_degrees);
660 let epsilon = degrees_to_radians(epsilon_degrees);
661
662 let delta_tau = ABERRATION_CONSTANT / (SECONDS_PER_HOUR * r);
664
665 let lambda_degrees = theta_degrees + delta_psi + delta_tau;
667 let lambda = degrees_to_radians(lambda_degrees);
668
669 let alpha_degrees = calculate_geocentric_sun_right_ascension(beta, epsilon, lambda);
671 let delta_degrees =
672 radians_to_degrees(calculate_geocentric_sun_declination(beta, epsilon, lambda));
673
674 AlphaDelta {
675 alpha: alpha_degrees,
676 delta: delta_degrees,
677 }
678}
679
680fn normalize_to_unit_range(val: f64) -> f64 {
682 let divided = val;
683 let limited = divided - divided.floor();
684 if limited < 0.0 {
685 limited + 1.0
686 } else {
687 limited
688 }
689}
690
691#[allow(clippy::needless_pass_by_value)]
693fn add_fraction_of_day<Tz: TimeZone>(day: DateTime<Tz>, fraction: f64) -> DateTime<Tz> {
694 const MS_PER_DAY: i32 = 24 * 60 * 60 * 1000; let millis_plus = (f64::from(MS_PER_DAY) * fraction) as i32; let day_start = day
702 .timezone()
703 .from_local_datetime(&day.date_naive().and_hms_opt(0, 0, 0).unwrap())
704 .unwrap();
705
706 day_start + chrono::Duration::milliseconds(i64::from(millis_plus))
707}
708
709fn limit_if_necessary(val: f64) -> f64 {
711 if val.abs() > 2.0 {
712 normalize_to_unit_range(val)
713 } else {
714 val
715 }
716}
717
718fn limit_h_prime(h_prime: f64) -> f64 {
720 let normalized = h_prime / 360.0;
721 let limited = 360.0 * (normalized - floor(normalized));
722
723 if limited < -180.0 {
724 limited + 360.0
725 } else if limited > 180.0 {
726 limited - 360.0
727 } else {
728 limited
729 }
730}
731
732pub fn sunrise_sunset_multiple<Tz, H>(
778 date: DateTime<Tz>,
779 latitude: f64,
780 longitude: f64,
781 delta_t: f64,
782 horizons: H,
783) -> impl Iterator<Item = Result<(Horizon, crate::SunriseResult<DateTime<Tz>>)>>
784where
785 Tz: TimeZone,
786 H: IntoIterator<Item = Horizon>,
787{
788 let precomputed = (|| -> Result<_> {
790 check_coordinates(latitude, longitude)?;
791
792 let day_start = date
793 .timezone()
794 .from_local_datetime(&date.date_naive().and_hms_opt(0, 0, 0).unwrap())
795 .unwrap();
796 let (nu_degrees, delta_psi_epsilon, epsilon_degrees) =
797 calculate_sidereal_time_and_nutation(day_start.clone());
798 let alpha_deltas =
799 calculate_alpha_deltas_for_three_days(day_start, delta_psi_epsilon, epsilon_degrees)?;
800
801 Ok((nu_degrees, alpha_deltas))
802 })();
803
804 horizons.into_iter().map(move |horizon| match &precomputed {
805 Ok((nu_degrees, alpha_deltas)) => {
806 let result = calculate_sunrise_sunset_spa_algorithm_with_precomputed(
807 date.clone(),
808 latitude,
809 longitude,
810 delta_t,
811 horizon.elevation_angle(),
812 *nu_degrees,
813 *alpha_deltas,
814 );
815 Ok((horizon, result))
816 }
817 Err(e) => Err(e.clone()),
818 })
819}
820
821#[allow(clippy::needless_pass_by_value)]
822fn calculate_sunrise_sunset_spa_algorithm_with_precomputed<Tz: TimeZone>(
823 day: DateTime<Tz>,
824 latitude: f64,
825 longitude: f64,
826 delta_t: f64,
827 elevation_angle: f64,
828 nu_degrees: f64,
829 alpha_deltas: [AlphaDelta; 3],
830) -> crate::SunriseResult<DateTime<Tz>> {
831 let day_start = day
833 .timezone()
834 .from_local_datetime(&day.date_naive().and_hms_opt(0, 0, 0).unwrap())
835 .unwrap();
836
837 let m0 = (alpha_deltas[1].alpha - longitude - nu_degrees) / 360.0;
839 if let Some(polar_result) = check_polar_conditions(
840 day_start.clone(),
841 m0,
842 latitude,
843 elevation_angle,
844 alpha_deltas[1].delta,
845 ) {
846 return polar_result;
847 }
848
849 let (m_values, _h0_degrees) =
851 calculate_approximate_times(m0, latitude, elevation_angle, alpha_deltas[1].delta);
852
853 calculate_final_times(FinalTimeParams {
855 day: day_start,
856 m_values,
857 nu_degrees,
858 delta_t,
859 latitude,
860 longitude,
861 elevation_angle,
862 alpha_deltas,
863 })
864}
865
866#[allow(clippy::needless_pass_by_value)]
896pub fn spa_time_dependent_parts<Tz: TimeZone>(
897 datetime: DateTime<Tz>,
898 delta_t: f64,
899) -> Result<SpaTimeDependent> {
900 use crate::time::JulianDate;
901
902 let utc_datetime = datetime.with_timezone(&chrono::Utc);
904 let jd = JulianDate::from_datetime(&utc_datetime, delta_t)?;
905 let jme = jd.julian_ephemeris_millennium();
906 let jce = jd.julian_ephemeris_century();
907
908 let l_terms = calculate_lbr_terms(jme, TERMS_L);
910 let l_degrees =
911 normalize_degrees_0_to_360(radians_to_degrees(calculate_lbr_polynomial(jme, &l_terms)));
912
913 let b_terms = calculate_lbr_terms(jme, TERMS_B);
915 let b_degrees =
916 normalize_degrees_0_to_360(radians_to_degrees(calculate_lbr_polynomial(jme, &b_terms)));
917
918 let r_terms = calculate_lbr_terms(jme, TERMS_R);
920 let r = calculate_lbr_polynomial(jme, &r_terms);
921
922 if r == 0.0 {
923 return Err(Error::computation_error("Earth radius vector is zero"));
924 }
925
926 let theta_degrees = normalize_degrees_0_to_360(l_degrees + 180.0);
928 let beta_degrees = -b_degrees;
930
931 let x_terms = calculate_nutation_terms(jce);
933 let delta_psi_epsilon = calculate_delta_psi_epsilon(jce, &x_terms);
934
935 let epsilon_degrees =
937 calculate_true_obliquity_of_ecliptic(&jd, delta_psi_epsilon.delta_epsilon);
938
939 let delta_tau = ABERRATION_CONSTANT / (SECONDS_PER_HOUR * r);
941
942 let lambda_degrees = theta_degrees + delta_psi_epsilon.delta_psi + delta_tau;
944
945 let nu_degrees = calculate_apparent_sidereal_time_at_greenwich(
947 &jd,
948 delta_psi_epsilon.delta_psi,
949 epsilon_degrees,
950 );
951
952 let beta = degrees_to_radians(beta_degrees);
954 let epsilon = degrees_to_radians(epsilon_degrees);
955 let lambda = degrees_to_radians(lambda_degrees);
956 let alpha_degrees = calculate_geocentric_sun_right_ascension(beta, epsilon, lambda);
957
958 let delta_degrees =
960 radians_to_degrees(calculate_geocentric_sun_declination(beta, epsilon, lambda));
961
962 Ok(SpaTimeDependent {
963 theta_degrees,
964 beta_degrees,
965 r,
966 delta_psi: delta_psi_epsilon.delta_psi,
967 epsilon_degrees,
968 lambda_degrees,
969 nu_degrees,
970 alpha_degrees,
971 delta_degrees,
972 })
973}
974
975#[allow(clippy::needless_pass_by_value)]
995pub fn spa_with_time_dependent_parts<Tz: TimeZone>(
996 datetime: DateTime<Tz>,
997 latitude: f64,
998 longitude: f64,
999 elevation: f64,
1000 delta_t: f64,
1001 refraction: Option<RefractionCorrection>,
1002 time_dependent: &SpaTimeDependent,
1003) -> Result<SolarPosition> {
1004 check_coordinates(latitude, longitude)?;
1005
1006 let jd = JulianDate::from_datetime(&datetime, delta_t)?; let nu_degrees = calculate_apparent_sidereal_time_at_greenwich(
1010 &jd,
1011 time_dependent.delta_psi,
1012 time_dependent.epsilon_degrees,
1013 );
1014
1015 let beta = degrees_to_radians(time_dependent.beta_degrees);
1017 let epsilon = degrees_to_radians(time_dependent.epsilon_degrees);
1018 let lambda = degrees_to_radians(time_dependent.lambda_degrees);
1019 let alpha_degrees = calculate_geocentric_sun_right_ascension(beta, epsilon, lambda);
1020 let delta_degrees =
1021 radians_to_degrees(calculate_geocentric_sun_declination(beta, epsilon, lambda));
1022
1023 let h_degrees = normalize_degrees_0_to_360(nu_degrees + longitude - alpha_degrees);
1024 let h = degrees_to_radians(h_degrees);
1025
1026 let xi_degrees = 8.794 / (3600.0 * time_dependent.r);
1028 let xi = degrees_to_radians(xi_degrees);
1029 let phi = degrees_to_radians(latitude);
1030 let delta = degrees_to_radians(delta_degrees);
1031
1032 let u = atan(EARTH_FLATTENING_FACTOR * tan(phi));
1033 let y = EARTH_FLATTENING_FACTOR.mul_add(sin(u), (elevation / EARTH_RADIUS_METERS) * sin(phi));
1034 let x = (elevation / EARTH_RADIUS_METERS).mul_add(cos(phi), cos(u));
1035
1036 let delta_alpha_prime_degrees = radians_to_degrees(atan2(
1037 -x * sin(xi) * sin(h),
1038 (x * sin(xi)).mul_add(-cos(h), cos(delta)),
1039 ));
1040
1041 let delta_prime = radians_to_degrees(atan2(
1042 y.mul_add(-sin(xi), sin(delta)) * cos(degrees_to_radians(delta_alpha_prime_degrees)),
1043 (x * sin(xi)).mul_add(-cos(h), cos(delta)),
1044 ));
1045
1046 let h_prime_degrees = h_degrees - delta_alpha_prime_degrees;
1048
1049 let zenith_angle = radians_to_degrees(acos(sin(degrees_to_radians(latitude)).mul_add(
1051 sin(degrees_to_radians(delta_prime)),
1052 cos(degrees_to_radians(latitude))
1053 * cos(degrees_to_radians(delta_prime))
1054 * cos(degrees_to_radians(h_prime_degrees)),
1055 )));
1056
1057 let azimuth = normalize_degrees_0_to_360(
1059 180.0
1060 + radians_to_degrees(atan2(
1061 sin(degrees_to_radians(h_prime_degrees)),
1062 cos(degrees_to_radians(h_prime_degrees)) * sin(degrees_to_radians(latitude))
1063 - tan(degrees_to_radians(delta_prime)) * cos(degrees_to_radians(latitude)),
1064 )),
1065 );
1066
1067 let elevation_angle = 90.0 - zenith_angle;
1069 let final_zenith = refraction.map_or(zenith_angle, |correction| {
1070 if elevation_angle > SUNRISE_SUNSET_ANGLE {
1071 let pressure = correction.pressure();
1072 let temperature = correction.temperature();
1073 zenith_angle
1075 - (pressure / 1010.0) * (283.0 / (273.0 + temperature)) * 1.02
1076 / (60.0
1077 * tan(degrees_to_radians(
1078 elevation_angle + 10.3 / (elevation_angle + 5.11),
1079 )))
1080 } else {
1081 zenith_angle
1082 }
1083 });
1084
1085 SolarPosition::new(azimuth, final_zenith)
1086}
1087
1088#[cfg(test)]
1089mod tests {
1090 use super::*;
1091 use chrono::{DateTime, FixedOffset};
1092
1093 #[test]
1094 fn test_spa_basic_functionality() {
1095 let datetime = "2023-06-21T12:00:00Z"
1096 .parse::<DateTime<FixedOffset>>()
1097 .unwrap();
1098
1099 let result = solar_position(
1100 datetime,
1101 37.7749, -122.4194,
1103 0.0,
1104 69.0,
1105 Some(RefractionCorrection::new(1013.25, 15.0).unwrap()),
1106 );
1107
1108 assert!(result.is_ok());
1109 let position = result.unwrap();
1110 assert!(position.azimuth() >= 0.0 && position.azimuth() <= 360.0);
1111 assert!(position.zenith_angle() >= 0.0 && position.zenith_angle() <= 180.0);
1112 }
1113
1114 #[test]
1115 fn test_sunrise_sunset_multiple() {
1116 let datetime = "2023-06-21T12:00:00Z"
1117 .parse::<DateTime<FixedOffset>>()
1118 .unwrap();
1119 let horizons = [
1120 Horizon::SunriseSunset,
1121 Horizon::CivilTwilight,
1122 Horizon::NauticalTwilight,
1123 ];
1124
1125 let results: std::result::Result<Vec<_>, _> = sunrise_sunset_multiple(
1126 datetime,
1127 37.7749, -122.4194, 69.0, horizons.iter().copied(),
1131 )
1132 .collect();
1133
1134 let results = results.unwrap();
1135
1136 assert_eq!(results.len(), 3);
1138
1139 let returned_horizons: std::collections::HashSet<_> =
1141 results.iter().map(|(h, _)| *h).collect();
1142 for expected_horizon in horizons {
1143 assert!(returned_horizons.contains(&expected_horizon));
1144 }
1145
1146 for (horizon, bulk_result) in &results {
1148 let individual_result =
1149 sunrise_sunset_for_horizon(datetime, 37.7749, -122.4194, 69.0, *horizon).unwrap();
1150
1151 match (&individual_result, bulk_result) {
1153 (
1154 crate::SunriseResult::RegularDay {
1155 sunrise: s1,
1156 transit: t1,
1157 sunset: ss1,
1158 },
1159 crate::SunriseResult::RegularDay {
1160 sunrise: s2,
1161 transit: t2,
1162 sunset: ss2,
1163 },
1164 ) => {
1165 assert_eq!(s1, s2);
1166 assert_eq!(t1, t2);
1167 assert_eq!(ss1, ss2);
1168 }
1169 (
1170 crate::SunriseResult::AllDay { transit: t1 },
1171 crate::SunriseResult::AllDay { transit: t2 },
1172 )
1173 | (
1174 crate::SunriseResult::AllNight { transit: t1 },
1175 crate::SunriseResult::AllNight { transit: t2 },
1176 ) => {
1177 assert_eq!(t1, t2);
1178 }
1179 _ => panic!("Bulk and individual results differ in type for {horizon:?}"),
1180 }
1181 }
1182 }
1183
1184 #[test]
1185 fn test_spa_no_refraction() {
1186 let datetime = "2023-06-21T12:00:00Z"
1187 .parse::<DateTime<FixedOffset>>()
1188 .unwrap();
1189
1190 let result = solar_position(datetime, 37.7749, -122.4194, 0.0, 69.0, None);
1191
1192 assert!(result.is_ok());
1193 let position = result.unwrap();
1194 assert!(position.azimuth() >= 0.0 && position.azimuth() <= 360.0);
1195 assert!(position.zenith_angle() >= 0.0 && position.zenith_angle() <= 180.0);
1196 }
1197
1198 #[test]
1199 fn test_spa_coordinate_validation() {
1200 let datetime = "2023-06-21T12:00:00Z"
1201 .parse::<DateTime<FixedOffset>>()
1202 .unwrap();
1203
1204 assert!(
1206 solar_position(
1207 datetime,
1208 95.0,
1209 0.0,
1210 0.0,
1211 0.0,
1212 Some(RefractionCorrection::new(1013.25, 15.0).unwrap())
1213 )
1214 .is_err()
1215 );
1216
1217 assert!(
1219 solar_position(
1220 datetime,
1221 0.0,
1222 185.0,
1223 0.0,
1224 0.0,
1225 Some(RefractionCorrection::new(1013.25, 15.0).unwrap())
1226 )
1227 .is_err()
1228 );
1229 }
1230
1231 #[test]
1232 fn test_sunrise_sunset_basic() {
1233 let date = "2023-06-21T00:00:00Z"
1234 .parse::<DateTime<FixedOffset>>()
1235 .unwrap();
1236
1237 let result = sunrise_sunset(date, 37.7749, -122.4194, 69.0, -0.833);
1238 assert!(result.is_ok());
1239
1240 let result =
1241 sunrise_sunset_for_horizon(date, 37.7749, -122.4194, 69.0, Horizon::SunriseSunset);
1242 assert!(result.is_ok());
1243 }
1244
1245 #[test]
1246 fn test_horizon_enum() {
1247 assert_eq!(Horizon::SunriseSunset.elevation_angle(), -0.83337);
1248 assert_eq!(Horizon::CivilTwilight.elevation_angle(), -6.0);
1249 assert_eq!(Horizon::Custom(-10.5).elevation_angle(), -10.5);
1250 }
1251}