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