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