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