solar_positioning/spa/
mod.rs

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