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