Skip to main content

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