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