solar_positioning/spa/
mod.rs

1//! SPA algorithm implementation.
2//!
3//! High-accuracy solar positioning based on the NREL algorithm by Reda & Andreas (2003).
4//! Accuracy: ±0.0003° for years -2000 to 6000.
5//!
6//! Reference: Reda, I.; Andreas, A. (2003). Solar position algorithm for solar radiation applications.
7//! Solar Energy, 76(5), 577-589. DOI: <http://dx.doi.org/10.1016/j.solener.2003.12.003>
8
9#![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
33/// Standard sunrise/sunset elevation angle (accounts for refraction and sun's radius).
34const SUNRISE_SUNSET_ANGLE: f64 = -0.83337;
35
36/// Aberration constant in arcseconds.
37const ABERRATION_CONSTANT: f64 = -20.4898;
38
39/// Earth flattening factor (WGS84).
40const EARTH_FLATTENING_FACTOR: f64 = 0.99664719;
41
42/// Earth radius in meters (WGS84).
43const EARTH_RADIUS_METERS: f64 = 6378140.0;
44
45/// Seconds per hour conversion factor.
46const SECONDS_PER_HOUR: f64 = 3600.0;
47
48/// Calculate solar position using the SPA algorithm.
49///
50/// # Arguments
51/// * `datetime` - Date and time with timezone
52/// * `latitude` - Observer latitude in degrees (-90 to +90)
53/// * `longitude` - Observer longitude in degrees (-180 to +180)
54/// * `elevation` - Observer elevation in meters above sea level
55/// * `delta_t` - ΔT in seconds (difference between TT and UT1)
56/// * `refraction` - Optional atmospheric refraction correction
57///
58/// # Returns
59/// Solar position or error
60///
61/// # Errors
62/// Returns error for invalid coordinates (latitude outside ±90°, longitude outside ±180°)
63///
64/// # Example
65/// ```rust
66/// use solar_positioning::{spa, RefractionCorrection};
67/// use chrono::{DateTime, FixedOffset};
68///
69/// let datetime = "2023-06-21T12:00:00-07:00".parse::<DateTime<FixedOffset>>().unwrap();
70///
71/// // With atmospheric refraction correction
72/// let position = spa::solar_position(
73///     datetime,
74///     37.7749,     // San Francisco latitude
75///     -122.4194,   // San Francisco longitude
76///     0.0,         // elevation (meters)
77///     69.0,        // deltaT (seconds)
78///     Some(RefractionCorrection::standard()),
79/// ).unwrap();
80///
81/// // Without refraction correction
82/// let position_no_refraction = spa::solar_position(
83///     datetime,
84///     37.7749,
85///     -122.4194,
86///     0.0,
87///     69.0,
88///     None,
89/// ).unwrap();
90///
91/// println!("Azimuth: {:.3}°", position.azimuth());
92/// println!("Elevation: {:.3}°", position.elevation_angle());
93/// ```
94#[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
108/// Calculate solar position from a Julian date.
109///
110/// Core implementation for `no_std` compatibility (no chrono dependency).
111///
112/// # Arguments
113/// * `jd` - Julian date with `delta_t`
114/// * `latitude` - Observer latitude in degrees (-90 to +90)
115/// * `longitude` - Observer longitude in degrees (-180 to +180)
116/// * `elevation` - Observer elevation in meters above sea level
117/// * `refraction` - Optional atmospheric refraction correction
118///
119/// # Returns
120/// Solar position or error
121///
122/// # Errors
123/// Returns error for invalid coordinates
124///
125/// # Example
126/// ```rust
127/// use solar_positioning::{spa, time::JulianDate, RefractionCorrection};
128///
129/// // Julian date for 2023-06-21 12:00:00 UTC with ΔT=69s
130/// let jd = JulianDate::from_utc(2023, 6, 21, 12, 0, 0.0, 69.0).unwrap();
131///
132/// let position = spa::solar_position_from_julian(
133///     jd,
134///     37.7749,     // San Francisco latitude
135///     -122.4194,   // San Francisco longitude
136///     0.0,         // elevation (meters)
137///     Some(RefractionCorrection::standard()),
138/// ).unwrap();
139///
140/// println!("Azimuth: {:.3}°", position.azimuth());
141/// println!("Elevation: {:.3}°", position.elevation_angle());
142/// ```
143pub 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/// Time-dependent intermediate values from SPA calculation (steps 1-11).
155///
156/// Pre-computed astronomical values independent of observer location.
157/// Use with [`spa_with_time_dependent_parts`] for efficient coordinate sweeps.
158#[derive(Debug, Clone)]
159#[allow(dead_code)]
160pub struct SpaTimeDependent {
161    /// Geocentric longitude (degrees)
162    pub(crate) theta_degrees: f64,
163    /// Geocentric latitude (degrees)
164    pub(crate) beta_degrees: f64,
165    /// Earth radius vector (AU)
166    pub(crate) r: f64,
167    /// Nutation in longitude (degrees)
168    pub(crate) delta_psi: f64,
169    /// True obliquity of ecliptic (degrees)
170    pub(crate) epsilon_degrees: f64,
171    /// Apparent sun longitude (degrees)
172    pub(crate) lambda_degrees: f64,
173    /// Apparent sidereal time at Greenwich (degrees)
174    pub(crate) nu_degrees: f64,
175    /// Geocentric sun right ascension (degrees)
176    pub(crate) alpha_degrees: f64,
177    /// Geocentric sun declination (degrees)
178    pub(crate) delta_degrees: f64,
179}
180
181#[derive(Debug, Clone, Copy)]
182struct DeltaPsiEpsilon {
183    delta_psi: f64,
184    delta_epsilon: f64,
185}
186
187/// Calculate L, B, R terms from the coefficient tables.
188fn calculate_lbr_terms(jme: f64, term_coeffs: &[&[&[f64; 3]]]) -> [f64; 6] {
189    // We know from coefficients that we have exactly 6 terms for L, 2 for B, and 5 for R
190    // Use a fixed-size array to avoid heap allocation
191    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
204/// Calculate L, B, or R polynomial from the terms.
205fn calculate_lbr_polynomial(jme: f64, terms: &[f64], num_terms: usize) -> f64 {
206    polynomial(&terms[..num_terms], jme) / 1e8
207}
208
209/// Calculate normalized degrees from LBR polynomial
210fn 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
216/// Calculate nutation terms (X values).
217fn calculate_nutation_terms(jce: f64) -> [f64; 5] {
218    // Use fixed-size array to avoid heap allocation
219    // NUTATION_COEFFS always has exactly 5 elements
220    [
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
229/// Calculate nutation in longitude and obliquity.
230fn 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        // Use Math.fma equivalent: a * b + c
238        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
251/// Calculate sum of X[j] * Y[i][j] for nutation.
252fn 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
260/// Calculate true obliquity of the ecliptic.
261fn 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
266/// Calculate apparent sidereal time at Greenwich.
267fn 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
289/// Calculate geocentric sun right ascension.
290fn 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
306/// Calculate geocentric sun declination.
307fn 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/// Calculate sunrise, solar transit, and sunset times using the SPA algorithm.
316///
317/// This follows the NREL SPA algorithm (Reda & Andreas 2003) for calculating
318/// sunrise, transit (solar noon), and sunset times with high accuracy.
319///
320/// # Arguments
321/// * `date` - Date for calculations
322/// * `latitude` - Observer latitude in degrees (-90 to +90)
323/// * `longitude` - Observer longitude in degrees (-180 to +180)
324/// * `delta_t` - ΔT in seconds (difference between TT and UT1)
325/// * `elevation_angle` - Sun elevation angle for sunrise/sunset in degrees (typically -0.833°)
326///
327/// # Returns
328/// `SunriseResult` variant indicating regular day, polar day, or polar night
329///
330/// # Errors
331/// Returns error for invalid coordinates (latitude outside ±90°, longitude outside ±180°)
332///
333/// # Panics
334/// May panic if date cannot be converted to start of day (unlikely with valid dates).
335///
336/// # Example
337/// ```rust
338/// use solar_positioning::spa;
339/// use chrono::{DateTime, FixedOffset, NaiveDate, TimeZone};
340///
341/// let date = FixedOffset::east_opt(-7 * 3600).unwrap() // Pacific Time (UTC-7)
342///     .from_local_datetime(&NaiveDate::from_ymd_opt(2023, 6, 21).unwrap()
343///         .and_hms_opt(0, 0, 0).unwrap()).unwrap();
344/// let result = spa::sunrise_sunset(
345///     date,
346///     37.7749,   // San Francisco latitude
347///     -122.4194, // San Francisco longitude
348///     69.0,      // deltaT (seconds)
349///     -0.833     // standard sunrise/sunset angle
350/// ).unwrap();
351#[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    // Implement the complete algorithm in one place for accuracy
365    calculate_sunrise_sunset_spa_algorithm(day_start, latitude, longitude, delta_t, elevation_angle)
366}
367
368/// Calculate sunrise/sunset times using NREL SPA algorithm Appendix A.2
369#[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    // A.2.1. Calculate the apparent sidereal time at Greenwich at 0 UT
380    let (nu_degrees, delta_psi_epsilon, epsilon_degrees) =
381        calculate_sidereal_time_and_nutation(day_start.clone());
382
383    // A.2.2. Calculate alpha/delta for day before, same day, next day
384    let alpha_deltas =
385        calculate_alpha_deltas_for_three_days(day_start, delta_psi_epsilon, epsilon_degrees)?;
386
387    // Calculate initial transit time and check for polar conditions
388    let m0 = (alpha_deltas[1].alpha - longitude - nu_degrees) / 360.0;
389
390    // Check for polar conditions but don't return early - need to calculate accurate transit
391    let polar_type = check_polar_conditions_type(latitude, elevation_angle, alpha_deltas[1].delta);
392
393    // Calculate approximate times and apply corrections (needed for accurate transit time)
394    let (m_values, _h0_degrees) =
395        calculate_approximate_times(m0, latitude, elevation_angle, alpha_deltas[1].delta);
396
397    // Apply final corrections to get accurate times
398    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    // Convert to appropriate polar result type if needed
410    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// ============================================================================
468// Sunrise/sunset helper functions below
469// All marked #[cfg(feature = "chrono")] as they're only used by chrono-based APIs
470// ============================================================================
471
472/// Enum for polar condition types
473#[cfg(feature = "chrono")]
474#[derive(Debug, Clone, Copy)]
475enum PolarType {
476    AllDay,
477    AllNight,
478}
479
480/// Check for polar day/night conditions and return the type
481#[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/// Check for polar day/night conditions and construct `SunriseResult`
504#[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/// A.2.5-6. Calculate approximate times for transit, sunrise, sunset
522#[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/// A.2.8-15. Calculate final accurate times using corrections
547#[cfg(feature = "chrono")]
548fn calculate_final_times<Tz: TimeZone>(
549    params: FinalTimeParams<Tz>,
550) -> crate::SunriseResult<DateTime<Tz>> {
551    // A.2.8. Calculate sidereal times
552    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    // A.2.9. Calculate terms with deltaT correction
558    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    // A.2.10. Calculate α'i and δ'i using interpolation
564    let alpha_delta_primes = calculate_interpolated_alpha_deltas(&params.alpha_deltas, &n);
565
566    // A.2.11. Calculate local hour angles
567    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    // A.2.12. Calculate sun altitudes
574    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    // A.2.13-15. Calculate final times
586    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/// A.2.10. Calculate interpolated alpha/delta values
612#[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/// Parameters for calculating final sunrise/sunset times
647#[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/// Calculate sunrise, solar transit, and sunset times for a specific horizon type.
661///
662/// This is a convenience function that uses predefined elevation angles for common
663/// sunrise/twilight calculations.
664///
665/// # Arguments
666/// * `date` - Date for calculations
667/// * `latitude` - Observer latitude in degrees (-90 to +90)
668/// * `longitude` - Observer longitude in degrees (-180 to +180)
669/// * `delta_t` - ΔT in seconds (difference between TT and UT1)
670/// * `horizon` - Horizon type (sunrise/sunset, civil twilight, etc.)
671///
672/// # Errors
673/// Returns error for invalid coordinates, dates, or computation failures.
674///
675/// # Panics
676/// May panic if date cannot be converted to start of day (unlikely with valid dates).
677///
678/// # Example
679/// ```rust
680/// use solar_positioning::{spa, Horizon};
681/// use chrono::{FixedOffset, NaiveDate, TimeZone};
682///
683/// let date = FixedOffset::east_opt(-7 * 3600).unwrap() // Pacific Time (UTC-7)
684///     .from_local_datetime(&NaiveDate::from_ymd_opt(2023, 6, 21).unwrap()
685///         .and_hms_opt(0, 0, 0).unwrap()).unwrap();
686///
687/// // Standard sunrise/sunset
688/// let sunrise_result = spa::sunrise_sunset_for_horizon(
689///     date, 37.7749, -122.4194, 69.0, Horizon::SunriseSunset
690/// ).unwrap();
691///
692/// // Civil twilight
693/// let twilight_result = spa::sunrise_sunset_for_horizon(
694///     date, 37.7749, -122.4194, 69.0, Horizon::CivilTwilight
695/// ).unwrap();
696/// ```
697#[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/// Calculate alpha (right ascension) and delta (declination) for a given JME using full SPA algorithm
715/// Following NREL SPA Algorithm Section 3.2-3.8 for sunrise/sunset calculations
716#[cfg(feature = "chrono")]
717fn calculate_alpha_delta(jme: f64, delta_psi: f64, epsilon_degrees: f64) -> AlphaDelta {
718    // Follow Java calculateAlphaDelta exactly
719
720    // 3.2.3. Calculate Earth heliocentric latitude, B
721    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    // 3.2.4. Calculate Earth radius vector, R
725    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    // 3.2.2. Calculate Earth heliocentric longitude, L
733    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    // 3.2.5. Calculate geocentric longitude, theta
737    let theta_degrees = normalize_degrees_0_to_360(l_degrees + 180.0);
738
739    // 3.2.6. Calculate geocentric latitude, beta
740    let beta_degrees = -b_degrees;
741    let beta = degrees_to_radians(beta_degrees);
742    let epsilon = degrees_to_radians(epsilon_degrees);
743
744    // 3.5. Calculate aberration correction
745    let delta_tau = ABERRATION_CONSTANT / (SECONDS_PER_HOUR * r);
746
747    // 3.6. Calculate the apparent sun longitude
748    let lambda_degrees = theta_degrees + delta_psi + delta_tau;
749    let lambda = degrees_to_radians(lambda_degrees);
750
751    // 3.8.1-3.8.2. Calculate the geocentric sun right ascension and declination
752    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/// Normalize value to [0, 1) range using the same logic as the removed `limit_to` function
763#[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    // Match Java implementation exactly:
792    // 1. Truncate to start of day (like Java's truncatedTo(ChronoUnit.DAYS))
793    // 2. Calculate milliseconds as int (truncating fractional milliseconds)
794    // 3. Add the milliseconds
795    const MS_PER_DAY: i32 = 24 * 60 * 60 * 1000; // Use i32 like Java
796    let millis_plus = (f64::from(MS_PER_DAY) * fraction) as i32; // Cast to i32 like Java
797
798    let day_start = truncate_to_day_start(&day);
799
800    day_start + chrono::Duration::milliseconds(i64::from(millis_plus))
801}
802
803/// Limit to 0..1 if absolute value > 2 (Java limitIfNecessary)
804#[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/// Limit H' values according to A.2.11
814#[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/// Calculate sunrise/sunset times for multiple horizons efficiently.
829///
830/// Returns an iterator that yields `(Horizon, SunriseResult)` pairs. This is more
831/// efficient than separate calls as it reuses expensive astronomical calculations.
832///
833/// # Arguments
834/// * `date` - Date for calculations
835/// * `latitude` - Observer latitude in degrees (-90 to +90)
836/// * `longitude` - Observer longitude in degrees (-180 to +180)
837/// * `delta_t` - ΔT in seconds (difference between TT and UT1)
838/// * `horizons` - Iterator of horizon types to calculate
839///
840/// # Returns
841/// Iterator over `Result<(Horizon, SunriseResult)>`
842///
843/// # Errors
844/// Returns error for invalid coordinates (latitude outside ±90°, longitude outside ±180°)
845///
846/// # Panics
847/// May panic if date cannot be converted to start of day (unlikely with valid dates).
848///
849/// # Example
850/// ```rust
851/// use solar_positioning::{spa, Horizon};
852/// use chrono::{DateTime, FixedOffset};
853///
854/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
855/// let datetime = "2023-06-21T12:00:00-07:00".parse::<DateTime<FixedOffset>>().unwrap();
856/// let horizons = [
857///     Horizon::SunriseSunset,
858///     Horizon::CivilTwilight,
859///     Horizon::NauticalTwilight,
860/// ];
861///
862/// let results: Result<Vec<_>, _> = spa::sunrise_sunset_multiple(
863///     datetime,
864///     37.7749,     // San Francisco latitude
865///     -122.4194,   // San Francisco longitude
866///     69.0,        // deltaT (seconds)
867///     horizons.iter().copied()
868/// ).collect();
869///
870/// for (horizon, result) in results? {
871///     println!("{:?}: {:?}", horizon, result);
872/// }
873/// # Ok(())
874/// # }
875/// ```
876#[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    // Pre-calculate common values once for efficiency
889    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    // Calculate initial transit time and check for polar conditions
931    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    // Calculate approximate times and apply corrections
943    let (m_values, _h0_degrees) =
944        calculate_approximate_times(m0, latitude, elevation_angle, alpha_deltas[1].delta);
945
946    // Apply final corrections to get accurate times
947    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/// Extract expensive time-dependent parts of SPA calculation (steps 1-11).
960///
961/// This function calculates the expensive astronomical quantities that are independent
962/// of observer location. Typically used for coordinate sweeps (many locations at fixed
963/// time).
964///
965/// # Arguments
966/// * `datetime` - Date and time with timezone
967/// * `delta_t` - ΔT in seconds (difference between TT and UT1)
968///
969/// # Returns
970/// Pre-computed time-dependent values for SPA calculations
971///
972/// # Performance
973///
974/// Use this with [`spa_with_time_dependent_parts`] for coordinate sweeps:
975/// ```rust
976/// use solar_positioning::spa;
977/// use chrono::{DateTime, Utc};
978///
979/// let datetime = "2023-06-21T12:00:00Z".parse::<DateTime<Utc>>().unwrap();
980/// let shared_parts = spa::spa_time_dependent_parts(datetime, 69.0)?;
981///
982/// for lat in -60..=60 {
983///     for lon in -180..=179 {
984///         let pos = spa::spa_with_time_dependent_parts(
985///             lat as f64, lon as f64, 0.0, None, &shared_parts
986///         )?;
987///     }
988/// }
989/// # Ok::<(), Box<dyn std::error::Error>>(())
990/// ```
991///
992/// # Errors
993/// Returns error if Julian date calculation fails for the provided datetime
994///
995/// # Panics
996#[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
1006/// Calculate time-dependent parts of SPA from a Julian date.
1007///
1008/// Core implementation for `no_std` compatibility.
1009///
1010/// # Errors
1011/// Returns error if Julian date is invalid.
1012///
1013/// # Panics
1014/// Panics if Earth radius vector is zero (astronomical impossibility).
1015pub 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    // 3.2.2. Calculate the Earth heliocentric longitude, L (in degrees)
1020    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    // 3.2.3. Calculate the Earth heliocentric latitude, B (in degrees)
1024    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    // 3.2.4. Calculate the Earth radius vector, R (in Astronomical Units, AU)
1028    let r_terms = calculate_lbr_terms(jme, TERMS_R);
1029    let r = calculate_lbr_polynomial(jme, &r_terms, TERMS_R.len());
1030
1031    // Earth's radius vector should never be zero (would mean Earth at center of Sun)
1032    assert!(
1033        r != 0.0,
1034        "Earth radius vector is zero - astronomical impossibility"
1035    );
1036
1037    // 3.2.5. Calculate the geocentric longitude, theta (in degrees)
1038    let theta_degrees = normalize_degrees_0_to_360(l_degrees + 180.0);
1039    // 3.2.6. Calculate the geocentric latitude, beta (in degrees)
1040    let beta_degrees = -b_degrees;
1041
1042    // 3.3. Calculate the nutation in longitude and obliquity
1043    let x_terms = calculate_nutation_terms(jce);
1044    let delta_psi_epsilon = calculate_delta_psi_epsilon(jce, &x_terms);
1045
1046    // 3.4. Calculate the true obliquity of the ecliptic, epsilon (in degrees)
1047    let epsilon_degrees =
1048        calculate_true_obliquity_of_ecliptic(&jd, delta_psi_epsilon.delta_epsilon);
1049
1050    // 3.5. Calculate the aberration correction, delta_tau (in degrees)
1051    let delta_tau = ABERRATION_CONSTANT / (SECONDS_PER_HOUR * r);
1052
1053    // 3.6. Calculate the apparent sun longitude, lambda (in degrees)
1054    let lambda_degrees = theta_degrees + delta_psi_epsilon.delta_psi + delta_tau;
1055
1056    // 3.7. Calculate the apparent sidereal time at Greenwich at any given time, nu (in degrees)
1057    let nu_degrees = calculate_apparent_sidereal_time_at_greenwich(
1058        &jd,
1059        delta_psi_epsilon.delta_psi,
1060        epsilon_degrees,
1061    );
1062
1063    // 3.8.1. Calculate the geocentric sun right ascension, alpha (in degrees)
1064    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    // 3.8.2. Calculate the geocentric sun declination, delta (in degrees)
1070    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
1086/// Complete SPA calculation using pre-computed time-dependent parts (steps 12+).
1087///
1088/// This function completes the SPA calculation using cached intermediate values
1089/// from [`spa_time_dependent_parts`]. Used together, these provide significant
1090/// speedup for coordinate sweeps with unchanged accuracy.
1091///
1092/// # Arguments
1093/// * `latitude` - Observer latitude in degrees (-90 to +90)
1094/// * `longitude` - Observer longitude in degrees (-180 to +180)
1095/// * `elevation` - Observer elevation above sea level in meters
1096/// * `refraction` - Optional atmospheric refraction correction
1097/// * `time_dependent` - Pre-computed time-dependent calculations from [`spa_time_dependent_parts`]
1098///
1099/// # Returns
1100/// Solar position or error
1101///
1102/// # Errors
1103/// Returns error for invalid coordinates (latitude outside ±90°, longitude outside ±180°)
1104///
1105/// # Example
1106/// ```rust
1107/// use solar_positioning::{spa, RefractionCorrection};
1108/// use chrono::{DateTime, Utc};
1109///
1110/// let datetime = "2023-06-21T12:00:00Z".parse::<DateTime<Utc>>().unwrap();
1111/// let time_parts = spa::spa_time_dependent_parts(datetime, 69.0).unwrap();
1112///
1113/// let position = spa::spa_with_time_dependent_parts(
1114///     37.7749,   // San Francisco latitude
1115///     -122.4194, // San Francisco longitude
1116///     0.0,       // elevation (meters)
1117///     Some(RefractionCorrection::standard()),
1118///     &time_parts
1119/// ).unwrap();
1120///
1121/// println!("Azimuth: {:.3}°", position.azimuth());
1122/// ```
1123pub 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    // 3.9. Calculate the observer local hour angle, H (in degrees)
1133    // Use pre-computed apparent sidereal time from time_dependent parts
1134    let nu_degrees = time_dependent.nu_degrees;
1135
1136    // Use pre-computed geocentric sun right ascension and declination
1137    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    // 3.10-3.11. Calculate the topocentric sun coordinates
1142    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    // 3.12. Calculate the topocentric local hour angle, H' (in degrees)
1166    let h_prime_degrees = h_degrees - delta_alpha_prime_degrees;
1167
1168    // 3.13. Calculate the topocentric zenith and azimuth angles
1169    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    // 3.14. Calculate the topocentric azimuth angle
1178    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    // Apply atmospheric refraction if requested
1188    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            // Apply refraction correction following the same pattern as calculate_topocentric_zenith_angle
1194            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, // San Francisco
1223            -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,   // San Francisco latitude
1249            -122.4194, // San Francisco longitude
1250            69.0,      // deltaT (seconds)
1251            horizons.iter().copied(),
1252        )
1253        .collect();
1254
1255        let results = results.unwrap();
1256
1257        // Should have results for all requested horizons
1258        assert_eq!(results.len(), 3);
1259
1260        // Check that we have all expected horizons
1261        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        // Compare with individual calls to ensure consistency
1267        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            // Results should be identical
1272            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        // Invalid latitude
1325        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        // Invalid longitude
1336        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}