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