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