solar_positioning/spa/
mod.rs

1//! SPA (Solar Position Algorithm) implementation.
2//!
3//! High-accuracy solar positioning based on the NREL algorithm by Reda & Andreas (2003).
4//! Provides uncertainties of ±0.0003 degrees for years -2000 to 6000.
5
6#![allow(clippy::similar_names)]
7#![allow(clippy::many_single_char_names)]
8#![allow(clippy::unreadable_literal)]
9
10use crate::error::{check_coordinates, check_refraction_params_usable};
11use crate::math::{
12    acos, asin, atan, atan2, cos, degrees_to_radians, floor, normalize_degrees_0_to_360,
13    polynomial, radians_to_degrees, sin, tan,
14};
15use crate::time::JulianDate;
16use crate::{Error, Horizon, Result, SolarPosition};
17use chrono::{DateTime, TimeZone};
18
19pub mod coefficients;
20use coefficients::{
21    NUTATION_COEFFS, OBLIQUITY_COEFFS, TERMS_B, TERMS_L, TERMS_PE, TERMS_R, TERMS_Y,
22};
23
24/// Standard sunrise/sunset elevation angle (accounts for refraction and sun's radius).
25const SUNRISE_SUNSET_ANGLE: f64 = -0.83337;
26
27/// Aberration constant in arcseconds.
28const ABERRATION_CONSTANT: f64 = -20.4898;
29
30/// Earth flattening factor (WGS84).
31const EARTH_FLATTENING_FACTOR: f64 = 0.99664719;
32
33/// Earth radius in meters (WGS84).
34const EARTH_RADIUS_METERS: f64 = 6378140.0;
35
36/// Seconds per hour conversion factor.
37const SECONDS_PER_HOUR: f64 = 3600.0;
38
39/// Calculate solar position using the SPA algorithm.
40///
41/// # Arguments
42/// * `datetime` - Date and time with timezone
43/// * `latitude` - Observer latitude in degrees (-90 to +90)
44/// * `longitude` - Observer longitude in degrees (-180 to +180)
45/// * `elevation` - Observer elevation in meters above sea level
46/// * `delta_t` - ΔT in seconds (difference between TT and UT1)
47/// * `pressure` - Atmospheric pressure in millibars (for refraction correction)
48/// * `temperature` - Temperature in °C (for refraction correction)
49///
50/// # Returns
51/// Solar position or error
52///
53/// # Errors
54/// Returns error for invalid coordinates (latitude outside ±90°, longitude outside ±180°)
55///
56/// # Example
57/// ```rust
58/// use solar_positioning::spa;
59/// use chrono::{DateTime, FixedOffset};
60///
61/// let datetime = "2023-06-21T12:00:00-07:00".parse::<DateTime<FixedOffset>>().unwrap();
62/// let position = spa::solar_position(
63///     datetime,
64///     37.7749,     // San Francisco latitude
65///     -122.4194,   // San Francisco longitude
66///     0.0,         // elevation (meters)
67///     69.0,        // deltaT (seconds)
68///     1013.25,     // pressure (millibars)
69///     15.0         // temperature (°C)
70/// ).unwrap();
71///
72/// println!("Azimuth: {:.3}°", position.azimuth());
73/// println!("Elevation: {:.3}°", position.elevation_angle());
74/// ```
75pub fn solar_position<Tz: TimeZone>(
76    datetime: DateTime<Tz>,
77    latitude: f64,
78    longitude: f64,
79    elevation: f64,
80    delta_t: f64,
81    pressure: f64,
82    temperature: f64,
83) -> Result<SolarPosition> {
84    check_coordinates(latitude, longitude)?;
85
86    // Convert datetime to Julian date components
87    let jd = JulianDate::from_datetime(&datetime, delta_t)?;
88
89    let jme = jd.julian_ephemeris_millennium();
90    let jce = jd.julian_ephemeris_century();
91
92    // Step 1: Calculate Earth heliocentric longitude, L
93    let l_terms = calculate_lbr_terms(jme, TERMS_L);
94    let l_degrees =
95        normalize_degrees_0_to_360(radians_to_degrees(calculate_lbr_polynomial(jme, &l_terms)));
96
97    // Step 2: Calculate Earth heliocentric latitude, B
98    let b_terms = calculate_lbr_terms(jme, TERMS_B);
99    let b_degrees =
100        normalize_degrees_0_to_360(radians_to_degrees(calculate_lbr_polynomial(jme, &b_terms)));
101
102    // Step 3: Calculate Earth radius vector, R
103    let r_terms = calculate_lbr_terms(jme, TERMS_R);
104    let r = calculate_lbr_polynomial(jme, &r_terms);
105
106    if r == 0.0 {
107        return Err(Error::computation_error("Earth radius vector is zero"));
108    }
109
110    // Step 4: Calculate geocentric longitude and latitude
111    let theta_degrees = normalize_degrees_0_to_360(l_degrees + 180.0);
112    let beta_degrees = -b_degrees;
113    let beta = degrees_to_radians(beta_degrees);
114
115    // Step 5: Calculate nutation
116    let x_terms = calculate_nutation_terms(jce);
117    let delta_psi_epsilon = calculate_delta_psi_epsilon(jce, &x_terms);
118
119    // Step 6: Calculate true obliquity of the ecliptic
120    let epsilon_degrees =
121        calculate_true_obliquity_of_ecliptic(&jd, delta_psi_epsilon.delta_epsilon);
122    let epsilon = degrees_to_radians(epsilon_degrees);
123
124    // Step 7: Calculate aberration correction
125    let delta_tau = ABERRATION_CONSTANT / (SECONDS_PER_HOUR * r);
126
127    // Step 8: Calculate apparent sun longitude
128    let lambda_degrees = theta_degrees + delta_psi_epsilon.delta_psi + delta_tau;
129    let lambda = degrees_to_radians(lambda_degrees);
130
131    // Step 9: Calculate apparent sidereal time at Greenwich
132    let nu_degrees = calculate_apparent_sidereal_time_at_greenwich(
133        &jd,
134        delta_psi_epsilon.delta_psi,
135        epsilon_degrees,
136    );
137
138    // Step 10: Calculate geocentric sun right ascension
139    let alpha_degrees = calculate_geocentric_sun_right_ascension(beta, epsilon, lambda);
140
141    // Step 11: Calculate geocentric sun declination
142    let delta_degrees =
143        radians_to_degrees(calculate_geocentric_sun_declination(beta, epsilon, lambda));
144
145    // Step 12: Calculate observer local hour angle
146    let h_degrees = normalize_degrees_0_to_360(nu_degrees + longitude - alpha_degrees);
147    let h = degrees_to_radians(h_degrees);
148
149    // Step 13: Calculate topocentric sun right ascension and declination
150    let xi_degrees = 8.794 / (3600.0 * r);
151    let xi = degrees_to_radians(xi_degrees);
152    let phi = degrees_to_radians(latitude);
153    let delta = degrees_to_radians(delta_degrees);
154
155    let u = atan(EARTH_FLATTENING_FACTOR * tan(phi));
156    let x = cos(u) + elevation * cos(phi) / EARTH_RADIUS_METERS;
157    let y = EARTH_FLATTENING_FACTOR.mul_add(sin(u), (elevation * sin(phi)) / EARTH_RADIUS_METERS);
158
159    let x1 = (x * sin(xi)).mul_add(-cos(h), cos(delta));
160    let delta_alpha_degrees = radians_to_degrees(atan2(-x * sin(xi) * sin(h), x1));
161    let delta_prime = atan2(
162        y.mul_add(-sin(xi), sin(delta)) * cos(degrees_to_radians(delta_alpha_degrees)),
163        x1,
164    );
165
166    // Step 14: Calculate topocentric local hour angle
167    let h_prime_degrees = h_degrees - delta_alpha_degrees;
168    let h_prime = degrees_to_radians(h_prime_degrees);
169
170    // Step 15: Calculate topocentric solar position
171    calculate_topocentric_solar_position(pressure, temperature, phi, delta_prime, h_prime)
172}
173
174/// Calculate solar position using the SPA algorithm without refraction correction.
175///
176/// This is identical to the main function but doesn't apply atmospheric refraction correction.
177///
178/// # Errors
179/// Returns error for invalid coordinates (latitude outside ±90°, longitude outside ±180°)
180pub fn solar_position_no_refraction<Tz: TimeZone>(
181    datetime: DateTime<Tz>,
182    latitude: f64,
183    longitude: f64,
184    elevation: f64,
185    delta_t: f64,
186) -> Result<SolarPosition> {
187    solar_position(
188        datetime,
189        latitude,
190        longitude,
191        elevation,
192        delta_t,
193        f64::NAN,
194        f64::NAN,
195    )
196}
197
198/// Represents nutation corrections for longitude and obliquity.
199#[derive(Debug, Clone, Copy)]
200struct DeltaPsiEpsilon {
201    delta_psi: f64,
202    delta_epsilon: f64,
203}
204
205/// Calculate L, B, R terms from the coefficient tables.
206fn calculate_lbr_terms(jme: f64, term_coeffs: &[&[&[f64; 3]]]) -> Vec<f64> {
207    let mut lbr_terms = vec![0.0; term_coeffs.len()];
208
209    for (i, term_set) in term_coeffs.iter().enumerate() {
210        let mut lbr_sum = 0.0;
211        for term in *term_set {
212            lbr_sum += term[0] * cos(term[2].mul_add(jme, term[1]));
213        }
214        lbr_terms[i] = lbr_sum;
215    }
216
217    lbr_terms
218}
219
220/// Calculate L, B, or R polynomial from the terms.
221fn calculate_lbr_polynomial(jme: f64, terms: &[f64]) -> f64 {
222    polynomial(terms, jme) / 1e8
223}
224
225/// Calculate nutation terms (X values).
226fn calculate_nutation_terms(jce: f64) -> Vec<f64> {
227    NUTATION_COEFFS
228        .iter()
229        .map(|coeffs| polynomial(coeffs, jce))
230        .collect()
231}
232
233/// Calculate nutation in longitude and obliquity.
234fn calculate_delta_psi_epsilon(jce: f64, x: &[f64]) -> DeltaPsiEpsilon {
235    let mut delta_psi = 0.0;
236    let mut delta_epsilon = 0.0;
237
238    for (i, pe_term) in TERMS_PE.iter().enumerate() {
239        let xj_yterm_sum = degrees_to_radians(calculate_xj_yterm_sum(i, x));
240
241        // Use Math.fma equivalent: a * b + c
242        let delta_psi_contrib = pe_term[1].mul_add(jce, pe_term[0]) * sin(xj_yterm_sum);
243        let delta_epsilon_contrib = pe_term[3].mul_add(jce, pe_term[2]) * cos(xj_yterm_sum);
244
245        delta_psi += delta_psi_contrib;
246        delta_epsilon += delta_epsilon_contrib;
247    }
248
249    DeltaPsiEpsilon {
250        delta_psi: delta_psi / 36_000_000.0,
251        delta_epsilon: delta_epsilon / 36_000_000.0,
252    }
253}
254
255/// Calculate sum of X[j] * Y[i][j] for nutation.
256fn calculate_xj_yterm_sum(i: usize, x: &[f64]) -> f64 {
257    let mut sum = 0.0;
258    for (j, &x_val) in x.iter().enumerate() {
259        sum += x_val * f64::from(TERMS_Y[i][j]);
260    }
261    sum
262}
263
264/// Calculate true obliquity of the ecliptic.
265fn calculate_true_obliquity_of_ecliptic(jd: &JulianDate, delta_epsilon: f64) -> f64 {
266    let epsilon0 = polynomial(OBLIQUITY_COEFFS, jd.julian_ephemeris_millennium() / 10.0);
267    epsilon0 / 3600.0 + delta_epsilon
268}
269
270/// Calculate apparent sidereal time at Greenwich.
271fn calculate_apparent_sidereal_time_at_greenwich(
272    jd: &JulianDate,
273    delta_psi: f64,
274    epsilon_degrees: f64,
275) -> f64 {
276    let nu0_degrees = normalize_degrees_0_to_360(jd.julian_century().powi(2).mul_add(
277        0.000387933 - jd.julian_century() / 38710000.0,
278        360.98564736629f64.mul_add(jd.julian_date() - 2451545.0, 280.46061837),
279    ));
280
281    delta_psi.mul_add(cos(degrees_to_radians(epsilon_degrees)), nu0_degrees)
282}
283
284/// Calculate geocentric sun right ascension.
285fn calculate_geocentric_sun_right_ascension(
286    beta_rad: f64,
287    epsilon_rad: f64,
288    lambda_rad: f64,
289) -> f64 {
290    let alpha = atan2(
291        sin(lambda_rad).mul_add(cos(epsilon_rad), -(tan(beta_rad) * sin(epsilon_rad))),
292        cos(lambda_rad),
293    );
294    normalize_degrees_0_to_360(radians_to_degrees(alpha))
295}
296
297/// Calculate geocentric sun declination.
298fn calculate_geocentric_sun_declination(beta_rad: f64, epsilon_rad: f64, lambda_rad: f64) -> f64 {
299    asin(sin(beta_rad).mul_add(
300        cos(epsilon_rad),
301        cos(beta_rad) * sin(epsilon_rad) * sin(lambda_rad),
302    ))
303}
304
305/// Calculate topocentric solar position with optional refraction correction.
306fn calculate_topocentric_solar_position(
307    pressure: f64,
308    temperature: f64,
309    phi: f64,
310    delta_prime: f64,
311    h_prime: f64,
312) -> Result<SolarPosition> {
313    // Calculate topocentric zenith angle
314    let sin_phi = sin(phi);
315    let cos_phi = cos(phi);
316    let cos_h_prime = cos(h_prime);
317
318    let e_zero = asin(sin_phi.mul_add(sin(delta_prime), cos_phi * cos(delta_prime) * cos_h_prime));
319    let topocentric_zenith_angle =
320        calculate_topocentric_zenith_angle(pressure, temperature, e_zero);
321
322    // Calculate topocentric azimuth angle
323    let gamma = atan2(
324        sin(h_prime),
325        cos_h_prime.mul_add(sin_phi, -(tan(delta_prime) * cos_phi)),
326    );
327    let gamma_degrees = normalize_degrees_0_to_360(radians_to_degrees(gamma));
328    let topocentric_azimuth_angle = normalize_degrees_0_to_360(gamma_degrees + 180.0);
329
330    SolarPosition::new(topocentric_azimuth_angle, topocentric_zenith_angle)
331}
332
333/// Calculate topocentric zenith angle with optional refraction correction.
334fn calculate_topocentric_zenith_angle(pressure: f64, temperature: f64, e_zero: f64) -> f64 {
335    let e_zero_degrees = radians_to_degrees(e_zero);
336
337    // Apply refraction correction if parameters are usable and sun is visible
338    let do_correct = check_refraction_params_usable(pressure, temperature)
339        && e_zero_degrees > SUNRISE_SUNSET_ANGLE;
340
341    if do_correct {
342        90.0 - e_zero_degrees
343            - (pressure / 1010.0) * (283.0 / (273.0 + temperature)) * 1.02
344                / (60.0
345                    * tan(degrees_to_radians(
346                        e_zero_degrees + 10.3 / (e_zero_degrees + 5.11),
347                    )))
348    } else {
349        90.0 - e_zero_degrees
350    }
351}
352
353/// Calculate sunrise, solar transit, and sunset times using the SPA algorithm.
354///
355/// This follows the NREL SPA algorithm (Reda & Andreas 2003) for calculating
356/// sunrise, transit (solar noon), and sunset times with high accuracy.
357///
358/// # Arguments
359/// * `date` - UTC date for calculations (time is ignored, calculations are for the entire day)
360/// * `latitude` - Observer latitude in degrees (-90 to +90)
361/// * `longitude` - Observer longitude in degrees (-180 to +180)
362/// * `delta_t` - ΔT in seconds (difference between TT and UT1)
363/// * `elevation_angle` - Sun elevation angle for sunrise/sunset in degrees (typically -0.833°)
364///
365/// # Returns
366/// `SunriseResult` variant indicating regular day, polar day, or polar night
367///
368/// # Errors
369/// Returns error for invalid coordinates (latitude outside ±90°, longitude outside ±180°)
370///
371/// # Panics
372/// May panic if date conversion fails (should not occur for valid UTC dates)
373///
374/// # Example
375/// ```rust
376/// use solar_positioning::spa;
377/// use chrono::{DateTime, FixedOffset, NaiveDate, TimeZone};
378///
379/// let date = FixedOffset::east_opt(-7 * 3600).unwrap() // Pacific Time (UTC-7)
380///     .from_local_datetime(&NaiveDate::from_ymd_opt(2023, 6, 21).unwrap()
381///         .and_hms_opt(0, 0, 0).unwrap()).unwrap();
382/// let result = spa::sunrise_sunset(
383///     date,
384///     37.7749,   // San Francisco latitude
385///     -122.4194, // San Francisco longitude
386///     69.0,      // deltaT (seconds)
387///     -0.833     // standard sunrise/sunset angle
388/// ).unwrap();
389/// ```
390pub fn sunrise_sunset<Tz: TimeZone>(
391    date: DateTime<Tz>,
392    latitude: f64,
393    longitude: f64,
394    delta_t: f64,
395    elevation_angle: f64,
396) -> Result<crate::SunriseResult<DateTime<Tz>>> {
397    check_coordinates(latitude, longitude)?;
398
399    // Create Julian date for the day using the original timezone
400    let day_start = date
401        .timezone()
402        .from_local_datetime(&date.date_naive().and_hms_opt(0, 0, 0).unwrap())
403        .unwrap();
404
405    // Implement the complete Java algorithm in one place for accuracy
406    calculate_sunrise_sunset_spa_algorithm(day_start, latitude, longitude, delta_t, elevation_angle)
407}
408
409/// Calculate sunrise/sunset times using NREL SPA algorithm Appendix A.2
410fn calculate_sunrise_sunset_spa_algorithm<Tz: TimeZone>(
411    day: DateTime<Tz>,
412    latitude: f64,
413    longitude: f64,
414    delta_t: f64,
415    elevation_angle: f64,
416) -> Result<crate::SunriseResult<DateTime<Tz>>> {
417    let day_start = day
418        .timezone()
419        .from_local_datetime(&day.date_naive().and_hms_opt(0, 0, 0).unwrap())
420        .unwrap();
421
422    // A.2.1. Calculate the apparent sidereal time at Greenwich at 0 UT
423    let (nu_degrees, delta_psi_epsilon, epsilon_degrees) =
424        calculate_sidereal_time_and_nutation(day_start.clone());
425
426    // A.2.2. Calculate alpha/delta for day before, same day, next day
427    let alpha_deltas =
428        calculate_alpha_deltas_for_three_days(day_start, delta_psi_epsilon, epsilon_degrees)?;
429
430    // Calculate initial transit time and check for polar conditions
431    let m0 = (alpha_deltas[1].alpha - longitude - nu_degrees) / 360.0;
432
433    // Check for polar conditions but don't return early - we need to calculate accurate transit
434    let polar_type = check_polar_conditions_type(latitude, elevation_angle, alpha_deltas[1].delta);
435
436    // Calculate approximate times and apply corrections (needed for accurate transit time)
437    let (m_values, _h0_degrees) =
438        calculate_approximate_times(m0, latitude, elevation_angle, alpha_deltas[1].delta);
439
440    // Apply final corrections to get accurate times
441    let final_result = calculate_final_times(FinalTimeParams {
442        day: day.clone(),
443        m_values,
444        nu_degrees,
445        delta_t,
446        latitude,
447        longitude,
448        elevation_angle,
449        alpha_deltas,
450    });
451
452    // Convert to appropriate polar result type if needed
453    match polar_type {
454        Some(PolarType::AllDay) => {
455            if let crate::SunriseResult::RegularDay { transit, .. } = final_result {
456                Ok(crate::SunriseResult::AllDay { transit })
457            } else {
458                Ok(final_result)
459            }
460        }
461        Some(PolarType::AllNight) => {
462            if let crate::SunriseResult::RegularDay { transit, .. } = final_result {
463                Ok(crate::SunriseResult::AllNight { transit })
464            } else {
465                Ok(final_result)
466            }
467        }
468        None => Ok(final_result),
469    }
470}
471
472/// A.2.1. Calculate apparent sidereal time and nutation parameters
473fn calculate_sidereal_time_and_nutation<Tz: TimeZone>(
474    day_start: DateTime<Tz>,
475) -> (f64, DeltaPsiEpsilon, f64) {
476    let jd = JulianDate::from_datetime(&day_start, 0.0).unwrap();
477    let jce_day = jd.julian_ephemeris_century();
478    let x_terms = calculate_nutation_terms(jce_day);
479    let delta_psi_epsilon = calculate_delta_psi_epsilon(jce_day, &x_terms);
480    let epsilon_degrees =
481        calculate_true_obliquity_of_ecliptic(&jd, delta_psi_epsilon.delta_epsilon);
482    let nu_degrees = calculate_apparent_sidereal_time_at_greenwich(
483        &jd,
484        delta_psi_epsilon.delta_psi,
485        epsilon_degrees,
486    );
487    (nu_degrees, delta_psi_epsilon, epsilon_degrees)
488}
489
490/// A.2.2. Calculate alpha/delta for day before, same day, and next day
491fn calculate_alpha_deltas_for_three_days<Tz: TimeZone>(
492    day_start: DateTime<Tz>,
493    delta_psi_epsilon: DeltaPsiEpsilon,
494    epsilon_degrees: f64,
495) -> Result<[AlphaDelta; 3]> {
496    let mut alpha_deltas = [AlphaDelta {
497        alpha: 0.0,
498        delta: 0.0,
499    }; 3];
500    for (i, alpha_delta) in alpha_deltas.iter_mut().enumerate() {
501        let current_jd = JulianDate::from_datetime(&day_start, 0.0)?.add_days((i as f64) - 1.0);
502        let current_jme = current_jd.julian_ephemeris_millennium();
503        let ad = calculate_alpha_delta(current_jme, delta_psi_epsilon.delta_psi, epsilon_degrees);
504        *alpha_delta = ad;
505    }
506    Ok(alpha_deltas)
507}
508
509/// Check for polar day/night conditions and return early if they apply
510fn check_polar_conditions<Tz: TimeZone>(
511    day: DateTime<Tz>,
512    m0: f64,
513    latitude: f64,
514    elevation_angle: f64,
515    delta1: f64,
516) -> Option<crate::SunriseResult<DateTime<Tz>>> {
517    let phi = degrees_to_radians(latitude);
518    let delta1_rad = degrees_to_radians(delta1);
519    let elevation_rad = degrees_to_radians(elevation_angle);
520
521    let acos_arg =
522        sin(phi).mul_add(-sin(delta1_rad), sin(elevation_rad)) / (cos(phi) * cos(delta1_rad));
523
524    if acos_arg < -1.0 {
525        let transit = add_fraction_of_day(day, m0);
526        Some(crate::SunriseResult::AllDay { transit })
527    } else if acos_arg > 1.0 {
528        let transit = add_fraction_of_day(day, m0);
529        Some(crate::SunriseResult::AllNight { transit })
530    } else {
531        None
532    }
533}
534
535/// Enum for polar condition types
536#[derive(Debug, Clone, Copy)]
537enum PolarType {
538    AllDay,
539    AllNight,
540}
541
542/// Check for polar day/night conditions and return the type (but don't return early)
543fn check_polar_conditions_type(
544    latitude: f64,
545    elevation_angle: f64,
546    delta1: f64,
547) -> Option<PolarType> {
548    let phi = degrees_to_radians(latitude);
549    let elevation_rad = degrees_to_radians(elevation_angle);
550    let delta1_rad = degrees_to_radians(delta1);
551
552    let acos_arg =
553        sin(phi).mul_add(-sin(delta1_rad), sin(elevation_rad)) / (cos(phi) * cos(delta1_rad));
554
555    if acos_arg < -1.0 {
556        Some(PolarType::AllDay)
557    } else if acos_arg > 1.0 {
558        Some(PolarType::AllNight)
559    } else {
560        None
561    }
562}
563
564/// A.2.5-6. Calculate approximate times for transit, sunrise, sunset
565fn calculate_approximate_times(
566    m0: f64,
567    latitude: f64,
568    elevation_angle: f64,
569    delta1: f64,
570) -> ([f64; 3], f64) {
571    let phi = degrees_to_radians(latitude);
572    let delta1_rad = degrees_to_radians(delta1);
573    let elevation_rad = degrees_to_radians(elevation_angle);
574
575    let acos_arg =
576        sin(phi).mul_add(-sin(delta1_rad), sin(elevation_rad)) / (cos(phi) * cos(delta1_rad));
577    let h0 = acos(acos_arg);
578    let h0_degrees = radians_to_degrees(h0).min(180.0);
579
580    let mut m = [0.0; 3];
581    m[0] = normalize_to_unit_range(m0);
582    m[1] = normalize_to_unit_range(m0 - h0_degrees / 360.0);
583    m[2] = normalize_to_unit_range(m0 + h0_degrees / 360.0);
584
585    (m, h0_degrees)
586}
587
588/// A.2.8-15. Calculate final accurate times using corrections
589fn calculate_final_times<Tz: TimeZone>(
590    params: FinalTimeParams<Tz>,
591) -> crate::SunriseResult<DateTime<Tz>> {
592    // A.2.8. Calculate sidereal times
593    let mut nu = [0.0; 3];
594    for (i, nu_item) in nu.iter_mut().enumerate() {
595        *nu_item = 360.985647f64.mul_add(params.m_values[i], params.nu_degrees);
596    }
597
598    // A.2.9. Calculate terms with deltaT correction
599    let mut n = [0.0; 3];
600    for (i, n_item) in n.iter_mut().enumerate() {
601        *n_item = params.m_values[i] + params.delta_t / 86400.0;
602    }
603
604    // A.2.10. Calculate α'i and δ'i using quadratic interpolation
605    let alpha_delta_primes = calculate_interpolated_alpha_deltas(&params.alpha_deltas, &n);
606
607    // A.2.11. Calculate local hour angles
608    let mut h_prime = [0.0; 3];
609    for i in 0..3 {
610        let h_prime_i = nu[i] + params.longitude - alpha_delta_primes[i].alpha;
611        h_prime[i] = limit_h_prime(h_prime_i);
612    }
613
614    // A.2.12. Calculate sun altitudes
615    let phi = degrees_to_radians(params.latitude);
616    let mut h = [0.0; 3];
617    for i in 0..3 {
618        let delta_prime_rad = degrees_to_radians(alpha_delta_primes[i].delta);
619        h[i] = radians_to_degrees(asin(sin(phi).mul_add(
620            sin(delta_prime_rad),
621            cos(phi) * cos(delta_prime_rad) * cos(degrees_to_radians(h_prime[i])),
622        )));
623    }
624
625    // A.2.13-15. Calculate final times
626    let t = params.m_values[0] - h_prime[0] / 360.0;
627    let r = params.m_values[1]
628        + (h[1] - params.elevation_angle)
629            / (360.0
630                * cos(degrees_to_radians(alpha_delta_primes[1].delta))
631                * cos(phi)
632                * sin(degrees_to_radians(h_prime[1])));
633    let s = params.m_values[2]
634        + (h[2] - params.elevation_angle)
635            / (360.0
636                * cos(degrees_to_radians(alpha_delta_primes[2].delta))
637                * cos(phi)
638                * sin(degrees_to_radians(h_prime[2])));
639
640    let sunrise = add_fraction_of_day(params.day.clone(), r);
641    let transit = add_fraction_of_day(params.day.clone(), t);
642    let sunset = add_fraction_of_day(params.day, s);
643
644    crate::SunriseResult::RegularDay {
645        sunrise,
646        transit,
647        sunset,
648    }
649}
650
651/// A.2.10. Calculate interpolated alpha/delta values using quadratic interpolation
652fn calculate_interpolated_alpha_deltas(
653    alpha_deltas: &[AlphaDelta; 3],
654    n: &[f64; 3],
655) -> [AlphaDelta; 3] {
656    let a = limit_if_necessary(alpha_deltas[1].alpha - alpha_deltas[0].alpha);
657    let a_prime = limit_if_necessary(alpha_deltas[1].delta - alpha_deltas[0].delta);
658
659    let b = limit_if_necessary(alpha_deltas[2].alpha - alpha_deltas[1].alpha);
660    let b_prime = limit_if_necessary(alpha_deltas[2].delta - alpha_deltas[1].delta);
661
662    let c = b - a;
663    let c_prime = b_prime - a_prime;
664
665    let mut alpha_delta_primes = [AlphaDelta {
666        alpha: 0.0,
667        delta: 0.0,
668    }; 3];
669    for i in 0..3 {
670        alpha_delta_primes[i].alpha =
671            alpha_deltas[1].alpha + (n[i] * (c.mul_add(n[i], a + b))) / 2.0;
672        alpha_delta_primes[i].delta =
673            alpha_deltas[1].delta + (n[i] * (c_prime.mul_add(n[i], a_prime + b_prime))) / 2.0;
674    }
675    alpha_delta_primes
676}
677
678#[derive(Debug, Clone, Copy)]
679struct AlphaDelta {
680    alpha: f64,
681    delta: f64,
682}
683
684/// Parameters for calculating final sunrise/sunset times
685#[derive(Debug, Clone)]
686struct FinalTimeParams<Tz: TimeZone> {
687    day: DateTime<Tz>,
688    m_values: [f64; 3],
689    nu_degrees: f64,
690    delta_t: f64,
691    latitude: f64,
692    longitude: f64,
693    elevation_angle: f64,
694    alpha_deltas: [AlphaDelta; 3],
695}
696
697/// Calculate sunrise, solar transit, and sunset times for a specific horizon type.
698///
699/// This is a convenience function that uses predefined elevation angles for common
700/// sunrise/twilight calculations.
701///
702/// # Arguments
703/// * `date` - UTC date for calculations
704/// * `latitude` - Observer latitude in degrees
705/// * `longitude` - Observer longitude in degrees
706/// * `delta_t` - ΔT in seconds
707/// * `horizon` - Horizon type (sunrise/sunset, civil twilight, etc.)
708///
709/// # Errors
710/// Returns error for invalid coordinates, dates, or computation failures.
711///
712/// # Example
713/// ```rust
714/// use solar_positioning::{spa, Horizon};
715/// use chrono::{FixedOffset, NaiveDate, TimeZone};
716///
717/// let date = FixedOffset::east_opt(-7 * 3600).unwrap() // Pacific Time (UTC-7)
718///     .from_local_datetime(&NaiveDate::from_ymd_opt(2023, 6, 21).unwrap()
719///         .and_hms_opt(0, 0, 0).unwrap()).unwrap();
720///
721/// // Standard sunrise/sunset
722/// let sunrise_result = spa::sunrise_sunset_for_horizon(
723///     date, 37.7749, -122.4194, 69.0, Horizon::SunriseSunset
724/// ).unwrap();
725///
726/// // Civil twilight
727/// let twilight_result = spa::sunrise_sunset_for_horizon(
728///     date, 37.7749, -122.4194, 69.0, Horizon::CivilTwilight
729/// ).unwrap();
730/// ```
731pub fn sunrise_sunset_for_horizon<Tz: TimeZone>(
732    date: DateTime<Tz>,
733    latitude: f64,
734    longitude: f64,
735    delta_t: f64,
736    horizon: Horizon,
737) -> Result<crate::SunriseResult<DateTime<Tz>>> {
738    sunrise_sunset(
739        date,
740        latitude,
741        longitude,
742        delta_t,
743        horizon.elevation_angle(),
744    )
745}
746
747/// Calculate alpha (right ascension) and delta (declination) for a given JME using full SPA algorithm
748fn calculate_alpha_delta(jme: f64, delta_psi: f64, epsilon_degrees: f64) -> AlphaDelta {
749    // Follow Java calculateAlphaDelta exactly
750
751    // calculate Earth heliocentric latitude, B
752    let b_terms = calculate_lbr_terms(jme, TERMS_B);
753    let b_degrees =
754        normalize_degrees_0_to_360(radians_to_degrees(calculate_lbr_polynomial(jme, &b_terms)));
755
756    // calculate Earth radius vector, R
757    let r_terms = calculate_lbr_terms(jme, TERMS_R);
758    let r = calculate_lbr_polynomial(jme, &r_terms);
759    assert!(r != 0.0);
760
761    // calculate Earth heliocentric longitude, L
762    let l_terms = calculate_lbr_terms(jme, TERMS_L);
763    let l_degrees =
764        normalize_degrees_0_to_360(radians_to_degrees(calculate_lbr_polynomial(jme, &l_terms)));
765
766    // calculate geocentric longitude, theta
767    let theta_degrees = normalize_degrees_0_to_360(l_degrees + 180.0);
768
769    // calculate geocentric latitude, beta
770    let beta_degrees = -b_degrees;
771    let beta = degrees_to_radians(beta_degrees);
772    let epsilon = degrees_to_radians(epsilon_degrees);
773
774    // calculate aberration correction
775    let delta_tau = ABERRATION_CONSTANT / (SECONDS_PER_HOUR * r);
776
777    // calculate the apparent sun longitude
778    let lambda_degrees = theta_degrees + delta_psi + delta_tau;
779    let lambda = degrees_to_radians(lambda_degrees);
780
781    // Calculate the geocentric sun right ascension and declination (like Java)
782    let alpha_degrees = calculate_geocentric_sun_right_ascension(beta, epsilon, lambda);
783    let delta_degrees =
784        radians_to_degrees(calculate_geocentric_sun_declination(beta, epsilon, lambda));
785
786    AlphaDelta {
787        alpha: alpha_degrees,
788        delta: delta_degrees,
789    }
790}
791
792/// Normalize value to [0, 1) range using the same logic as the removed `limit_to` function
793fn normalize_to_unit_range(val: f64) -> f64 {
794    let divided = val;
795    let limited = divided - divided.floor();
796    if limited < 0.0 {
797        limited + 1.0
798    } else {
799        limited
800    }
801}
802
803/// Add a fraction of a day to a date
804fn add_fraction_of_day<Tz: TimeZone>(day: DateTime<Tz>, fraction: f64) -> DateTime<Tz> {
805    // Match Java implementation exactly:
806    // 1. Truncate to start of day (like Java's truncatedTo(ChronoUnit.DAYS))
807    // 2. Calculate milliseconds as int (truncating fractional milliseconds)
808    // 3. Add the milliseconds
809    const MS_PER_DAY: i32 = 24 * 60 * 60 * 1000; // Use i32 like Java
810    let millis_plus = (f64::from(MS_PER_DAY) * fraction) as i32; // Cast to i32 like Java
811
812    let day_start = day
813        .timezone()
814        .from_local_datetime(&day.date_naive().and_hms_opt(0, 0, 0).unwrap())
815        .unwrap();
816
817    day_start + chrono::Duration::milliseconds(i64::from(millis_plus))
818}
819
820/// Limit to 0..1 if absolute value > 2 (Java limitIfNecessary)
821fn limit_if_necessary(val: f64) -> f64 {
822    if val.abs() > 2.0 {
823        normalize_to_unit_range(val)
824    } else {
825        val
826    }
827}
828
829/// Limit H' values according to A.2.11
830fn limit_h_prime(h_prime: f64) -> f64 {
831    let normalized = h_prime / 360.0;
832    let limited = 360.0 * (normalized - floor(normalized));
833
834    if limited < -180.0 {
835        limited + 360.0
836    } else if limited > 180.0 {
837        limited - 360.0
838    } else {
839        limited
840    }
841}
842
843/// Calculate sunrise/sunset times for multiple horizons efficiently.
844///
845/// Returns an iterator that yields `(Horizon, SunriseResult)` pairs. This is more
846/// efficient than separate calls as it reuses expensive astronomical calculations.
847///
848/// # Arguments
849/// * `date` - UTC date and time
850/// * `latitude` - Observer latitude in degrees (-90 to +90)
851/// * `longitude` - Observer longitude in degrees (-180 to +180)
852/// * `delta_t` - ΔT in seconds (difference between TT and UT1)
853/// * `horizons` - Iterator of horizon types to calculate
854///
855/// # Returns
856/// Iterator over `Result<(Horizon, SunriseResult)>`
857///
858/// # Panics
859/// May panic if date cannot be converted to start of day (unlikely with valid dates).
860///
861/// # Example
862/// ```rust
863/// use solar_positioning::{spa, Horizon};
864/// use chrono::{DateTime, FixedOffset};
865///
866/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
867/// let datetime = "2023-06-21T12:00:00-07:00".parse::<DateTime<FixedOffset>>().unwrap();
868/// let horizons = [
869///     Horizon::SunriseSunset,
870///     Horizon::CivilTwilight,
871///     Horizon::NauticalTwilight,
872/// ];
873///
874/// let results: Result<Vec<_>, _> = spa::sunrise_sunset_multiple(
875///     datetime,
876///     37.7749,     // San Francisco latitude
877///     -122.4194,   // San Francisco longitude
878///     69.0,        // deltaT (seconds)
879///     horizons.iter().copied()
880/// ).collect();
881///
882/// for (horizon, result) in results? {
883///     println!("{:?}: {:?}", horizon, result);
884/// }
885/// # Ok(())
886/// # }
887/// ```
888pub fn sunrise_sunset_multiple<Tz, H>(
889    date: DateTime<Tz>,
890    latitude: f64,
891    longitude: f64,
892    delta_t: f64,
893    horizons: H,
894) -> impl Iterator<Item = Result<(Horizon, crate::SunriseResult<DateTime<Tz>>)>>
895where
896    Tz: TimeZone,
897    H: IntoIterator<Item = Horizon>,
898{
899    // Pre-calculate common values once for efficiency
900    let precomputed = (|| -> Result<_> {
901        check_coordinates(latitude, longitude)?;
902
903        let day_start = date
904            .timezone()
905            .from_local_datetime(&date.date_naive().and_hms_opt(0, 0, 0).unwrap())
906            .unwrap();
907        let (nu_degrees, delta_psi_epsilon, epsilon_degrees) =
908            calculate_sidereal_time_and_nutation(day_start.clone());
909        let alpha_deltas =
910            calculate_alpha_deltas_for_three_days(day_start, delta_psi_epsilon, epsilon_degrees)?;
911
912        Ok((nu_degrees, alpha_deltas))
913    })();
914
915    horizons.into_iter().map(move |horizon| match &precomputed {
916        Ok((nu_degrees, alpha_deltas)) => {
917            let result = calculate_sunrise_sunset_spa_algorithm_with_precomputed(
918                date.clone(),
919                latitude,
920                longitude,
921                delta_t,
922                horizon.elevation_angle(),
923                *nu_degrees,
924                *alpha_deltas,
925            );
926            Ok((horizon, result))
927        }
928        Err(e) => Err(e.clone()),
929    })
930}
931
932/// Internal helper that uses precomputed values for efficiency
933fn calculate_sunrise_sunset_spa_algorithm_with_precomputed<Tz: TimeZone>(
934    day: DateTime<Tz>,
935    latitude: f64,
936    longitude: f64,
937    delta_t: f64,
938    elevation_angle: f64,
939    nu_degrees: f64,
940    alpha_deltas: [AlphaDelta; 3],
941) -> crate::SunriseResult<DateTime<Tz>> {
942    // Use the same day_start as the individual calculation to ensure consistency
943    let day_start = day
944        .timezone()
945        .from_local_datetime(&day.date_naive().and_hms_opt(0, 0, 0).unwrap())
946        .unwrap();
947
948    // Calculate initial transit time and check for polar conditions
949    let m0 = (alpha_deltas[1].alpha - longitude - nu_degrees) / 360.0;
950    if let Some(polar_result) = check_polar_conditions(
951        day_start.clone(),
952        m0,
953        latitude,
954        elevation_angle,
955        alpha_deltas[1].delta,
956    ) {
957        return polar_result;
958    }
959
960    // Calculate approximate times and apply corrections
961    let (m_values, _h0_degrees) =
962        calculate_approximate_times(m0, latitude, elevation_angle, alpha_deltas[1].delta);
963
964    // Apply final corrections to get accurate times
965    calculate_final_times(FinalTimeParams {
966        day: day_start,
967        m_values,
968        nu_degrees,
969        delta_t,
970        latitude,
971        longitude,
972        elevation_angle,
973        alpha_deltas,
974    })
975}
976
977#[cfg(test)]
978mod tests {
979    use super::*;
980    use chrono::{DateTime, FixedOffset};
981
982    #[test]
983    fn test_spa_basic_functionality() {
984        let datetime = "2023-06-21T12:00:00Z"
985            .parse::<DateTime<FixedOffset>>()
986            .unwrap();
987
988        let result = solar_position(
989            datetime, 37.7749, // San Francisco
990            -122.4194, 0.0, 69.0, 1013.25, 15.0,
991        );
992
993        assert!(result.is_ok());
994        let position = result.unwrap();
995        assert!(position.azimuth() >= 0.0 && position.azimuth() <= 360.0);
996        assert!(position.zenith_angle() >= 0.0 && position.zenith_angle() <= 180.0);
997    }
998
999    #[test]
1000    fn test_sunrise_sunset_multiple() {
1001        let datetime = "2023-06-21T12:00:00Z"
1002            .parse::<DateTime<FixedOffset>>()
1003            .unwrap();
1004        let horizons = [
1005            Horizon::SunriseSunset,
1006            Horizon::CivilTwilight,
1007            Horizon::NauticalTwilight,
1008        ];
1009
1010        let results: std::result::Result<Vec<_>, _> = sunrise_sunset_multiple(
1011            datetime,
1012            37.7749,   // San Francisco latitude
1013            -122.4194, // San Francisco longitude
1014            69.0,      // deltaT (seconds)
1015            horizons.iter().copied(),
1016        )
1017        .collect();
1018
1019        let results = results.unwrap();
1020
1021        // Should have results for all requested horizons
1022        assert_eq!(results.len(), 3);
1023
1024        // Check that we have all expected horizons
1025        let returned_horizons: std::collections::HashSet<_> =
1026            results.iter().map(|(h, _)| *h).collect();
1027        for expected_horizon in horizons {
1028            assert!(returned_horizons.contains(&expected_horizon));
1029        }
1030
1031        // Compare with individual calls to ensure consistency
1032        for (horizon, bulk_result) in &results {
1033            let individual_result =
1034                sunrise_sunset_for_horizon(datetime, 37.7749, -122.4194, 69.0, *horizon).unwrap();
1035
1036            // Results should be identical
1037            match (&individual_result, bulk_result) {
1038                (
1039                    crate::SunriseResult::RegularDay {
1040                        sunrise: s1,
1041                        transit: t1,
1042                        sunset: ss1,
1043                    },
1044                    crate::SunriseResult::RegularDay {
1045                        sunrise: s2,
1046                        transit: t2,
1047                        sunset: ss2,
1048                    },
1049                ) => {
1050                    assert_eq!(s1, s2);
1051                    assert_eq!(t1, t2);
1052                    assert_eq!(ss1, ss2);
1053                }
1054                (
1055                    crate::SunriseResult::AllDay { transit: t1 },
1056                    crate::SunriseResult::AllDay { transit: t2 },
1057                )
1058                | (
1059                    crate::SunriseResult::AllNight { transit: t1 },
1060                    crate::SunriseResult::AllNight { transit: t2 },
1061                ) => {
1062                    assert_eq!(t1, t2);
1063                }
1064                _ => panic!("Bulk and individual results differ in type for {horizon:?}"),
1065            }
1066        }
1067    }
1068
1069    #[test]
1070    fn test_spa_no_refraction() {
1071        let datetime = "2023-06-21T12:00:00Z"
1072            .parse::<DateTime<FixedOffset>>()
1073            .unwrap();
1074
1075        let result = solar_position_no_refraction(datetime, 37.7749, -122.4194, 0.0, 69.0);
1076
1077        assert!(result.is_ok());
1078        let position = result.unwrap();
1079        assert!(position.azimuth() >= 0.0 && position.azimuth() <= 360.0);
1080        assert!(position.zenith_angle() >= 0.0 && position.zenith_angle() <= 180.0);
1081    }
1082
1083    #[test]
1084    fn test_spa_coordinate_validation() {
1085        let datetime = "2023-06-21T12:00:00Z"
1086            .parse::<DateTime<FixedOffset>>()
1087            .unwrap();
1088
1089        // Invalid latitude
1090        assert!(solar_position(datetime, 95.0, 0.0, 0.0, 0.0, 1013.25, 15.0).is_err());
1091
1092        // Invalid longitude
1093        assert!(solar_position(datetime, 0.0, 185.0, 0.0, 0.0, 1013.25, 15.0).is_err());
1094    }
1095
1096    #[test]
1097    fn test_sunrise_sunset_basic() {
1098        let date = "2023-06-21T00:00:00Z"
1099            .parse::<DateTime<FixedOffset>>()
1100            .unwrap();
1101
1102        let result = sunrise_sunset(date, 37.7749, -122.4194, 69.0, -0.833);
1103        assert!(result.is_ok());
1104
1105        let result =
1106            sunrise_sunset_for_horizon(date, 37.7749, -122.4194, 69.0, Horizon::SunriseSunset);
1107        assert!(result.is_ok());
1108    }
1109
1110    #[test]
1111    fn test_horizon_enum() {
1112        assert_eq!(Horizon::SunriseSunset.elevation_angle(), -0.83337);
1113        assert_eq!(Horizon::CivilTwilight.elevation_angle(), -6.0);
1114        assert_eq!(Horizon::Custom(-10.5).elevation_angle(), -10.5);
1115    }
1116}