solar_positioning/spa/
mod.rs

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