solar_positioning/spa/
mod.rs

1//! SPA (Solar Position Algorithm) implementation.
2//!
3//! High-accuracy solar positioning based on the NREL algorithm by Reda & Andreas (2003).
4//! Provides uncertainties of ±0.0003 degrees 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;
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
27/// Standard sunrise/sunset elevation angle (accounts for refraction and sun's radius).
28const SUNRISE_SUNSET_ANGLE: f64 = -0.83337;
29
30/// Aberration constant in arcseconds.
31const ABERRATION_CONSTANT: f64 = -20.4898;
32
33/// Earth flattening factor (WGS84).
34const EARTH_FLATTENING_FACTOR: f64 = 0.99664719;
35
36/// Earth radius in meters (WGS84).
37const EARTH_RADIUS_METERS: f64 = 6378140.0;
38
39/// Seconds per hour conversion factor.
40const SECONDS_PER_HOUR: f64 = 3600.0;
41
42/// Calculate solar position using the SPA algorithm.
43///
44/// # Arguments
45/// * `datetime` - Date and time with timezone
46/// * `latitude` - Observer latitude in degrees (-90 to +90)
47/// * `longitude` - Observer longitude in degrees (-180 to +180)
48/// * `elevation` - Observer elevation in meters above sea level
49/// * `delta_t` - ΔT in seconds (difference between TT and UT1)
50/// * `refraction` - Optional atmospheric refraction correction
51///
52/// # Returns
53/// Solar position or error
54///
55/// # Errors
56/// Returns error for invalid coordinates (latitude outside ±90°, longitude outside ±180°)
57///
58/// # Example
59/// ```rust
60/// use solar_positioning::{spa, RefractionCorrection};
61/// use chrono::{DateTime, FixedOffset};
62///
63/// let datetime = "2023-06-21T12:00:00-07:00".parse::<DateTime<FixedOffset>>().unwrap();
64///
65/// // With atmospheric refraction correction
66/// let position = spa::solar_position(
67///     datetime,
68///     37.7749,     // San Francisco latitude
69///     -122.4194,   // San Francisco longitude
70///     0.0,         // elevation (meters)
71///     69.0,        // deltaT (seconds)
72///     Some(RefractionCorrection::standard()),
73/// ).unwrap();
74///
75/// // Without refraction correction
76/// let position_no_refraction = spa::solar_position(
77///     datetime,
78///     37.7749,
79///     -122.4194,
80///     0.0,
81///     69.0,
82///     None,
83/// ).unwrap();
84///
85/// println!("Azimuth: {:.3}°", position.azimuth());
86/// println!("Elevation: {:.3}°", position.elevation_angle());
87/// ```
88#[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    // Convenience wrapper that calls the split functions in sequence
98    let time_dependent = spa_time_dependent_parts(datetime.clone(), delta_t)?;
99    spa_with_time_dependent_parts(
100        datetime,
101        latitude,
102        longitude,
103        elevation,
104        delta_t,
105        refraction,
106        &time_dependent,
107    )
108}
109
110/// Represents nutation corrections for longitude and obliquity.
111/// Time-dependent intermediate values from SPA calculation (steps 1-11).
112#[derive(Debug, Clone)]
113pub struct SpaTimeDependent {
114    /// Geocentric longitude (degrees)
115    pub theta_degrees: f64,
116    /// Geocentric latitude (degrees)
117    pub beta_degrees: f64,
118    /// Earth radius vector (AU)
119    pub r: f64,
120    /// Nutation in longitude (degrees)
121    pub delta_psi: f64,
122    /// True obliquity of ecliptic (degrees)
123    pub epsilon_degrees: f64,
124    /// Apparent sun longitude (degrees)
125    pub lambda_degrees: f64,
126    /// Apparent sidereal time at Greenwich (degrees)
127    pub nu_degrees: f64,
128    /// Geocentric sun right ascension (degrees)
129    pub alpha_degrees: f64,
130    /// Geocentric sun declination (degrees)
131    pub delta_degrees: f64,
132}
133
134#[derive(Debug, Clone, Copy)]
135struct DeltaPsiEpsilon {
136    delta_psi: f64,
137    delta_epsilon: f64,
138}
139
140/// Calculate L, B, R terms from the coefficient tables.
141fn calculate_lbr_terms(jme: f64, term_coeffs: &[&[&[f64; 3]]]) -> Vec<f64> {
142    let mut lbr_terms = vec![0.0; term_coeffs.len()];
143
144    for (i, term_set) in term_coeffs.iter().enumerate() {
145        let mut lbr_sum = 0.0;
146        for term in *term_set {
147            lbr_sum += term[0] * cos(term[2].mul_add(jme, term[1]));
148        }
149        lbr_terms[i] = lbr_sum;
150    }
151
152    lbr_terms
153}
154
155/// Calculate L, B, or R polynomial from the terms.
156fn calculate_lbr_polynomial(jme: f64, terms: &[f64]) -> f64 {
157    polynomial(terms, jme) / 1e8
158}
159
160/// Calculate nutation terms (X values).
161fn calculate_nutation_terms(jce: f64) -> [f64; 5] {
162    // Use fixed-size array to avoid heap allocation
163    // NUTATION_COEFFS always has exactly 5 elements
164    [
165        polynomial(NUTATION_COEFFS[0], jce),
166        polynomial(NUTATION_COEFFS[1], jce),
167        polynomial(NUTATION_COEFFS[2], jce),
168        polynomial(NUTATION_COEFFS[3], jce),
169        polynomial(NUTATION_COEFFS[4], jce),
170    ]
171}
172
173/// Calculate nutation in longitude and obliquity.
174fn calculate_delta_psi_epsilon(jce: f64, x: &[f64]) -> DeltaPsiEpsilon {
175    let mut delta_psi = 0.0;
176    let mut delta_epsilon = 0.0;
177
178    for (i, pe_term) in TERMS_PE.iter().enumerate() {
179        let xj_yterm_sum = degrees_to_radians(calculate_xj_yterm_sum(i, x));
180
181        // Use Math.fma equivalent: a * b + c
182        let delta_psi_contrib = pe_term[1].mul_add(jce, pe_term[0]) * sin(xj_yterm_sum);
183        let delta_epsilon_contrib = pe_term[3].mul_add(jce, pe_term[2]) * cos(xj_yterm_sum);
184
185        delta_psi += delta_psi_contrib;
186        delta_epsilon += delta_epsilon_contrib;
187    }
188
189    DeltaPsiEpsilon {
190        delta_psi: delta_psi / 36_000_000.0,
191        delta_epsilon: delta_epsilon / 36_000_000.0,
192    }
193}
194
195/// Calculate sum of X[j] * Y[i][j] for nutation.
196fn calculate_xj_yterm_sum(i: usize, x: &[f64]) -> f64 {
197    let mut sum = 0.0;
198    for (j, &x_val) in x.iter().enumerate() {
199        sum += x_val * f64::from(TERMS_Y[i][j]);
200    }
201    sum
202}
203
204/// Calculate true obliquity of the ecliptic.
205fn calculate_true_obliquity_of_ecliptic(jd: &JulianDate, delta_epsilon: f64) -> f64 {
206    let epsilon0 = polynomial(OBLIQUITY_COEFFS, jd.julian_ephemeris_millennium() / 10.0);
207    epsilon0 / 3600.0 + delta_epsilon
208}
209
210/// Calculate apparent sidereal time at Greenwich.
211fn calculate_apparent_sidereal_time_at_greenwich(
212    jd: &JulianDate,
213    delta_psi: f64,
214    epsilon_degrees: f64,
215) -> f64 {
216    let nu0_degrees = normalize_degrees_0_to_360(jd.julian_century().powi(2).mul_add(
217        0.000387933 - jd.julian_century() / 38710000.0,
218        360.98564736629f64.mul_add(jd.julian_date() - 2451545.0, 280.46061837),
219    ));
220
221    delta_psi.mul_add(cos(degrees_to_radians(epsilon_degrees)), nu0_degrees)
222}
223
224/// Calculate geocentric sun right ascension.
225fn calculate_geocentric_sun_right_ascension(
226    beta_rad: f64,
227    epsilon_rad: f64,
228    lambda_rad: f64,
229) -> f64 {
230    let alpha = atan2(
231        sin(lambda_rad).mul_add(cos(epsilon_rad), -(tan(beta_rad) * sin(epsilon_rad))),
232        cos(lambda_rad),
233    );
234    normalize_degrees_0_to_360(radians_to_degrees(alpha))
235}
236
237/// Calculate geocentric sun declination.
238fn calculate_geocentric_sun_declination(beta_rad: f64, epsilon_rad: f64, lambda_rad: f64) -> f64 {
239    asin(sin(beta_rad).mul_add(
240        cos(epsilon_rad),
241        cos(beta_rad) * sin(epsilon_rad) * sin(lambda_rad),
242    ))
243}
244
245/// Calculate sunrise, solar transit, and sunset times using the SPA algorithm.
246///
247/// This follows the NREL SPA algorithm (Reda & Andreas 2003) for calculating
248/// sunrise, transit (solar noon), and sunset times with high accuracy.
249///
250/// # Arguments
251/// * `date` - UTC date for calculations (time is ignored, calculations are for the entire day)
252/// * `latitude` - Observer latitude in degrees (-90 to +90)
253/// * `longitude` - Observer longitude in degrees (-180 to +180)
254/// * `delta_t` - ΔT in seconds (difference between TT and UT1)
255/// * `elevation_angle` - Sun elevation angle for sunrise/sunset in degrees (typically -0.833°)
256///
257/// # Returns
258/// `SunriseResult` variant indicating regular day, polar day, or polar night
259///
260/// # Errors
261/// Returns error for invalid coordinates (latitude outside ±90°, longitude outside ±180°)
262///
263/// # Panics
264/// May panic if date conversion fails (should not occur for valid UTC dates)
265///
266/// # Example
267/// ```rust
268/// use solar_positioning::spa;
269/// use chrono::{DateTime, FixedOffset, NaiveDate, TimeZone};
270///
271/// let date = FixedOffset::east_opt(-7 * 3600).unwrap() // Pacific Time (UTC-7)
272///     .from_local_datetime(&NaiveDate::from_ymd_opt(2023, 6, 21).unwrap()
273///         .and_hms_opt(0, 0, 0).unwrap()).unwrap();
274/// let result = spa::sunrise_sunset(
275///     date,
276///     37.7749,   // San Francisco latitude
277///     -122.4194, // San Francisco longitude
278///     69.0,      // deltaT (seconds)
279///     -0.833     // standard sunrise/sunset angle
280/// ).unwrap();
281/// ```
282#[allow(clippy::needless_pass_by_value)]
283pub fn sunrise_sunset<Tz: TimeZone>(
284    date: DateTime<Tz>,
285    latitude: f64,
286    longitude: f64,
287    delta_t: f64,
288    elevation_angle: f64,
289) -> Result<crate::SunriseResult<DateTime<Tz>>> {
290    check_coordinates(latitude, longitude)?;
291
292    // Create Julian date for the day using the original timezone
293    let day_start = date
294        .timezone()
295        .from_local_datetime(&date.date_naive().and_hms_opt(0, 0, 0).unwrap())
296        .unwrap();
297
298    // Implement the complete algorithm in one place for accuracy
299    calculate_sunrise_sunset_spa_algorithm(day_start, latitude, longitude, delta_t, elevation_angle)
300}
301
302/// Calculate sunrise/sunset times using NREL SPA algorithm Appendix A.2
303fn calculate_sunrise_sunset_spa_algorithm<Tz: TimeZone>(
304    day: DateTime<Tz>,
305    latitude: f64,
306    longitude: f64,
307    delta_t: f64,
308    elevation_angle: f64,
309) -> Result<crate::SunriseResult<DateTime<Tz>>> {
310    let day_start = day
311        .timezone()
312        .from_local_datetime(&day.date_naive().and_hms_opt(0, 0, 0).unwrap())
313        .unwrap();
314
315    // A.2.1. Calculate the apparent sidereal time at Greenwich at 0 UT
316    let (nu_degrees, delta_psi_epsilon, epsilon_degrees) =
317        calculate_sidereal_time_and_nutation(day_start.clone());
318
319    // A.2.2. Calculate alpha/delta for day before, same day, next day
320    let alpha_deltas =
321        calculate_alpha_deltas_for_three_days(day_start, delta_psi_epsilon, epsilon_degrees)?;
322
323    // Calculate initial transit time and check for polar conditions
324    let m0 = (alpha_deltas[1].alpha - longitude - nu_degrees) / 360.0;
325
326    // Check for polar conditions but don't return early - need to calculate accurate transit
327    let polar_type = check_polar_conditions_type(latitude, elevation_angle, alpha_deltas[1].delta);
328
329    // Calculate approximate times and apply corrections (needed for accurate transit time)
330    let (m_values, _h0_degrees) =
331        calculate_approximate_times(m0, latitude, elevation_angle, alpha_deltas[1].delta);
332
333    // Apply final corrections to get accurate times
334    let final_result = calculate_final_times(FinalTimeParams {
335        day,
336        m_values,
337        nu_degrees,
338        delta_t,
339        latitude,
340        longitude,
341        elevation_angle,
342        alpha_deltas,
343    });
344
345    // Convert to appropriate polar result type if needed
346    match polar_type {
347        Some(PolarType::AllDay) => {
348            if let crate::SunriseResult::RegularDay { transit, .. } = final_result {
349                Ok(crate::SunriseResult::AllDay { transit })
350            } else {
351                Ok(final_result)
352            }
353        }
354        Some(PolarType::AllNight) => {
355            if let crate::SunriseResult::RegularDay { transit, .. } = final_result {
356                Ok(crate::SunriseResult::AllNight { transit })
357            } else {
358                Ok(final_result)
359            }
360        }
361        None => Ok(final_result),
362    }
363}
364
365/// A.2.1. Calculate apparent sidereal time and nutation parameters
366#[allow(clippy::needless_pass_by_value)]
367fn calculate_sidereal_time_and_nutation<Tz: TimeZone>(
368    day_start: DateTime<Tz>,
369) -> (f64, DeltaPsiEpsilon, f64) {
370    let jd = JulianDate::from_datetime(&day_start, 0.0).unwrap();
371    let jce_day = jd.julian_ephemeris_century();
372    let x_terms = calculate_nutation_terms(jce_day);
373    let delta_psi_epsilon = calculate_delta_psi_epsilon(jce_day, &x_terms);
374    let epsilon_degrees =
375        calculate_true_obliquity_of_ecliptic(&jd, delta_psi_epsilon.delta_epsilon);
376    let nu_degrees = calculate_apparent_sidereal_time_at_greenwich(
377        &jd,
378        delta_psi_epsilon.delta_psi,
379        epsilon_degrees,
380    );
381    (nu_degrees, delta_psi_epsilon, epsilon_degrees)
382}
383
384/// A.2.2. Calculate alpha/delta for day before, same day, and next day
385#[allow(clippy::needless_pass_by_value)]
386fn calculate_alpha_deltas_for_three_days<Tz: TimeZone>(
387    day_start: DateTime<Tz>,
388    delta_psi_epsilon: DeltaPsiEpsilon,
389    epsilon_degrees: f64,
390) -> Result<[AlphaDelta; 3]> {
391    let mut alpha_deltas = [AlphaDelta {
392        alpha: 0.0,
393        delta: 0.0,
394    }; 3];
395    for (i, alpha_delta) in alpha_deltas.iter_mut().enumerate() {
396        let current_jd = JulianDate::from_datetime(&day_start, 0.0)?.add_days((i as f64) - 1.0);
397        let current_jme = current_jd.julian_ephemeris_millennium();
398        let ad = calculate_alpha_delta(current_jme, delta_psi_epsilon.delta_psi, epsilon_degrees);
399        *alpha_delta = ad;
400    }
401    Ok(alpha_deltas)
402}
403
404/// Check for polar day/night conditions
405fn check_polar_conditions<Tz: TimeZone>(
406    day: DateTime<Tz>,
407    m0: f64,
408    latitude: f64,
409    elevation_angle: f64,
410    delta1: f64,
411) -> Option<crate::SunriseResult<DateTime<Tz>>> {
412    let phi = degrees_to_radians(latitude);
413    let delta1_rad = degrees_to_radians(delta1);
414    let elevation_rad = degrees_to_radians(elevation_angle);
415
416    let acos_arg =
417        sin(phi).mul_add(-sin(delta1_rad), sin(elevation_rad)) / (cos(phi) * cos(delta1_rad));
418
419    if acos_arg < -1.0 {
420        let transit = add_fraction_of_day(day, m0);
421        Some(crate::SunriseResult::AllDay { transit })
422    } else if acos_arg > 1.0 {
423        let transit = add_fraction_of_day(day, m0);
424        Some(crate::SunriseResult::AllNight { transit })
425    } else {
426        None
427    }
428}
429
430/// Enum for polar condition types
431#[derive(Debug, Clone, Copy)]
432enum PolarType {
433    AllDay,
434    AllNight,
435}
436
437/// Check for polar day/night conditions and return the type (but don't return early)
438fn check_polar_conditions_type(
439    latitude: f64,
440    elevation_angle: f64,
441    delta1: f64,
442) -> Option<PolarType> {
443    let phi = degrees_to_radians(latitude);
444    let elevation_rad = degrees_to_radians(elevation_angle);
445    let delta1_rad = degrees_to_radians(delta1);
446
447    let acos_arg =
448        sin(phi).mul_add(-sin(delta1_rad), sin(elevation_rad)) / (cos(phi) * cos(delta1_rad));
449
450    if acos_arg < -1.0 {
451        Some(PolarType::AllDay)
452    } else if acos_arg > 1.0 {
453        Some(PolarType::AllNight)
454    } else {
455        None
456    }
457}
458
459/// A.2.5-6. Calculate approximate times for transit, sunrise, sunset
460fn calculate_approximate_times(
461    m0: f64,
462    latitude: f64,
463    elevation_angle: f64,
464    delta1: f64,
465) -> ([f64; 3], f64) {
466    let phi = degrees_to_radians(latitude);
467    let delta1_rad = degrees_to_radians(delta1);
468    let elevation_rad = degrees_to_radians(elevation_angle);
469
470    let acos_arg =
471        sin(phi).mul_add(-sin(delta1_rad), sin(elevation_rad)) / (cos(phi) * cos(delta1_rad));
472    let h0 = acos(acos_arg);
473    let h0_degrees = radians_to_degrees(h0).min(180.0);
474
475    let mut m = [0.0; 3];
476    m[0] = normalize_to_unit_range(m0);
477    m[1] = normalize_to_unit_range(m0 - h0_degrees / 360.0);
478    m[2] = normalize_to_unit_range(m0 + h0_degrees / 360.0);
479
480    (m, h0_degrees)
481}
482
483/// A.2.8-15. Calculate final accurate times using corrections
484fn calculate_final_times<Tz: TimeZone>(
485    params: FinalTimeParams<Tz>,
486) -> crate::SunriseResult<DateTime<Tz>> {
487    // A.2.8. Calculate sidereal times
488    let mut nu = [0.0; 3];
489    for (i, nu_item) in nu.iter_mut().enumerate() {
490        *nu_item = 360.985647f64.mul_add(params.m_values[i], params.nu_degrees);
491    }
492
493    // A.2.9. Calculate terms with deltaT correction
494    let mut n = [0.0; 3];
495    for (i, n_item) in n.iter_mut().enumerate() {
496        *n_item = params.m_values[i] + params.delta_t / 86400.0;
497    }
498
499    // A.2.10. Calculate α'i and δ'i using interpolation
500    let alpha_delta_primes = calculate_interpolated_alpha_deltas(&params.alpha_deltas, &n);
501
502    // A.2.11. Calculate local hour angles
503    let mut h_prime = [0.0; 3];
504    for i in 0..3 {
505        let h_prime_i = nu[i] + params.longitude - alpha_delta_primes[i].alpha;
506        h_prime[i] = limit_h_prime(h_prime_i);
507    }
508
509    // A.2.12. Calculate sun altitudes
510    let phi = degrees_to_radians(params.latitude);
511    let mut h = [0.0; 3];
512    for i in 0..3 {
513        let delta_prime_rad = degrees_to_radians(alpha_delta_primes[i].delta);
514        h[i] = radians_to_degrees(asin(sin(phi).mul_add(
515            sin(delta_prime_rad),
516            cos(phi) * cos(delta_prime_rad) * cos(degrees_to_radians(h_prime[i])),
517        )));
518    }
519
520    // A.2.13-15. Calculate final times
521    let t = params.m_values[0] - h_prime[0] / 360.0;
522    let r = params.m_values[1]
523        + (h[1] - params.elevation_angle)
524            / (360.0
525                * cos(degrees_to_radians(alpha_delta_primes[1].delta))
526                * cos(phi)
527                * sin(degrees_to_radians(h_prime[1])));
528    let s = params.m_values[2]
529        + (h[2] - params.elevation_angle)
530            / (360.0
531                * cos(degrees_to_radians(alpha_delta_primes[2].delta))
532                * cos(phi)
533                * sin(degrees_to_radians(h_prime[2])));
534
535    let sunrise = add_fraction_of_day(params.day.clone(), r);
536    let transit = add_fraction_of_day(params.day.clone(), t);
537    let sunset = add_fraction_of_day(params.day, s);
538
539    crate::SunriseResult::RegularDay {
540        sunrise,
541        transit,
542        sunset,
543    }
544}
545
546/// A.2.10. Calculate interpolated alpha/delta values
547fn calculate_interpolated_alpha_deltas(
548    alpha_deltas: &[AlphaDelta; 3],
549    n: &[f64; 3],
550) -> [AlphaDelta; 3] {
551    let a = limit_if_necessary(alpha_deltas[1].alpha - alpha_deltas[0].alpha);
552    let a_prime = limit_if_necessary(alpha_deltas[1].delta - alpha_deltas[0].delta);
553
554    let b = limit_if_necessary(alpha_deltas[2].alpha - alpha_deltas[1].alpha);
555    let b_prime = limit_if_necessary(alpha_deltas[2].delta - alpha_deltas[1].delta);
556
557    let c = b - a;
558    let c_prime = b_prime - a_prime;
559
560    let mut alpha_delta_primes = [AlphaDelta {
561        alpha: 0.0,
562        delta: 0.0,
563    }; 3];
564    for i in 0..3 {
565        alpha_delta_primes[i].alpha =
566            alpha_deltas[1].alpha + (n[i] * (c.mul_add(n[i], a + b))) / 2.0;
567        alpha_delta_primes[i].delta =
568            alpha_deltas[1].delta + (n[i] * (c_prime.mul_add(n[i], a_prime + b_prime))) / 2.0;
569    }
570    alpha_delta_primes
571}
572
573#[derive(Debug, Clone, Copy)]
574struct AlphaDelta {
575    alpha: f64,
576    delta: f64,
577}
578
579/// Parameters for calculating final sunrise/sunset times
580#[derive(Debug, Clone)]
581struct FinalTimeParams<Tz: TimeZone> {
582    day: DateTime<Tz>,
583    m_values: [f64; 3],
584    nu_degrees: f64,
585    delta_t: f64,
586    latitude: f64,
587    longitude: f64,
588    elevation_angle: f64,
589    alpha_deltas: [AlphaDelta; 3],
590}
591
592/// Calculate sunrise, solar transit, and sunset times for a specific horizon type.
593///
594/// This is a convenience function that uses predefined elevation angles for common
595/// sunrise/twilight calculations.
596///
597/// # Arguments
598/// * `date` - UTC date for calculations
599/// * `latitude` - Observer latitude in degrees
600/// * `longitude` - Observer longitude in degrees
601/// * `delta_t` - ΔT in seconds
602/// * `horizon` - Horizon type (sunrise/sunset, civil twilight, etc.)
603///
604/// # Errors
605/// Returns error for invalid coordinates, dates, or computation failures.
606///
607/// # Example
608/// ```rust
609/// use solar_positioning::{spa, Horizon};
610/// use chrono::{FixedOffset, NaiveDate, TimeZone};
611///
612/// let date = FixedOffset::east_opt(-7 * 3600).unwrap() // Pacific Time (UTC-7)
613///     .from_local_datetime(&NaiveDate::from_ymd_opt(2023, 6, 21).unwrap()
614///         .and_hms_opt(0, 0, 0).unwrap()).unwrap();
615///
616/// // Standard sunrise/sunset
617/// let sunrise_result = spa::sunrise_sunset_for_horizon(
618///     date, 37.7749, -122.4194, 69.0, Horizon::SunriseSunset
619/// ).unwrap();
620///
621/// // Civil twilight
622/// let twilight_result = spa::sunrise_sunset_for_horizon(
623///     date, 37.7749, -122.4194, 69.0, Horizon::CivilTwilight
624/// ).unwrap();
625/// ```
626pub fn sunrise_sunset_for_horizon<Tz: TimeZone>(
627    date: DateTime<Tz>,
628    latitude: f64,
629    longitude: f64,
630    delta_t: f64,
631    horizon: Horizon,
632) -> Result<crate::SunriseResult<DateTime<Tz>>> {
633    sunrise_sunset(
634        date,
635        latitude,
636        longitude,
637        delta_t,
638        horizon.elevation_angle(),
639    )
640}
641
642/// Calculate alpha (right ascension) and delta (declination) for a given JME using full SPA algorithm
643/// Following NREL SPA Algorithm Section 3.2-3.8 for sunrise/sunset calculations
644fn calculate_alpha_delta(jme: f64, delta_psi: f64, epsilon_degrees: f64) -> AlphaDelta {
645    // Follow Java calculateAlphaDelta exactly
646
647    // 3.2.3. Calculate Earth heliocentric latitude, B
648    let b_terms = calculate_lbr_terms(jme, TERMS_B);
649    let b_degrees =
650        normalize_degrees_0_to_360(radians_to_degrees(calculate_lbr_polynomial(jme, &b_terms)));
651
652    // 3.2.4. Calculate Earth radius vector, R
653    let r_terms = calculate_lbr_terms(jme, TERMS_R);
654    let r = calculate_lbr_polynomial(jme, &r_terms);
655    assert!(
656        r != 0.0,
657        "Earth radius vector is zero - astronomical impossibility"
658    );
659
660    // 3.2.2. Calculate Earth heliocentric longitude, L
661    let l_terms = calculate_lbr_terms(jme, TERMS_L);
662    let l_degrees =
663        normalize_degrees_0_to_360(radians_to_degrees(calculate_lbr_polynomial(jme, &l_terms)));
664
665    // 3.2.5. Calculate geocentric longitude, theta
666    let theta_degrees = normalize_degrees_0_to_360(l_degrees + 180.0);
667
668    // 3.2.6. Calculate geocentric latitude, beta
669    let beta_degrees = -b_degrees;
670    let beta = degrees_to_radians(beta_degrees);
671    let epsilon = degrees_to_radians(epsilon_degrees);
672
673    // 3.5. Calculate aberration correction
674    let delta_tau = ABERRATION_CONSTANT / (SECONDS_PER_HOUR * r);
675
676    // 3.6. Calculate the apparent sun longitude
677    let lambda_degrees = theta_degrees + delta_psi + delta_tau;
678    let lambda = degrees_to_radians(lambda_degrees);
679
680    // 3.8.1-3.8.2. Calculate the geocentric sun right ascension and declination
681    let alpha_degrees = calculate_geocentric_sun_right_ascension(beta, epsilon, lambda);
682    let delta_degrees =
683        radians_to_degrees(calculate_geocentric_sun_declination(beta, epsilon, lambda));
684
685    AlphaDelta {
686        alpha: alpha_degrees,
687        delta: delta_degrees,
688    }
689}
690
691/// Normalize value to [0, 1) range using the same logic as the removed `limit_to` function
692fn normalize_to_unit_range(val: f64) -> f64 {
693    let divided = val;
694    let limited = divided - divided.floor();
695    if limited < 0.0 {
696        limited + 1.0
697    } else {
698        limited
699    }
700}
701
702/// Add a fraction of a day to a date
703#[allow(clippy::needless_pass_by_value)]
704fn add_fraction_of_day<Tz: TimeZone>(day: DateTime<Tz>, fraction: f64) -> DateTime<Tz> {
705    // Match Java implementation exactly:
706    // 1. Truncate to start of day (like Java's truncatedTo(ChronoUnit.DAYS))
707    // 2. Calculate milliseconds as int (truncating fractional milliseconds)
708    // 3. Add the milliseconds
709    const MS_PER_DAY: i32 = 24 * 60 * 60 * 1000; // Use i32 like Java
710    let millis_plus = (f64::from(MS_PER_DAY) * fraction) as i32; // Cast to i32 like Java
711
712    let day_start = day
713        .timezone()
714        .from_local_datetime(&day.date_naive().and_hms_opt(0, 0, 0).unwrap())
715        .unwrap();
716
717    day_start + chrono::Duration::milliseconds(i64::from(millis_plus))
718}
719
720/// Limit to 0..1 if absolute value > 2 (Java limitIfNecessary)
721fn limit_if_necessary(val: f64) -> f64 {
722    if val.abs() > 2.0 {
723        normalize_to_unit_range(val)
724    } else {
725        val
726    }
727}
728
729/// Limit H' values according to A.2.11
730fn limit_h_prime(h_prime: f64) -> f64 {
731    let normalized = h_prime / 360.0;
732    let limited = 360.0 * (normalized - floor(normalized));
733
734    if limited < -180.0 {
735        limited + 360.0
736    } else if limited > 180.0 {
737        limited - 360.0
738    } else {
739        limited
740    }
741}
742
743/// Calculate sunrise/sunset times for multiple horizons efficiently.
744///
745/// Returns an iterator that yields `(Horizon, SunriseResult)` pairs. This is more
746/// efficient than separate calls as it reuses expensive astronomical calculations.
747///
748/// # Arguments
749/// * `date` - UTC date and time
750/// * `latitude` - Observer latitude in degrees (-90 to +90)
751/// * `longitude` - Observer longitude in degrees (-180 to +180)
752/// * `delta_t` - ΔT in seconds (difference between TT and UT1)
753/// * `horizons` - Iterator of horizon types to calculate
754///
755/// # Returns
756/// Iterator over `Result<(Horizon, SunriseResult)>`
757///
758/// # Panics
759/// May panic if date cannot be converted to start of day (unlikely with valid dates).
760///
761/// # Example
762/// ```rust
763/// use solar_positioning::{spa, Horizon};
764/// use chrono::{DateTime, FixedOffset};
765///
766/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
767/// let datetime = "2023-06-21T12:00:00-07:00".parse::<DateTime<FixedOffset>>().unwrap();
768/// let horizons = [
769///     Horizon::SunriseSunset,
770///     Horizon::CivilTwilight,
771///     Horizon::NauticalTwilight,
772/// ];
773///
774/// let results: Result<Vec<_>, _> = spa::sunrise_sunset_multiple(
775///     datetime,
776///     37.7749,     // San Francisco latitude
777///     -122.4194,   // San Francisco longitude
778///     69.0,        // deltaT (seconds)
779///     horizons.iter().copied()
780/// ).collect();
781///
782/// for (horizon, result) in results? {
783///     println!("{:?}: {:?}", horizon, result);
784/// }
785/// # Ok(())
786/// # }
787/// ```
788pub fn sunrise_sunset_multiple<Tz, H>(
789    date: DateTime<Tz>,
790    latitude: f64,
791    longitude: f64,
792    delta_t: f64,
793    horizons: H,
794) -> impl Iterator<Item = Result<(Horizon, crate::SunriseResult<DateTime<Tz>>)>>
795where
796    Tz: TimeZone,
797    H: IntoIterator<Item = Horizon>,
798{
799    // Pre-calculate common values once for efficiency
800    let precomputed = (|| -> Result<_> {
801        check_coordinates(latitude, longitude)?;
802
803        let day_start = date
804            .timezone()
805            .from_local_datetime(&date.date_naive().and_hms_opt(0, 0, 0).unwrap())
806            .unwrap();
807        let (nu_degrees, delta_psi_epsilon, epsilon_degrees) =
808            calculate_sidereal_time_and_nutation(day_start.clone());
809        let alpha_deltas =
810            calculate_alpha_deltas_for_three_days(day_start, delta_psi_epsilon, epsilon_degrees)?;
811
812        Ok((nu_degrees, alpha_deltas))
813    })();
814
815    horizons.into_iter().map(move |horizon| match &precomputed {
816        Ok((nu_degrees, alpha_deltas)) => {
817            let result = calculate_sunrise_sunset_spa_algorithm_with_precomputed(
818                date.clone(),
819                latitude,
820                longitude,
821                delta_t,
822                horizon.elevation_angle(),
823                *nu_degrees,
824                *alpha_deltas,
825            );
826            Ok((horizon, result))
827        }
828        Err(e) => Err(e.clone()),
829    })
830}
831
832#[allow(clippy::needless_pass_by_value)]
833fn calculate_sunrise_sunset_spa_algorithm_with_precomputed<Tz: TimeZone>(
834    day: DateTime<Tz>,
835    latitude: f64,
836    longitude: f64,
837    delta_t: f64,
838    elevation_angle: f64,
839    nu_degrees: f64,
840    alpha_deltas: [AlphaDelta; 3],
841) -> crate::SunriseResult<DateTime<Tz>> {
842    // Use the same day_start as the individual calculation to ensure consistency
843    let day_start = day
844        .timezone()
845        .from_local_datetime(&day.date_naive().and_hms_opt(0, 0, 0).unwrap())
846        .unwrap();
847
848    // Calculate initial transit time and check for polar conditions
849    let m0 = (alpha_deltas[1].alpha - longitude - nu_degrees) / 360.0;
850    if let Some(polar_result) = check_polar_conditions(
851        day_start.clone(),
852        m0,
853        latitude,
854        elevation_angle,
855        alpha_deltas[1].delta,
856    ) {
857        return polar_result;
858    }
859
860    // Calculate approximate times and apply corrections
861    let (m_values, _h0_degrees) =
862        calculate_approximate_times(m0, latitude, elevation_angle, alpha_deltas[1].delta);
863
864    // Apply final corrections to get accurate times
865    calculate_final_times(FinalTimeParams {
866        day: day_start,
867        m_values,
868        nu_degrees,
869        delta_t,
870        latitude,
871        longitude,
872        elevation_angle,
873        alpha_deltas,
874    })
875}
876
877/// Extract expensive time-dependent parts of SPA calculation (steps 1-11).
878///
879/// This function calculates the expensive astronomical quantities that are independent
880/// of observer location. Typically used for coordinate sweeps (many locations at fixed
881/// time).
882///
883/// # Performance
884///
885/// Use this with [`spa_with_time_dependent_parts`] for coordinate sweeps:
886/// ```rust
887/// use solar_positioning::spa;
888/// use chrono::{DateTime, Utc};
889///
890/// let datetime = "2023-06-21T12:00:00Z".parse::<DateTime<Utc>>().unwrap();
891/// let shared_parts = spa::spa_time_dependent_parts(datetime, 69.0)?;
892///
893/// for lat in -60..=60 {
894///     for lon in -180..=179 {
895///         let pos = spa::spa_with_time_dependent_parts(
896///             datetime, lat as f64, lon as f64, 0.0, 69.0, None,
897///             &shared_parts
898///         )?;
899///     }
900/// }
901/// # Ok::<(), Box<dyn std::error::Error>>(())
902/// ```
903///
904/// # Errors
905/// Returns error if Julian date calculation fails for the provided datetime
906///
907/// # Panics
908/// May panic if Earth's radius vector is zero (astronomical impossibility)
909#[allow(clippy::needless_pass_by_value)]
910pub fn spa_time_dependent_parts<Tz: TimeZone>(
911    datetime: DateTime<Tz>,
912    delta_t: f64,
913) -> Result<SpaTimeDependent> {
914    use crate::time::JulianDate;
915
916    // Convert to UTC for calculations
917    let utc_datetime = datetime.with_timezone(&chrono::Utc);
918    let jd = JulianDate::from_datetime(&utc_datetime, delta_t)?;
919    let jme = jd.julian_ephemeris_millennium();
920    let jce = jd.julian_ephemeris_century();
921
922    // 3.2.2. Calculate the Earth heliocentric longitude, L (in degrees)
923    let l_terms = calculate_lbr_terms(jme, TERMS_L);
924    let l_degrees =
925        normalize_degrees_0_to_360(radians_to_degrees(calculate_lbr_polynomial(jme, &l_terms)));
926
927    // 3.2.3. Calculate the Earth heliocentric latitude, B (in degrees)
928    let b_terms = calculate_lbr_terms(jme, TERMS_B);
929    let b_degrees =
930        normalize_degrees_0_to_360(radians_to_degrees(calculate_lbr_polynomial(jme, &b_terms)));
931
932    // 3.2.4. Calculate the Earth radius vector, R (in Astronomical Units, AU)
933    let r_terms = calculate_lbr_terms(jme, TERMS_R);
934    let r = calculate_lbr_polynomial(jme, &r_terms);
935
936    // Earth's radius vector should never be zero (would mean Earth at center of Sun)
937    assert!(
938        r != 0.0,
939        "Earth radius vector is zero - astronomical impossibility"
940    );
941
942    // 3.2.5. Calculate the geocentric longitude, theta (in degrees)
943    let theta_degrees = normalize_degrees_0_to_360(l_degrees + 180.0);
944    // 3.2.6. Calculate the geocentric latitude, beta (in degrees)
945    let beta_degrees = -b_degrees;
946
947    // 3.3. Calculate the nutation in longitude and obliquity
948    let x_terms = calculate_nutation_terms(jce);
949    let delta_psi_epsilon = calculate_delta_psi_epsilon(jce, &x_terms);
950
951    // 3.4. Calculate the true obliquity of the ecliptic, epsilon (in degrees)
952    let epsilon_degrees =
953        calculate_true_obliquity_of_ecliptic(&jd, delta_psi_epsilon.delta_epsilon);
954
955    // 3.5. Calculate the aberration correction, delta_tau (in degrees)
956    let delta_tau = ABERRATION_CONSTANT / (SECONDS_PER_HOUR * r);
957
958    // 3.6. Calculate the apparent sun longitude, lambda (in degrees)
959    let lambda_degrees = theta_degrees + delta_psi_epsilon.delta_psi + delta_tau;
960
961    // 3.7. Calculate the apparent sidereal time at Greenwich at any given time, nu (in degrees)
962    let nu_degrees = calculate_apparent_sidereal_time_at_greenwich(
963        &jd,
964        delta_psi_epsilon.delta_psi,
965        epsilon_degrees,
966    );
967
968    // 3.8.1. Calculate the geocentric sun right ascension, alpha (in degrees)
969    let beta = degrees_to_radians(beta_degrees);
970    let epsilon = degrees_to_radians(epsilon_degrees);
971    let lambda = degrees_to_radians(lambda_degrees);
972    let alpha_degrees = calculate_geocentric_sun_right_ascension(beta, epsilon, lambda);
973
974    // 3.8.2. Calculate the geocentric sun declination, delta (in degrees)
975    let delta_degrees =
976        radians_to_degrees(calculate_geocentric_sun_declination(beta, epsilon, lambda));
977
978    Ok(SpaTimeDependent {
979        theta_degrees,
980        beta_degrees,
981        r,
982        delta_psi: delta_psi_epsilon.delta_psi,
983        epsilon_degrees,
984        lambda_degrees,
985        nu_degrees,
986        alpha_degrees,
987        delta_degrees,
988    })
989}
990
991/// Complete SPA calculation using pre-computed time-dependent parts (steps 12+).
992///
993/// This function completes the SPA calculation using cached intermediate values
994/// from [`spa_time_dependent_parts`]. Used together, these provide significant
995/// speedup for coordinate sweeps with unchanged accuracy.
996///
997/// # Parameters
998///
999/// * `datetime` - The date and time (must match the datetime used for `time_dependent`)
1000/// * `latitude` - Observer latitude in decimal degrees [-90, 90]
1001/// * `longitude` - Observer longitude in decimal degrees [-180, 180]
1002/// * `elevation` - Observer elevation above sea level in meters
1003/// * `delta_t` - Difference between Earth rotation time and terrestrial time in seconds (must match `time_dependent`)
1004/// * `refraction` - Optional atmospheric refraction correction
1005/// * `time_dependent` - Pre-computed time-dependent calculations from [`spa_time_dependent_parts`]
1006///
1007/// # Errors
1008/// Returns error for invalid coordinates (latitude outside ±90°, longitude outside ±180°)
1009/// or if Julian date calculation fails
1010#[allow(clippy::needless_pass_by_value)]
1011pub fn spa_with_time_dependent_parts<Tz: TimeZone>(
1012    datetime: DateTime<Tz>,
1013    latitude: f64,
1014    longitude: f64,
1015    elevation: f64,
1016    delta_t: f64,
1017    refraction: Option<RefractionCorrection>,
1018    time_dependent: &SpaTimeDependent,
1019) -> Result<SolarPosition> {
1020    check_coordinates(latitude, longitude)?;
1021
1022    // 3.9. Calculate the observer local hour angle, H (in degrees)
1023    // nu_degrees needs to be calculated precisely for each timestamp
1024    let jd = JulianDate::from_datetime(&datetime, delta_t)?; // Get precise Julian date
1025    let nu_degrees = calculate_apparent_sidereal_time_at_greenwich(
1026        &jd,
1027        time_dependent.delta_psi,
1028        time_dependent.epsilon_degrees,
1029    );
1030
1031    // Use pre-computed geocentric sun right ascension and declination
1032    let h_degrees =
1033        normalize_degrees_0_to_360(nu_degrees + longitude - time_dependent.alpha_degrees);
1034    let h = degrees_to_radians(h_degrees);
1035
1036    // 3.10-3.11. Calculate the topocentric sun coordinates
1037    let xi_degrees = 8.794 / (3600.0 * time_dependent.r);
1038    let xi = degrees_to_radians(xi_degrees);
1039    let phi = degrees_to_radians(latitude);
1040    let delta = degrees_to_radians(time_dependent.delta_degrees);
1041
1042    let u = atan(EARTH_FLATTENING_FACTOR * tan(phi));
1043    let y = EARTH_FLATTENING_FACTOR.mul_add(sin(u), (elevation / EARTH_RADIUS_METERS) * sin(phi));
1044    let x = (elevation / EARTH_RADIUS_METERS).mul_add(cos(phi), cos(u));
1045
1046    let delta_alpha_prime_degrees = radians_to_degrees(atan2(
1047        -x * sin(xi) * sin(h),
1048        (x * sin(xi)).mul_add(-cos(h), cos(delta)),
1049    ));
1050
1051    let delta_prime = radians_to_degrees(atan2(
1052        y.mul_add(-sin(xi), sin(delta)) * cos(degrees_to_radians(delta_alpha_prime_degrees)),
1053        (x * sin(xi)).mul_add(-cos(h), cos(delta)),
1054    ));
1055
1056    // 3.12. Calculate the topocentric local hour angle, H' (in degrees)
1057    let h_prime_degrees = h_degrees - delta_alpha_prime_degrees;
1058
1059    // 3.13. Calculate the topocentric zenith and azimuth angles
1060    let zenith_angle = radians_to_degrees(acos(sin(degrees_to_radians(latitude)).mul_add(
1061        sin(degrees_to_radians(delta_prime)),
1062        cos(degrees_to_radians(latitude))
1063            * cos(degrees_to_radians(delta_prime))
1064            * cos(degrees_to_radians(h_prime_degrees)),
1065    )));
1066
1067    // 3.14. Calculate the topocentric azimuth angle
1068    let azimuth = normalize_degrees_0_to_360(
1069        180.0
1070            + radians_to_degrees(atan2(
1071                sin(degrees_to_radians(h_prime_degrees)),
1072                cos(degrees_to_radians(h_prime_degrees)) * sin(degrees_to_radians(latitude))
1073                    - tan(degrees_to_radians(delta_prime)) * cos(degrees_to_radians(latitude)),
1074            )),
1075    );
1076
1077    // Apply atmospheric refraction if requested
1078    let elevation_angle = 90.0 - zenith_angle;
1079    let final_zenith = refraction.map_or(zenith_angle, |correction| {
1080        if elevation_angle > SUNRISE_SUNSET_ANGLE {
1081            let pressure = correction.pressure();
1082            let temperature = correction.temperature();
1083            // Apply refraction correction following the same pattern as calculate_topocentric_zenith_angle
1084            zenith_angle
1085                - (pressure / 1010.0) * (283.0 / (273.0 + temperature)) * 1.02
1086                    / (60.0
1087                        * tan(degrees_to_radians(
1088                            elevation_angle + 10.3 / (elevation_angle + 5.11),
1089                        )))
1090        } else {
1091            zenith_angle
1092        }
1093    });
1094
1095    SolarPosition::new(azimuth, final_zenith)
1096}
1097
1098#[cfg(test)]
1099mod tests {
1100    use super::*;
1101    use chrono::{DateTime, FixedOffset};
1102
1103    #[test]
1104    fn test_spa_basic_functionality() {
1105        let datetime = "2023-06-21T12:00:00Z"
1106            .parse::<DateTime<FixedOffset>>()
1107            .unwrap();
1108
1109        let result = solar_position(
1110            datetime,
1111            37.7749, // San Francisco
1112            -122.4194,
1113            0.0,
1114            69.0,
1115            Some(RefractionCorrection::new(1013.25, 15.0).unwrap()),
1116        );
1117
1118        assert!(result.is_ok());
1119        let position = result.unwrap();
1120        assert!(position.azimuth() >= 0.0 && position.azimuth() <= 360.0);
1121        assert!(position.zenith_angle() >= 0.0 && position.zenith_angle() <= 180.0);
1122    }
1123
1124    #[test]
1125    fn test_sunrise_sunset_multiple() {
1126        let datetime = "2023-06-21T12:00:00Z"
1127            .parse::<DateTime<FixedOffset>>()
1128            .unwrap();
1129        let horizons = [
1130            Horizon::SunriseSunset,
1131            Horizon::CivilTwilight,
1132            Horizon::NauticalTwilight,
1133        ];
1134
1135        let results: std::result::Result<Vec<_>, _> = sunrise_sunset_multiple(
1136            datetime,
1137            37.7749,   // San Francisco latitude
1138            -122.4194, // San Francisco longitude
1139            69.0,      // deltaT (seconds)
1140            horizons.iter().copied(),
1141        )
1142        .collect();
1143
1144        let results = results.unwrap();
1145
1146        // Should have results for all requested horizons
1147        assert_eq!(results.len(), 3);
1148
1149        // Check that we have all expected horizons
1150        let returned_horizons: std::collections::HashSet<_> =
1151            results.iter().map(|(h, _)| *h).collect();
1152        for expected_horizon in horizons {
1153            assert!(returned_horizons.contains(&expected_horizon));
1154        }
1155
1156        // Compare with individual calls to ensure consistency
1157        for (horizon, bulk_result) in &results {
1158            let individual_result =
1159                sunrise_sunset_for_horizon(datetime, 37.7749, -122.4194, 69.0, *horizon).unwrap();
1160
1161            // Results should be identical
1162            match (&individual_result, bulk_result) {
1163                (
1164                    crate::SunriseResult::RegularDay {
1165                        sunrise: s1,
1166                        transit: t1,
1167                        sunset: ss1,
1168                    },
1169                    crate::SunriseResult::RegularDay {
1170                        sunrise: s2,
1171                        transit: t2,
1172                        sunset: ss2,
1173                    },
1174                ) => {
1175                    assert_eq!(s1, s2);
1176                    assert_eq!(t1, t2);
1177                    assert_eq!(ss1, ss2);
1178                }
1179                (
1180                    crate::SunriseResult::AllDay { transit: t1 },
1181                    crate::SunriseResult::AllDay { transit: t2 },
1182                )
1183                | (
1184                    crate::SunriseResult::AllNight { transit: t1 },
1185                    crate::SunriseResult::AllNight { transit: t2 },
1186                ) => {
1187                    assert_eq!(t1, t2);
1188                }
1189                _ => panic!("Bulk and individual results differ in type for {horizon:?}"),
1190            }
1191        }
1192    }
1193
1194    #[test]
1195    fn test_spa_no_refraction() {
1196        let datetime = "2023-06-21T12:00:00Z"
1197            .parse::<DateTime<FixedOffset>>()
1198            .unwrap();
1199
1200        let result = solar_position(datetime, 37.7749, -122.4194, 0.0, 69.0, None);
1201
1202        assert!(result.is_ok());
1203        let position = result.unwrap();
1204        assert!(position.azimuth() >= 0.0 && position.azimuth() <= 360.0);
1205        assert!(position.zenith_angle() >= 0.0 && position.zenith_angle() <= 180.0);
1206    }
1207
1208    #[test]
1209    fn test_spa_coordinate_validation() {
1210        let datetime = "2023-06-21T12:00:00Z"
1211            .parse::<DateTime<FixedOffset>>()
1212            .unwrap();
1213
1214        // Invalid latitude
1215        assert!(
1216            solar_position(
1217                datetime,
1218                95.0,
1219                0.0,
1220                0.0,
1221                0.0,
1222                Some(RefractionCorrection::new(1013.25, 15.0).unwrap())
1223            )
1224            .is_err()
1225        );
1226
1227        // Invalid longitude
1228        assert!(
1229            solar_position(
1230                datetime,
1231                0.0,
1232                185.0,
1233                0.0,
1234                0.0,
1235                Some(RefractionCorrection::new(1013.25, 15.0).unwrap())
1236            )
1237            .is_err()
1238        );
1239    }
1240
1241    #[test]
1242    fn test_sunrise_sunset_basic() {
1243        let date = "2023-06-21T00:00:00Z"
1244            .parse::<DateTime<FixedOffset>>()
1245            .unwrap();
1246
1247        let result = sunrise_sunset(date, 37.7749, -122.4194, 69.0, -0.833);
1248        assert!(result.is_ok());
1249
1250        let result =
1251            sunrise_sunset_for_horizon(date, 37.7749, -122.4194, 69.0, Horizon::SunriseSunset);
1252        assert!(result.is_ok());
1253    }
1254
1255    #[test]
1256    fn test_horizon_enum() {
1257        assert_eq!(Horizon::SunriseSunset.elevation_angle(), -0.83337);
1258        assert_eq!(Horizon::CivilTwilight.elevation_angle(), -6.0);
1259        assert_eq!(Horizon::Custom(-10.5).elevation_angle(), -10.5);
1260    }
1261}