solar_positioning/spa/
mod.rs

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