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};
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, 0.0)?;
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());
575
576    // A.2.2. Calculate alpha/delta for day before, same day, next day
577    let alpha_deltas =
578        calculate_alpha_deltas_for_three_days(day_start, delta_psi_epsilon, epsilon_degrees)?;
579
580    // Calculate initial transit time and check for polar conditions
581    let m0 = (alpha_deltas[1].alpha - longitude - nu_degrees) / 360.0;
582
583    // Check for polar conditions but don't return early - need to calculate accurate transit
584    let polar_type = check_polar_conditions_type(latitude, elevation_angle, alpha_deltas[1].delta);
585
586    // Calculate approximate times and apply corrections (needed for accurate transit time)
587    let (m_values, _h0_degrees) =
588        calculate_approximate_times(m0, latitude, elevation_angle, alpha_deltas[1].delta);
589
590    // Apply final corrections to get accurate times
591    let final_result = calculate_final_times(FinalTimeParams {
592        day,
593        m_values,
594        nu_degrees,
595        delta_t,
596        latitude,
597        longitude,
598        elevation_angle,
599        alpha_deltas,
600    });
601
602    // Convert to appropriate polar result type if needed
603    match polar_type {
604        Some(PolarType::AllDay) => {
605            if let crate::SunriseResult::RegularDay { transit, .. } = final_result {
606                Ok(crate::SunriseResult::AllDay { transit })
607            } else {
608                Ok(final_result)
609            }
610        }
611        Some(PolarType::AllNight) => {
612            if let crate::SunriseResult::RegularDay { transit, .. } = final_result {
613                Ok(crate::SunriseResult::AllNight { transit })
614            } else {
615                Ok(final_result)
616            }
617        }
618        None => Ok(final_result),
619    }
620}
621#[cfg(feature = "chrono")]
622#[allow(clippy::needless_pass_by_value)]
623fn calculate_sidereal_time_and_nutation<Tz: TimeZone>(
624    day_start: DateTime<Tz>,
625) -> (f64, DeltaPsiEpsilon, f64) {
626    let jd = JulianDate::from_datetime(&day_start, 0.0).unwrap();
627    let jce_day = jd.julian_ephemeris_century();
628    let x_terms = calculate_nutation_terms(jce_day);
629    let delta_psi_epsilon = calculate_delta_psi_epsilon(jce_day, &x_terms);
630    let epsilon_degrees =
631        calculate_true_obliquity_of_ecliptic(&jd, delta_psi_epsilon.delta_epsilon);
632    let nu_degrees = calculate_apparent_sidereal_time_at_greenwich(
633        &jd,
634        delta_psi_epsilon.delta_psi,
635        epsilon_degrees,
636    );
637    (nu_degrees, delta_psi_epsilon, epsilon_degrees)
638}
639
640#[cfg(feature = "chrono")]
641#[allow(clippy::needless_pass_by_value)]
642fn calculate_alpha_deltas_for_three_days<Tz: TimeZone>(
643    day_start: DateTime<Tz>,
644    delta_psi_epsilon: DeltaPsiEpsilon,
645    epsilon_degrees: f64,
646) -> Result<[AlphaDelta; 3]> {
647    let mut alpha_deltas = [AlphaDelta {
648        alpha: 0.0,
649        delta: 0.0,
650    }; 3];
651    for (i, alpha_delta) in alpha_deltas.iter_mut().enumerate() {
652        let current_jd = JulianDate::from_datetime(&day_start, 0.0)?.add_days((i as f64) - 1.0);
653        let current_jme = current_jd.julian_ephemeris_millennium();
654        let ad = calculate_alpha_delta(current_jme, delta_psi_epsilon.delta_psi, epsilon_degrees);
655        *alpha_delta = ad;
656    }
657    Ok(alpha_deltas)
658}
659
660// ============================================================================
661// Sunrise/sunset helper functions below
662// Core functions work without chrono, chrono-specific wrappers separate
663// ============================================================================
664
665/// Enum for polar condition types
666#[derive(Debug, Clone, Copy)]
667enum PolarType {
668    AllDay,
669    AllNight,
670}
671
672/// Check for polar day/night conditions and return the type
673fn check_polar_conditions_type(
674    latitude: f64,
675    elevation_angle: f64,
676    delta1: f64,
677) -> Option<PolarType> {
678    let phi = degrees_to_radians(latitude);
679    let elevation_rad = degrees_to_radians(elevation_angle);
680    let delta1_rad = degrees_to_radians(delta1);
681
682    let acos_arg =
683        mul_add(sin(phi), -sin(delta1_rad), sin(elevation_rad)) / (cos(phi) * cos(delta1_rad));
684
685    if acos_arg < -1.0 {
686        Some(PolarType::AllDay)
687    } else if acos_arg > 1.0 {
688        Some(PolarType::AllNight)
689    } else {
690        None
691    }
692}
693
694/// Check for polar day/night conditions and construct `SunriseResult`
695#[cfg(feature = "chrono")]
696fn check_polar_conditions<Tz: TimeZone>(
697    day: DateTime<Tz>,
698    m0: f64,
699    latitude: f64,
700    elevation_angle: f64,
701    delta1: f64,
702) -> Option<crate::SunriseResult<DateTime<Tz>>> {
703    check_polar_conditions_type(latitude, elevation_angle, delta1).map(|polar_type| {
704        let transit = add_fraction_of_day(day, m0);
705        match polar_type {
706            PolarType::AllDay => crate::SunriseResult::AllDay { transit },
707            PolarType::AllNight => crate::SunriseResult::AllNight { transit },
708        }
709    })
710}
711
712/// A.2.5-6. Calculate approximate times for transit, sunrise, sunset
713fn calculate_approximate_times(
714    m0: f64,
715    latitude: f64,
716    elevation_angle: f64,
717    delta1: f64,
718) -> ([f64; 3], f64) {
719    let phi = degrees_to_radians(latitude);
720    let delta1_rad = degrees_to_radians(delta1);
721    let elevation_rad = degrees_to_radians(elevation_angle);
722
723    let acos_arg =
724        mul_add(sin(phi), -sin(delta1_rad), sin(elevation_rad)) / (cos(phi) * cos(delta1_rad));
725    let h0 = acos(acos_arg);
726    let h0_degrees = radians_to_degrees(h0).min(180.0);
727
728    let mut m = [0.0; 3];
729    m[0] = normalize_to_unit_range(m0);
730    m[1] = normalize_to_unit_range(m0 - h0_degrees / 360.0);
731    m[2] = normalize_to_unit_range(m0 + h0_degrees / 360.0);
732
733    (m, h0_degrees)
734}
735
736/// A.2.8-15. Calculate final accurate time fractions using corrections
737/// Returns (`transit_frac`, `sunrise_frac`, `sunset_frac`) as fractions of day
738fn calculate_final_time_fractions(
739    m_values: [f64; 3],
740    nu_degrees: f64,
741    delta_t: f64,
742    latitude: f64,
743    longitude: f64,
744    elevation_angle: f64,
745    alpha_deltas: [AlphaDelta; 3],
746) -> (f64, f64, f64) {
747    // A.2.8. Calculate sidereal times
748    let mut nu = [0.0; 3];
749    for (i, nu_item) in nu.iter_mut().enumerate() {
750        *nu_item = mul_add(360.985647f64, m_values[i], nu_degrees);
751    }
752
753    // A.2.9. Calculate terms with deltaT correction
754    let mut n = [0.0; 3];
755    for (i, n_item) in n.iter_mut().enumerate() {
756        *n_item = m_values[i] + delta_t / 86400.0;
757    }
758
759    // A.2.10. Calculate α'i and δ'i using interpolation
760    let alpha_delta_primes = calculate_interpolated_alpha_deltas(&alpha_deltas, &n);
761
762    // A.2.11. Calculate local hour angles
763    let mut h_prime = [0.0; 3];
764    for i in 0..3 {
765        let h_prime_i = nu[i] + longitude - alpha_delta_primes[i].alpha;
766        h_prime[i] = limit_h_prime(h_prime_i);
767    }
768
769    // A.2.12. Calculate sun altitudes
770    let phi = degrees_to_radians(latitude);
771    let mut h = [0.0; 3];
772    for i in 0..3 {
773        let delta_prime_rad = degrees_to_radians(alpha_delta_primes[i].delta);
774        h[i] = radians_to_degrees(asin(mul_add(
775            sin(phi),
776            sin(delta_prime_rad),
777            cos(phi) * cos(delta_prime_rad) * cos(degrees_to_radians(h_prime[i])),
778        )));
779    }
780
781    // A.2.13-15. Calculate final times as fractions
782    let t = m_values[0] - h_prime[0] / 360.0;
783    let r = m_values[1]
784        + (h[1] - elevation_angle)
785            / (360.0
786                * cos(degrees_to_radians(alpha_delta_primes[1].delta))
787                * cos(phi)
788                * sin(degrees_to_radians(h_prime[1])));
789    let s = m_values[2]
790        + (h[2] - elevation_angle)
791            / (360.0
792                * cos(degrees_to_radians(alpha_delta_primes[2].delta))
793                * cos(phi)
794                * sin(degrees_to_radians(h_prime[2])));
795
796    (t, r, s)
797}
798
799/// A.2.8-15. Calculate final accurate times using corrections
800#[cfg(feature = "chrono")]
801fn calculate_final_times<Tz: TimeZone>(
802    params: FinalTimeParams<Tz>,
803) -> crate::SunriseResult<DateTime<Tz>> {
804    // A.2.8. Calculate sidereal times
805    let mut nu = [0.0; 3];
806    for (i, nu_item) in nu.iter_mut().enumerate() {
807        *nu_item = mul_add(360.985647f64, params.m_values[i], params.nu_degrees);
808    }
809
810    // A.2.9. Calculate terms with deltaT correction
811    let mut n = [0.0; 3];
812    for (i, n_item) in n.iter_mut().enumerate() {
813        *n_item = params.m_values[i] + params.delta_t / 86400.0;
814    }
815
816    // A.2.10. Calculate α'i and δ'i using interpolation
817    let alpha_delta_primes = calculate_interpolated_alpha_deltas(&params.alpha_deltas, &n);
818
819    // A.2.11. Calculate local hour angles
820    let mut h_prime = [0.0; 3];
821    for i in 0..3 {
822        let h_prime_i = nu[i] + params.longitude - alpha_delta_primes[i].alpha;
823        h_prime[i] = limit_h_prime(h_prime_i);
824    }
825
826    // A.2.12. Calculate sun altitudes
827    let phi = degrees_to_radians(params.latitude);
828    let mut h = [0.0; 3];
829    for i in 0..3 {
830        let delta_prime_rad = degrees_to_radians(alpha_delta_primes[i].delta);
831        h[i] = radians_to_degrees(asin(mul_add(
832            sin(phi),
833            sin(delta_prime_rad),
834            cos(phi) * cos(delta_prime_rad) * cos(degrees_to_radians(h_prime[i])),
835        )));
836    }
837
838    // A.2.13-15. Calculate final times
839    let t = params.m_values[0] - h_prime[0] / 360.0;
840    let r = params.m_values[1]
841        + (h[1] - params.elevation_angle)
842            / (360.0
843                * cos(degrees_to_radians(alpha_delta_primes[1].delta))
844                * cos(phi)
845                * sin(degrees_to_radians(h_prime[1])));
846    let s = params.m_values[2]
847        + (h[2] - params.elevation_angle)
848            / (360.0
849                * cos(degrees_to_radians(alpha_delta_primes[2].delta))
850                * cos(phi)
851                * sin(degrees_to_radians(h_prime[2])));
852
853    let sunrise = add_fraction_of_day(params.day.clone(), r);
854    let transit = add_fraction_of_day(params.day.clone(), t);
855    let sunset = add_fraction_of_day(params.day, s);
856
857    crate::SunriseResult::RegularDay {
858        sunrise,
859        transit,
860        sunset,
861    }
862}
863
864/// A.2.10. Calculate interpolated alpha/delta values
865fn calculate_interpolated_alpha_deltas(
866    alpha_deltas: &[AlphaDelta; 3],
867    n: &[f64; 3],
868) -> [AlphaDelta; 3] {
869    let a = limit_if_necessary(alpha_deltas[1].alpha - alpha_deltas[0].alpha);
870    let a_prime = limit_if_necessary(alpha_deltas[1].delta - alpha_deltas[0].delta);
871
872    let b = limit_if_necessary(alpha_deltas[2].alpha - alpha_deltas[1].alpha);
873    let b_prime = limit_if_necessary(alpha_deltas[2].delta - alpha_deltas[1].delta);
874
875    let c = b - a;
876    let c_prime = b_prime - a_prime;
877
878    let mut alpha_delta_primes = [AlphaDelta {
879        alpha: 0.0,
880        delta: 0.0,
881    }; 3];
882    for i in 0..3 {
883        alpha_delta_primes[i].alpha =
884            alpha_deltas[1].alpha + (n[i] * (mul_add(c, n[i], a + b))) / 2.0;
885        alpha_delta_primes[i].delta =
886            alpha_deltas[1].delta + (n[i] * (mul_add(c_prime, n[i], a_prime + b_prime))) / 2.0;
887    }
888    alpha_delta_primes
889}
890
891#[derive(Debug, Clone, Copy)]
892struct AlphaDelta {
893    alpha: f64,
894    delta: f64,
895}
896
897/// Parameters for calculating final sunrise/sunset times
898#[derive(Debug, Clone)]
899#[cfg(feature = "chrono")]
900struct FinalTimeParams<Tz: TimeZone> {
901    day: DateTime<Tz>,
902    m_values: [f64; 3],
903    nu_degrees: f64,
904    delta_t: f64,
905    latitude: f64,
906    longitude: f64,
907    elevation_angle: f64,
908    alpha_deltas: [AlphaDelta; 3],
909}
910
911/// Calculate sunrise, solar transit, and sunset times for a specific horizon type.
912///
913/// This is a convenience function that uses predefined elevation angles for common
914/// sunrise/twilight calculations.
915///
916/// # Arguments
917/// * `date` - Date for calculations
918/// * `latitude` - Observer latitude in degrees (-90 to +90)
919/// * `longitude` - Observer longitude in degrees (-180 to +180)
920/// * `delta_t` - ΔT in seconds (difference between TT and UT1)
921/// * `horizon` - Horizon type (sunrise/sunset, civil twilight, etc.)
922///
923/// # Errors
924/// Returns error for invalid coordinates, dates, or computation failures.
925///
926/// # Panics
927/// May panic if date cannot be converted to start of day (unlikely with valid dates).
928///
929/// # Example
930/// ```rust
931/// use solar_positioning::{spa, Horizon};
932/// use chrono::{FixedOffset, NaiveDate, TimeZone};
933///
934/// let date = FixedOffset::east_opt(-7 * 3600).unwrap() // Pacific Time (UTC-7)
935///     .from_local_datetime(&NaiveDate::from_ymd_opt(2023, 6, 21).unwrap()
936///         .and_hms_opt(0, 0, 0).unwrap()).unwrap();
937///
938/// // Standard sunrise/sunset
939/// let sunrise_result = spa::sunrise_sunset_for_horizon(
940///     date, 37.7749, -122.4194, 69.0, Horizon::SunriseSunset
941/// ).unwrap();
942///
943/// // Civil twilight
944/// let twilight_result = spa::sunrise_sunset_for_horizon(
945///     date, 37.7749, -122.4194, 69.0, Horizon::CivilTwilight
946/// ).unwrap();
947/// ```
948#[cfg(feature = "chrono")]
949pub fn sunrise_sunset_for_horizon<Tz: TimeZone>(
950    date: DateTime<Tz>,
951    latitude: f64,
952    longitude: f64,
953    delta_t: f64,
954    horizon: Horizon,
955) -> Result<crate::SunriseResult<DateTime<Tz>>> {
956    sunrise_sunset(
957        date,
958        latitude,
959        longitude,
960        delta_t,
961        horizon.elevation_angle(),
962    )
963}
964
965/// Calculate alpha (right ascension) and delta (declination) for a given JME using full SPA algorithm
966/// Following NREL SPA Algorithm Section 3.2-3.8 for sunrise/sunset calculations
967fn calculate_alpha_delta(jme: f64, delta_psi: f64, epsilon_degrees: f64) -> AlphaDelta {
968    // Follow Java calculateAlphaDelta exactly
969
970    // 3.2.3. Calculate Earth heliocentric latitude, B
971    let b_terms = calculate_lbr_terms(jme, TERMS_B);
972    let b_degrees = lbr_to_normalized_degrees(jme, &b_terms, TERMS_B.len());
973
974    // 3.2.4. Calculate Earth radius vector, R
975    let r_terms = calculate_lbr_terms(jme, TERMS_R);
976    let r = calculate_lbr_polynomial(jme, &r_terms, TERMS_R.len());
977    assert!(
978        r != 0.0,
979        "Earth radius vector is zero - astronomical impossibility"
980    );
981
982    // 3.2.2. Calculate Earth heliocentric longitude, L
983    let l_terms = calculate_lbr_terms(jme, TERMS_L);
984    let l_degrees = lbr_to_normalized_degrees(jme, &l_terms, TERMS_L.len());
985
986    // 3.2.5. Calculate geocentric longitude, theta
987    let theta_degrees = normalize_degrees_0_to_360(l_degrees + 180.0);
988
989    // 3.2.6. Calculate geocentric latitude, beta
990    let beta_degrees = -b_degrees;
991    let beta = degrees_to_radians(beta_degrees);
992    let epsilon = degrees_to_radians(epsilon_degrees);
993
994    // 3.5. Calculate aberration correction
995    let delta_tau = ABERRATION_CONSTANT / (SECONDS_PER_HOUR * r);
996
997    // 3.6. Calculate the apparent sun longitude
998    let lambda_degrees = theta_degrees + delta_psi + delta_tau;
999    let lambda = degrees_to_radians(lambda_degrees);
1000
1001    // 3.8.1-3.8.2. Calculate the geocentric sun right ascension and declination
1002    let alpha_degrees = calculate_geocentric_sun_right_ascension(beta, epsilon, lambda);
1003    let delta_degrees =
1004        radians_to_degrees(calculate_geocentric_sun_declination(beta, epsilon, lambda));
1005
1006    AlphaDelta {
1007        alpha: alpha_degrees,
1008        delta: delta_degrees,
1009    }
1010}
1011
1012/// Normalize value to [0, 1) range using the same logic as the removed `limit_to` function
1013fn normalize_to_unit_range(val: f64) -> f64 {
1014    let divided = val;
1015    let limited = divided - floor(divided);
1016    if limited < 0.0 {
1017        limited + 1.0
1018    } else {
1019        limited
1020    }
1021}
1022
1023#[cfg(feature = "chrono")]
1024fn truncate_to_day_start<Tz: TimeZone>(datetime: &DateTime<Tz>) -> DateTime<Tz> {
1025    datetime
1026        .timezone()
1027        .from_local_datetime(
1028            &datetime
1029                .date_naive()
1030                .and_hms_opt(0, 0, 0)
1031                .expect("midnight is always valid"),
1032        )
1033        .single()
1034        .expect("midnight should have single timezone representation")
1035}
1036
1037#[cfg(feature = "chrono")]
1038#[allow(clippy::needless_pass_by_value)]
1039fn add_fraction_of_day<Tz: TimeZone>(day: DateTime<Tz>, fraction: f64) -> DateTime<Tz> {
1040    // Match Java implementation exactly:
1041    // 1. Truncate to start of day (like Java's truncatedTo(ChronoUnit.DAYS))
1042    // 2. Calculate milliseconds as int (truncating fractional milliseconds)
1043    // 3. Add the milliseconds
1044    const MS_PER_DAY: i32 = 24 * 60 * 60 * 1000; // Use i32 like Java
1045    let millis_plus = (f64::from(MS_PER_DAY) * fraction) as i32; // Cast to i32 like Java
1046
1047    let day_start = truncate_to_day_start(&day);
1048
1049    day_start + chrono::Duration::milliseconds(i64::from(millis_plus))
1050}
1051
1052/// Limit to 0..1 if absolute value > 2 (Java limitIfNecessary)
1053fn limit_if_necessary(val: f64) -> f64 {
1054    if val.abs() > 2.0 {
1055        normalize_to_unit_range(val)
1056    } else {
1057        val
1058    }
1059}
1060
1061/// Limit H' values according to A.2.11
1062fn limit_h_prime(h_prime: f64) -> f64 {
1063    let normalized = h_prime / 360.0;
1064    let limited = 360.0 * (normalized - floor(normalized));
1065
1066    if limited < -180.0 {
1067        limited + 360.0
1068    } else if limited > 180.0 {
1069        limited - 360.0
1070    } else {
1071        limited
1072    }
1073}
1074
1075/// Calculate sunrise/sunset times for multiple horizons efficiently.
1076///
1077/// Returns an iterator that yields `(Horizon, SunriseResult)` pairs. This is more
1078/// efficient than separate calls as it reuses expensive astronomical calculations.
1079///
1080/// # Arguments
1081/// * `date` - Date for calculations
1082/// * `latitude` - Observer latitude in degrees (-90 to +90)
1083/// * `longitude` - Observer longitude in degrees (-180 to +180)
1084/// * `delta_t` - ΔT in seconds (difference between TT and UT1)
1085/// * `horizons` - Iterator of horizon types to calculate
1086///
1087/// # Returns
1088/// Iterator over `Result<(Horizon, SunriseResult)>`
1089///
1090/// # Errors
1091/// Returns error for invalid coordinates (latitude outside ±90°, longitude outside ±180°)
1092///
1093/// # Panics
1094/// May panic if date cannot be converted to start of day (unlikely with valid dates).
1095///
1096/// # Example
1097/// ```rust
1098/// use solar_positioning::{spa, Horizon};
1099/// use chrono::{DateTime, FixedOffset};
1100///
1101/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
1102/// let datetime = "2023-06-21T12:00:00-07:00".parse::<DateTime<FixedOffset>>().unwrap();
1103/// let horizons = [
1104///     Horizon::SunriseSunset,
1105///     Horizon::CivilTwilight,
1106///     Horizon::NauticalTwilight,
1107/// ];
1108///
1109/// let results: Result<Vec<_>, _> = spa::sunrise_sunset_multiple(
1110///     datetime,
1111///     37.7749,     // San Francisco latitude
1112///     -122.4194,   // San Francisco longitude
1113///     69.0,        // deltaT (seconds)
1114///     horizons.iter().copied()
1115/// ).collect();
1116///
1117/// for (horizon, result) in results? {
1118///     println!("{:?}: {:?}", horizon, result);
1119/// }
1120/// # Ok(())
1121/// # }
1122/// ```
1123#[cfg(feature = "chrono")]
1124pub fn sunrise_sunset_multiple<Tz, H>(
1125    date: DateTime<Tz>,
1126    latitude: f64,
1127    longitude: f64,
1128    delta_t: f64,
1129    horizons: H,
1130) -> impl Iterator<Item = Result<(Horizon, crate::SunriseResult<DateTime<Tz>>)>>
1131where
1132    Tz: TimeZone,
1133    H: IntoIterator<Item = Horizon>,
1134{
1135    // Pre-calculate common values once for efficiency
1136    let precomputed = (|| -> Result<_> {
1137        check_coordinates(latitude, longitude)?;
1138
1139        let day_start = truncate_to_day_start(&date);
1140        let (nu_degrees, delta_psi_epsilon, epsilon_degrees) =
1141            calculate_sidereal_time_and_nutation(day_start.clone());
1142        let alpha_deltas =
1143            calculate_alpha_deltas_for_three_days(day_start, delta_psi_epsilon, epsilon_degrees)?;
1144
1145        Ok((nu_degrees, alpha_deltas))
1146    })();
1147
1148    horizons.into_iter().map(move |horizon| match &precomputed {
1149        Ok((nu_degrees, alpha_deltas)) => {
1150            let result = calculate_sunrise_sunset_spa_algorithm_with_precomputed(
1151                date.clone(),
1152                latitude,
1153                longitude,
1154                delta_t,
1155                horizon.elevation_angle(),
1156                *nu_degrees,
1157                *alpha_deltas,
1158            );
1159            Ok((horizon, result))
1160        }
1161        Err(e) => Err(e.clone()),
1162    })
1163}
1164#[cfg(feature = "chrono")]
1165#[allow(clippy::needless_pass_by_value)]
1166fn calculate_sunrise_sunset_spa_algorithm_with_precomputed<Tz: TimeZone>(
1167    day: DateTime<Tz>,
1168    latitude: f64,
1169    longitude: f64,
1170    delta_t: f64,
1171    elevation_angle: f64,
1172    nu_degrees: f64,
1173    alpha_deltas: [AlphaDelta; 3],
1174) -> crate::SunriseResult<DateTime<Tz>> {
1175    let day_start = truncate_to_day_start(&day);
1176
1177    // Calculate initial transit time and check for polar conditions
1178    let m0 = (alpha_deltas[1].alpha - longitude - nu_degrees) / 360.0;
1179    if let Some(polar_result) = check_polar_conditions(
1180        day_start.clone(),
1181        m0,
1182        latitude,
1183        elevation_angle,
1184        alpha_deltas[1].delta,
1185    ) {
1186        return polar_result;
1187    }
1188
1189    // Calculate approximate times and apply corrections
1190    let (m_values, _h0_degrees) =
1191        calculate_approximate_times(m0, latitude, elevation_angle, alpha_deltas[1].delta);
1192
1193    // Apply final corrections to get accurate times
1194    calculate_final_times(FinalTimeParams {
1195        day: day_start,
1196        m_values,
1197        nu_degrees,
1198        delta_t,
1199        latitude,
1200        longitude,
1201        elevation_angle,
1202        alpha_deltas,
1203    })
1204}
1205
1206/// Extract expensive time-dependent parts of SPA calculation (steps 1-11).
1207///
1208/// This function calculates the expensive astronomical quantities that are independent
1209/// of observer location. Typically used for coordinate sweeps (many locations at fixed
1210/// time).
1211///
1212/// # Arguments
1213/// * `datetime` - Date and time with timezone
1214/// * `delta_t` - ΔT in seconds (difference between TT and UT1)
1215///
1216/// # Returns
1217/// Pre-computed time-dependent values for SPA calculations
1218///
1219/// # Performance
1220///
1221/// Use this with [`spa_with_time_dependent_parts`] for coordinate sweeps:
1222/// ```rust
1223/// use solar_positioning::spa;
1224/// use chrono::{DateTime, Utc};
1225///
1226/// let datetime = "2023-06-21T12:00:00Z".parse::<DateTime<Utc>>().unwrap();
1227/// let shared_parts = spa::spa_time_dependent_parts(datetime, 69.0)?;
1228///
1229/// for lat in -60..=60 {
1230///     for lon in -180..=179 {
1231///         let pos = spa::spa_with_time_dependent_parts(
1232///             lat as f64, lon as f64, 0.0, None, &shared_parts
1233///         )?;
1234///     }
1235/// }
1236/// # Ok::<(), Box<dyn std::error::Error>>(())
1237/// ```
1238///
1239/// # Errors
1240/// Returns error if Julian date calculation fails for the provided datetime
1241///
1242/// # Panics
1243#[cfg(feature = "chrono")]
1244#[allow(clippy::needless_pass_by_value)]
1245pub fn spa_time_dependent_parts<Tz: TimeZone>(
1246    datetime: DateTime<Tz>,
1247    delta_t: f64,
1248) -> Result<SpaTimeDependent> {
1249    let jd = JulianDate::from_datetime(&datetime, delta_t)?;
1250    spa_time_dependent_from_julian(jd)
1251}
1252
1253/// Calculate time-dependent parts of SPA from a Julian date.
1254///
1255/// Core implementation for `no_std` compatibility.
1256///
1257/// # Errors
1258/// Returns error if Julian date is invalid.
1259///
1260/// # Panics
1261/// Panics if Earth radius vector is zero (astronomical impossibility).
1262pub fn spa_time_dependent_from_julian(jd: JulianDate) -> Result<SpaTimeDependent> {
1263    let jme = jd.julian_ephemeris_millennium();
1264    let jce = jd.julian_ephemeris_century();
1265
1266    // 3.2.2. Calculate the Earth heliocentric longitude, L (in degrees)
1267    let l_terms = calculate_lbr_terms(jme, TERMS_L);
1268    let l_degrees = lbr_to_normalized_degrees(jme, &l_terms, TERMS_L.len());
1269
1270    // 3.2.3. Calculate the Earth heliocentric latitude, B (in degrees)
1271    let b_terms = calculate_lbr_terms(jme, TERMS_B);
1272    let b_degrees = lbr_to_normalized_degrees(jme, &b_terms, TERMS_B.len());
1273
1274    // 3.2.4. Calculate the Earth radius vector, R (in Astronomical Units, AU)
1275    let r_terms = calculate_lbr_terms(jme, TERMS_R);
1276    let r = calculate_lbr_polynomial(jme, &r_terms, TERMS_R.len());
1277
1278    // Earth's radius vector should never be zero (would mean Earth at center of Sun)
1279    assert!(
1280        r != 0.0,
1281        "Earth radius vector is zero - astronomical impossibility"
1282    );
1283
1284    // 3.2.5. Calculate the geocentric longitude, theta (in degrees)
1285    let theta_degrees = normalize_degrees_0_to_360(l_degrees + 180.0);
1286    // 3.2.6. Calculate the geocentric latitude, beta (in degrees)
1287    let beta_degrees = -b_degrees;
1288
1289    // 3.3. Calculate the nutation in longitude and obliquity
1290    let x_terms = calculate_nutation_terms(jce);
1291    let delta_psi_epsilon = calculate_delta_psi_epsilon(jce, &x_terms);
1292
1293    // 3.4. Calculate the true obliquity of the ecliptic, epsilon (in degrees)
1294    let epsilon_degrees =
1295        calculate_true_obliquity_of_ecliptic(&jd, delta_psi_epsilon.delta_epsilon);
1296
1297    // 3.5. Calculate the aberration correction, delta_tau (in degrees)
1298    let delta_tau = ABERRATION_CONSTANT / (SECONDS_PER_HOUR * r);
1299
1300    // 3.6. Calculate the apparent sun longitude, lambda (in degrees)
1301    let lambda_degrees = theta_degrees + delta_psi_epsilon.delta_psi + delta_tau;
1302
1303    // 3.7. Calculate the apparent sidereal time at Greenwich at any given time, nu (in degrees)
1304    let nu_degrees = calculate_apparent_sidereal_time_at_greenwich(
1305        &jd,
1306        delta_psi_epsilon.delta_psi,
1307        epsilon_degrees,
1308    );
1309
1310    // 3.8.1. Calculate the geocentric sun right ascension, alpha (in degrees)
1311    let beta = degrees_to_radians(beta_degrees);
1312    let epsilon = degrees_to_radians(epsilon_degrees);
1313    let lambda = degrees_to_radians(lambda_degrees);
1314    let alpha_degrees = calculate_geocentric_sun_right_ascension(beta, epsilon, lambda);
1315
1316    // 3.8.2. Calculate the geocentric sun declination, delta (in degrees)
1317    let delta_degrees =
1318        radians_to_degrees(calculate_geocentric_sun_declination(beta, epsilon, lambda));
1319
1320    Ok(SpaTimeDependent {
1321        theta_degrees,
1322        beta_degrees,
1323        r,
1324        delta_psi: delta_psi_epsilon.delta_psi,
1325        epsilon_degrees,
1326        lambda_degrees,
1327        nu_degrees,
1328        alpha_degrees,
1329        delta_degrees,
1330    })
1331}
1332
1333/// Complete SPA calculation using pre-computed time-dependent parts (steps 12+).
1334///
1335/// This function completes the SPA calculation using cached intermediate values
1336/// from [`spa_time_dependent_parts`]. Used together, these provide significant
1337/// speedup for coordinate sweeps with unchanged accuracy.
1338///
1339/// # Arguments
1340/// * `latitude` - Observer latitude in degrees (-90 to +90)
1341/// * `longitude` - Observer longitude in degrees (-180 to +180)
1342/// * `elevation` - Observer elevation above sea level in meters
1343/// * `refraction` - Optional atmospheric refraction correction
1344/// * `time_dependent` - Pre-computed time-dependent calculations from [`spa_time_dependent_parts`]
1345///
1346/// # Returns
1347/// Solar position or error
1348///
1349/// # Errors
1350/// Returns error for invalid coordinates (latitude outside ±90°, longitude outside ±180°)
1351///
1352/// # Example
1353/// ```rust
1354/// use solar_positioning::{spa, RefractionCorrection};
1355/// use chrono::{DateTime, Utc};
1356///
1357/// let datetime = "2023-06-21T12:00:00Z".parse::<DateTime<Utc>>().unwrap();
1358/// let time_parts = spa::spa_time_dependent_parts(datetime, 69.0).unwrap();
1359///
1360/// let position = spa::spa_with_time_dependent_parts(
1361///     37.7749,   // San Francisco latitude
1362///     -122.4194, // San Francisco longitude
1363///     0.0,       // elevation (meters)
1364///     Some(RefractionCorrection::standard()),
1365///     &time_parts
1366/// ).unwrap();
1367///
1368/// println!("Azimuth: {:.3}°", position.azimuth());
1369/// ```
1370pub fn spa_with_time_dependent_parts(
1371    latitude: f64,
1372    longitude: f64,
1373    elevation: f64,
1374    refraction: Option<RefractionCorrection>,
1375    time_dependent: &SpaTimeDependent,
1376) -> Result<SolarPosition> {
1377    check_coordinates(latitude, longitude)?;
1378
1379    // 3.9. Calculate the observer local hour angle, H (in degrees)
1380    // Use pre-computed apparent sidereal time from time_dependent parts
1381    let nu_degrees = time_dependent.nu_degrees;
1382
1383    // Use pre-computed geocentric sun right ascension and declination
1384    let h_degrees =
1385        normalize_degrees_0_to_360(nu_degrees + longitude - time_dependent.alpha_degrees);
1386    let h = degrees_to_radians(h_degrees);
1387
1388    // 3.10-3.11. Calculate the topocentric sun coordinates
1389    let xi_degrees = 8.794 / (3600.0 * time_dependent.r);
1390    let xi = degrees_to_radians(xi_degrees);
1391    let phi = degrees_to_radians(latitude);
1392    let delta = degrees_to_radians(time_dependent.delta_degrees);
1393
1394    let u = atan(EARTH_FLATTENING_FACTOR * tan(phi));
1395    let y = mul_add(
1396        EARTH_FLATTENING_FACTOR,
1397        sin(u),
1398        (elevation / EARTH_RADIUS_METERS) * sin(phi),
1399    );
1400    let x = mul_add(elevation / EARTH_RADIUS_METERS, cos(phi), cos(u));
1401
1402    let delta_alpha_prime_degrees = radians_to_degrees(atan2(
1403        -x * sin(xi) * sin(h),
1404        mul_add(x * sin(xi), -cos(h), cos(delta)),
1405    ));
1406
1407    let delta_prime = radians_to_degrees(atan2(
1408        mul_add(y, -sin(xi), sin(delta)) * cos(degrees_to_radians(delta_alpha_prime_degrees)),
1409        mul_add(x * sin(xi), -cos(h), cos(delta)),
1410    ));
1411
1412    // 3.12. Calculate the topocentric local hour angle, H' (in degrees)
1413    let h_prime_degrees = h_degrees - delta_alpha_prime_degrees;
1414
1415    // 3.13. Calculate the topocentric zenith and azimuth angles
1416    let zenith_angle = radians_to_degrees(acos(mul_add(
1417        sin(degrees_to_radians(latitude)),
1418        sin(degrees_to_radians(delta_prime)),
1419        cos(degrees_to_radians(latitude))
1420            * cos(degrees_to_radians(delta_prime))
1421            * cos(degrees_to_radians(h_prime_degrees)),
1422    )));
1423
1424    // 3.14. Calculate the topocentric azimuth angle
1425    let azimuth = normalize_degrees_0_to_360(
1426        180.0
1427            + radians_to_degrees(atan2(
1428                sin(degrees_to_radians(h_prime_degrees)),
1429                cos(degrees_to_radians(h_prime_degrees)) * sin(degrees_to_radians(latitude))
1430                    - tan(degrees_to_radians(delta_prime)) * cos(degrees_to_radians(latitude)),
1431            )),
1432    );
1433
1434    // Apply atmospheric refraction if requested
1435    let elevation_angle = 90.0 - zenith_angle;
1436    let final_zenith = refraction.map_or(zenith_angle, |correction| {
1437        if elevation_angle > SUNRISE_SUNSET_ANGLE {
1438            let pressure = correction.pressure();
1439            let temperature = correction.temperature();
1440            // Apply refraction correction following the same pattern as calculate_topocentric_zenith_angle
1441            zenith_angle
1442                - (pressure / 1010.0) * (283.0 / (273.0 + temperature)) * 1.02
1443                    / (60.0
1444                        * tan(degrees_to_radians(
1445                            elevation_angle + 10.3 / (elevation_angle + 5.11),
1446                        )))
1447        } else {
1448            zenith_angle
1449        }
1450    });
1451
1452    SolarPosition::new(azimuth, final_zenith)
1453}
1454
1455#[cfg(all(test, feature = "chrono", feature = "std"))]
1456mod tests {
1457    use super::*;
1458    use chrono::{DateTime, FixedOffset};
1459    use std::collections::HashSet;
1460
1461    #[test]
1462    fn test_spa_basic_functionality() {
1463        let datetime = "2023-06-21T12:00:00Z"
1464            .parse::<DateTime<FixedOffset>>()
1465            .unwrap();
1466
1467        let result = solar_position(
1468            datetime,
1469            37.7749, // San Francisco
1470            -122.4194,
1471            0.0,
1472            69.0,
1473            Some(RefractionCorrection::new(1013.25, 15.0).unwrap()),
1474        );
1475
1476        assert!(result.is_ok());
1477        let position = result.unwrap();
1478        assert!(position.azimuth() >= 0.0 && position.azimuth() <= 360.0);
1479        assert!(position.zenith_angle() >= 0.0 && position.zenith_angle() <= 180.0);
1480    }
1481
1482    #[test]
1483    fn test_sunrise_sunset_multiple() {
1484        let datetime = "2023-06-21T12:00:00Z"
1485            .parse::<DateTime<FixedOffset>>()
1486            .unwrap();
1487        let horizons = [
1488            Horizon::SunriseSunset,
1489            Horizon::CivilTwilight,
1490            Horizon::NauticalTwilight,
1491        ];
1492
1493        let results: Result<Vec<_>> = sunrise_sunset_multiple(
1494            datetime,
1495            37.7749,   // San Francisco latitude
1496            -122.4194, // San Francisco longitude
1497            69.0,      // deltaT (seconds)
1498            horizons.iter().copied(),
1499        )
1500        .collect();
1501
1502        let results = results.unwrap();
1503
1504        // Should have results for all requested horizons
1505        assert_eq!(results.len(), 3);
1506
1507        // Check that we have all expected horizons
1508        let returned_horizons: HashSet<_> = results.iter().map(|(h, _)| *h).collect();
1509        for expected_horizon in horizons {
1510            assert!(returned_horizons.contains(&expected_horizon));
1511        }
1512
1513        // Compare with individual calls to ensure consistency
1514        for (horizon, bulk_result) in &results {
1515            let individual_result =
1516                sunrise_sunset_for_horizon(datetime, 37.7749, -122.4194, 69.0, *horizon).unwrap();
1517
1518            // Results should be identical
1519            match (&individual_result, bulk_result) {
1520                (
1521                    crate::SunriseResult::RegularDay {
1522                        sunrise: s1,
1523                        transit: t1,
1524                        sunset: ss1,
1525                    },
1526                    crate::SunriseResult::RegularDay {
1527                        sunrise: s2,
1528                        transit: t2,
1529                        sunset: ss2,
1530                    },
1531                ) => {
1532                    assert_eq!(s1, s2);
1533                    assert_eq!(t1, t2);
1534                    assert_eq!(ss1, ss2);
1535                }
1536                (
1537                    crate::SunriseResult::AllDay { transit: t1 },
1538                    crate::SunriseResult::AllDay { transit: t2 },
1539                )
1540                | (
1541                    crate::SunriseResult::AllNight { transit: t1 },
1542                    crate::SunriseResult::AllNight { transit: t2 },
1543                ) => {
1544                    assert_eq!(t1, t2);
1545                }
1546                _ => panic!("Bulk and individual results differ in type for {horizon:?}"),
1547            }
1548        }
1549    }
1550
1551    #[test]
1552    fn test_spa_no_refraction() {
1553        let datetime = "2023-06-21T12:00:00Z"
1554            .parse::<DateTime<FixedOffset>>()
1555            .unwrap();
1556
1557        let result = solar_position(datetime, 37.7749, -122.4194, 0.0, 69.0, None);
1558
1559        assert!(result.is_ok());
1560        let position = result.unwrap();
1561        assert!(position.azimuth() >= 0.0 && position.azimuth() <= 360.0);
1562        assert!(position.zenith_angle() >= 0.0 && position.zenith_angle() <= 180.0);
1563    }
1564
1565    #[test]
1566    fn test_spa_coordinate_validation() {
1567        let datetime = "2023-06-21T12:00:00Z"
1568            .parse::<DateTime<FixedOffset>>()
1569            .unwrap();
1570
1571        // Invalid latitude
1572        assert!(solar_position(
1573            datetime,
1574            95.0,
1575            0.0,
1576            0.0,
1577            0.0,
1578            Some(RefractionCorrection::new(1013.25, 15.0).unwrap())
1579        )
1580        .is_err());
1581
1582        // Invalid longitude
1583        assert!(solar_position(
1584            datetime,
1585            0.0,
1586            185.0,
1587            0.0,
1588            0.0,
1589            Some(RefractionCorrection::new(1013.25, 15.0).unwrap())
1590        )
1591        .is_err());
1592    }
1593
1594    #[test]
1595    fn test_sunrise_sunset_basic() {
1596        let date = "2023-06-21T00:00:00Z"
1597            .parse::<DateTime<FixedOffset>>()
1598            .unwrap();
1599
1600        let result = sunrise_sunset(date, 37.7749, -122.4194, 69.0, -0.833);
1601        assert!(result.is_ok());
1602
1603        let result =
1604            sunrise_sunset_for_horizon(date, 37.7749, -122.4194, 69.0, Horizon::SunriseSunset);
1605        assert!(result.is_ok());
1606    }
1607
1608    #[test]
1609    fn test_horizon_enum() {
1610        assert_eq!(Horizon::SunriseSunset.elevation_angle(), -0.83337);
1611        assert_eq!(Horizon::CivilTwilight.elevation_angle(), -6.0);
1612        assert_eq!(Horizon::Custom(-10.5).elevation_angle(), -10.5);
1613    }
1614}