astronomical_calculator/
lib.rs

1//! # Astronomical Calculator
2//!
3//! A high-precision library for calculating solar position, sunrise/sunset times, and related astronomical phenomena.
4//!
5//! This library provides accurate calculations of the Sun's position and timing of solar events for any location
6//! on Earth. It accounts for atmospheric refraction, parallax, nutation, aberration, and other astronomical
7//! phenomena that affect solar position calculations.
8//!
9//! This is a Rust port of the [freespa](https://github.com/IEK-5/freespa) library, based on VSOP87 theory.
10//! The library is `no_std` compatible, making it suitable for embedded systems and constrained environments.
11//!
12//! ## Key Types
13//!
14//! - [`AstronomicalCalculator`] - Main calculator struct for solar position and event calculations
15//! - [`SolarPosition`] - Represents the Sun's position with zenith and azimuth angles
16//! - [`SolarEventResult`] - Result type for sunrise/sunset and twilight calculations
17//! - [`Refraction`] - Atmospheric refraction model selection
18//! - [`get_delta_t`] - Function to calculate ΔT (Terrestrial Time - Universal Time)
19//!
20//! ## Basic Usage
21//!
22//! ```
23//! use astronomical_calculator::{AstronomicalCalculator, Refraction};
24//! use chrono::NaiveDateTime;
25//!
26//! // Create a datetime (UTC)
27//! let dt = NaiveDateTime::parse_from_str("2024-01-15 12:00:00", "%Y-%m-%d %H:%M:%S").unwrap().and_utc();
28//!
29//! // Create calculator for New York City
30//! // Note: longitude and latitude are in degrees
31//! let mut calc = AstronomicalCalculator::new(
32//!     dt,
33//!     Some(69.0),        // delta_t: TT-UT in seconds (≈69s for 2024)
34//!     0.0,               // delta_ut1: UT1-UTC in seconds
35//!     -74.0,             // longitude in degrees (negative = West)
36//!     40.7,              // latitude in degrees (positive = North)
37//!     0.0,               // elevation in meters
38//!     15.0,              // temperature in Celsius
39//!     1013.0,            // pressure in millibars
40//!     None,              // optional geometric dip angle
41//!     Refraction::ApSolposBennetNA,  // refraction model (recommended for precision)
42//! ).unwrap();
43//!
44//! // Get current solar position
45//! let position = calc.get_solar_position();
46//! println!("Zenith: {:.2}°", position.zenith.to_degrees());
47//! println!("Azimuth: {:.2}°", position.azimuth.to_degrees());
48//!
49//! // Get sunrise and sunset times (as Unix timestamps)
50//! use astronomical_calculator::SolarEventResult;
51//! match calc.get_sunrise().unwrap() {
52//!     SolarEventResult::Occurs(timestamp) => {
53//!         println!("Sunrise at Unix timestamp: {}", timestamp);
54//!     }
55//!     SolarEventResult::AllDay => println!("Sun never sets (midnight sun)"),
56//!     SolarEventResult::AllNight => println!("Sun never rises (polar night)"),
57//! }
58//! ```
59#![no_std]
60
61pub(crate) mod tables;
62
63#[cfg(test)]
64mod tests;
65#[cfg(test)]
66mod unsafe_spa;
67
68use chrono::DateTime;
69use chrono::Datelike;
70use chrono::TimeZone;
71use chrono::Timelike;
72use chrono::Utc;
73
74use core::cell::OnceCell;
75use core::f64::consts::PI;
76use core::ops::Rem;
77
78#[allow(unused_imports)]
79use core_maths::*;
80use thiserror::Error;
81
82use crate::tables::*;
83
84// Julian date constants
85const JD0: f64 = 2451545.0; // J2000.0 epoch
86const ETJD0: i64 = 946728000; // Unix timestamp for J2000.0 epoch
87
88// Physical constants
89const SUN_RADIUS: f64 = 4.654_269_516_293_279e-3_f64; // Sun's angular radius in radians
90pub(crate) const EARTH_R: f64 = 6378136.6f64; // Earth's radius in meters
91pub(crate) const ABSOLUTEZERO: f64 = -273.15f64; // Absolute zero in Celsius
92
93// Standard atmospheric conditions
94const AP0: f64 = 1010.0f64; // Standard pressure in millibars
95const AT0: f64 = 10.0f64; // Standard temperature in Celsius
96
97// Iteration and convergence parameters
98const FRACDAYSEC: f64 = 1.1574074074074073e-05f64; // Fractional day per second
99const MAX_FPITER: i64 = 20; // Max iterations for fixed point
100const Z_EPS: f64 = PI * 0.005f64 / 180.0f64; // Zenith convergence tolerance (0.005 degrees ≈ 18 arcseconds)
101const MAXRAT: i64 = 2; // Max ratio for bisection adjustment
102const Z_MAXITER: i64 = 100; // Max iterations for zenith finding
103
104/// Main calculator for solar position and astronomical events.
105///
106/// This struct computes solar positions, sunrise/sunset times, twilight times, and solar transit times
107/// for a specific location and datetime. Results are cached for efficient repeated access.
108///
109/// ## Event Determination Algorithm
110///
111/// **Important:** For correct results, the input time should be close to local noon for the given location.
112/// Using times far from noon may result in events being calculated for the wrong solar day.
113///
114/// When calculating solar events (sunrise, sunset, twilight) for a given input time, the library uses
115/// the following algorithm to determine which day's events to return:
116///
117/// 1. Find the closest solar transit (solar noon) to the input time
118/// 2. Get the sunset that immediately follows that transit
119/// 3. Get the sunrise that precedes that transit
120///
121/// This ensures that events are calculated for the appropriate solar day relative to the input time.
122/// In polar regions where multiple transits may occur in a 24-hour period (or none at all), the presence
123/// of additional transits before the closest one indicates polar day/night conditions.
124///
125/// # Example
126///
127/// ```
128/// use astronomical_calculator::{AstronomicalCalculator, Refraction};
129/// use chrono::NaiveDateTime;
130///
131/// // For correct solar event calculations, use a time close to local noon
132/// let dt = NaiveDateTime::parse_from_str("2024-06-21 12:00:00", "%Y-%m-%d %H:%M:%S").unwrap().and_utc();
133///
134/// let mut calc = AstronomicalCalculator::new(
135///     dt,
136///     None,                    // calculate delta_t automatically
137///     0.0,                     // delta_ut1 in seconds
138///     0.0,                     // longitude: 0° (Greenwich)
139///     51.5,                    // latitude: 51.5°N (London)
140///     0.0,                     // elevation: sea level
141///     20.0,                    // temperature: 20°C
142///     1013.25,                 // pressure: 1013.25 mb
143///     None,                    // geometric dip: None
144///     Refraction::ApSolposBennetNA,
145/// ).unwrap();
146///
147/// // Get solar position
148/// let pos = calc.get_solar_position();
149/// assert!(pos.zenith >= 0.0 && pos.zenith <= std::f64::consts::PI);
150/// ```
151#[derive(Debug, Clone)]
152pub struct AstronomicalCalculator {
153    ut: DateTime<Utc>,
154    delta_t: Option<f64>,
155    delta_ut1: f64,
156    lon_radians: f64,
157    lat_radians: f64,
158    elevation: f64,
159    temperature: f64,
160    pressure: f64,
161    gdip: Option<f64>,
162    refraction: Refraction,
163    julian_date: OnceCell<JulianDate>,
164    geocentric_position: OnceCell<GeoCentricSolPos>,
165    solar_position: OnceCell<SolarPosition>,
166    solar_transit: OnceCell<Result<SolarInfo, CalculationError>>,
167    prev_solar_midnight: OnceCell<Result<SolarInfo, CalculationError>>,
168    next_solar_midnight: OnceCell<Result<SolarInfo, CalculationError>>,
169    sunrise: OnceCell<Result<SolarEventResult, CalculationError>>,
170    sunset: OnceCell<Result<SolarEventResult, CalculationError>>,
171    sea_level_sunrise: OnceCell<Result<SolarEventResult, CalculationError>>,
172    sea_level_sunset: OnceCell<Result<SolarEventResult, CalculationError>>,
173    civil_dawn: OnceCell<Result<SolarEventResult, CalculationError>>,
174    civil_dusk: OnceCell<Result<SolarEventResult, CalculationError>>,
175    nautical_dawn: OnceCell<Result<SolarEventResult, CalculationError>>,
176    nautical_dusk: OnceCell<Result<SolarEventResult, CalculationError>>,
177    astronomical_dawn: OnceCell<Result<SolarEventResult, CalculationError>>,
178    astronomical_dusk: OnceCell<Result<SolarEventResult, CalculationError>>,
179}
180
181#[derive(Copy, Clone, Debug)]
182struct SolarInfo {
183    position: SolarPosition,
184    timestamp: i64,
185}
186
187/// Result of a solar event calculation (sunrise, sunset, twilight, etc.)
188#[derive(Copy, Clone, Debug, PartialEq, Eq)]
189pub enum SolarEventResult {
190    /// Event occurs at the given timestamp in seconds since the Unix epoch
191    Occurs(i64),
192    /// Sun is always above the threshold (e.g., midnight sun)
193    AllDay,
194    /// Sun is always below the threshold (e.g., polar night)
195    AllNight,
196}
197
198impl SolarEventResult {
199    /// Extracts the timestamp from a solar event result.
200    ///
201    /// # Returns
202    ///
203    /// - `Some(timestamp)` if the event occurs at a specific time
204    /// - `None` if the sun is always above or always below the threshold
205    ///
206    /// # Example
207    ///
208    /// ```rust
209    /// use astronomical_calculator::{AstronomicalCalculator, Refraction};
210    /// use chrono::NaiveDateTime;
211    ///
212    /// let datetime = NaiveDateTime::parse_from_str("2024-01-01 12:00:00", "%Y-%m-%d %H:%M:%S").unwrap().and_utc();
213    /// let mut calc = AstronomicalCalculator::new(
214    ///     datetime, None, 0.0, 0.0, 0.0, 0.0, 20.0, 1013.25, None, Refraction::ApSolposBennetNA
215    /// ).unwrap();
216    ///
217    /// if let Some(ts) = calc.get_sunrise().unwrap().timestamp() {
218    ///     println!("Sunrise at timestamp: {}", ts);
219    /// }
220    /// ```
221    pub fn timestamp(self) -> Option<i64> {
222        match self {
223            SolarEventResult::Occurs(ts) => Some(ts),
224            _ => None,
225        }
226    }
227}
228
229impl AstronomicalCalculator {
230    /// Creates a new astronomical calculator for the given location, time, and atmospheric conditions.
231    ///
232    /// All input parameters are validated. If any parameter is out of range, an error is returned.
233    ///
234    /// **Important:** For correct solar event calculations (sunrise, sunset, twilight), the input time
235    /// should be close to local noon for the given location. Using times far from noon may result in
236    /// events being calculated for the wrong solar day.
237    ///
238    /// # Arguments
239    ///
240    /// * `ut` - Universal Time as a [`DateTime<Utc>`]
241    /// * `delta_t` - ΔT (TT-UT) in seconds. Use `Some(value)` for known ΔT, or `None` to calculate automatically
242    /// * `delta_ut1` - ΔUT1 (UT1-UTC) in seconds, typically in range [-0.9, 0.9]. Use 0.0 if unknown
243    /// * `lon` - Longitude in degrees (positive East, negative West)
244    /// * `lat` - Latitude in degrees (positive North, negative South)
245    /// * `elevation` - Elevation above sea level in meters
246    /// * `temperature` - Temperature in Celsius (affects atmospheric refraction)
247    /// * `pressure` - Atmospheric pressure in millibars (affects atmospheric refraction)
248    /// * `gdip` - Optional geometric dip angle in radians. Use `None` for standard horizon
249    /// * `refraction` - Atmospheric refraction model to use
250    ///
251    /// # Returns
252    ///
253    /// A `Result` containing the calculator or a [`CalculationError`] if validation fails.
254    ///
255    /// # Errors
256    ///
257    /// Returns an error if any parameter is outside its valid range. See [`CalculationError`] for details.
258    ///
259    /// # Example
260    ///
261    /// ```
262    /// use astronomical_calculator::{AstronomicalCalculator, Refraction, get_delta_t};
263    /// use chrono::NaiveDateTime;
264    ///
265    /// // For correct solar event calculations, use a time close to local noon
266    /// let dt = NaiveDateTime::parse_from_str("2024-06-21 12:00:00", "%Y-%m-%d %H:%M:%S").unwrap().and_utc();
267    ///
268    /// // Paris: 48.8566°N, 2.3522°E
269    /// let mut calc = AstronomicalCalculator::new(
270    ///     dt,
271    ///     Some(get_delta_t(&dt)),     // Calculate ΔT automatically
272    ///     0.0,                         // ΔUT1 (use 0.0 if unknown)
273    ///     2.3522,                      // longitude
274    ///     48.8566,                     // latitude
275    ///     35.0,                        // elevation (m)
276    ///     22.0,                        // temperature (°C)
277    ///     1013.25,                     // pressure (mb)
278    ///     None,                        // geometric dip
279    ///     Refraction::ApSolposBennetNA,  // refraction model
280    /// ).unwrap();
281    /// ```
282    #[allow(clippy::too_many_arguments)]
283    pub fn new(
284        ut: DateTime<Utc>,
285        delta_t: Option<f64>,
286        delta_ut1: f64,
287        lon: f64,
288        lat: f64,
289        elevation: f64,
290        temperature: f64,
291        pressure: f64,
292        gdip: Option<f64>,
293        refraction: Refraction,
294    ) -> Result<Self, CalculationError> {
295        // Validate year range (-2000 to 6000)
296        let year = ut.year();
297        if !(-2000..=6000).contains(&year) {
298            return Err(CalculationError::TimeConversionError);
299        }
300
301        let lon_radians = lon.to_radians();
302        let lat_radians = lat.to_radians();
303        if !(-1.0..=1.0).contains(&delta_ut1) {
304            return Err(CalculationError::DeltaUt1OutOfRange);
305        }
306        // Validate delta_t range if explicitly provided
307        if let Some(dt) = delta_t {
308            if !(-8000.0..=8000.0).contains(&dt) {
309                return Err(CalculationError::TimeConversionError);
310            }
311        }
312        if !(-PI..=PI).contains(&lon_radians) {
313            return Err(CalculationError::LongitudeOutOfRange);
314        }
315        if !(-PI / 2.0..=PI / 2.0).contains(&lat_radians) {
316            return Err(CalculationError::LatitudeOutOfRange);
317        }
318        if !(-EARTH_R..=EARTH_R).contains(&elevation) {
319            return Err(CalculationError::ElevationOutOfRange);
320        }
321        if pressure <= 0.0 || pressure > 5000.0 {
322            return Err(CalculationError::PressureOutOfRange);
323        }
324        if !(ABSOLUTEZERO..=6000.0).contains(&temperature) {
325            return Err(CalculationError::TemperatureOutOfRange);
326        }
327        // Validate gdip range if provided
328        if let Some(gdip_val) = gdip {
329            if gdip_val.abs() > PI / 2.0 {
330                return Err(CalculationError::GeometricDipOutOfRange);
331            }
332        }
333        Ok(Self {
334            ut,
335            delta_t,
336            delta_ut1,
337            lon_radians,
338            lat_radians,
339            temperature,
340            pressure,
341            elevation,
342            gdip,
343            refraction,
344            julian_date: OnceCell::new(),
345            geocentric_position: OnceCell::new(),
346            solar_position: OnceCell::new(),
347            solar_transit: OnceCell::new(),
348            prev_solar_midnight: OnceCell::new(),
349            next_solar_midnight: OnceCell::new(),
350            sunrise: OnceCell::new(),
351            sunset: OnceCell::new(),
352            sea_level_sunrise: OnceCell::new(),
353            sea_level_sunset: OnceCell::new(),
354            civil_dawn: OnceCell::new(),
355            civil_dusk: OnceCell::new(),
356            nautical_dawn: OnceCell::new(),
357            nautical_dusk: OnceCell::new(),
358            astronomical_dawn: OnceCell::new(),
359            astronomical_dusk: OnceCell::new(),
360        })
361    }
362    /// Returns the Julian date and related time values.
363    ///
364    /// This method computes and caches the Julian date representation of the input datetime.
365    /// The result is cached, so subsequent calls return the same reference without recomputation.
366    ///
367    /// # Returns
368    ///
369    /// A reference to the computed [`JulianDate`] struct.
370    ///
371    /// # Example
372    ///
373    /// ```
374    /// use astronomical_calculator::{AstronomicalCalculator, Refraction};
375    /// use chrono::NaiveDateTime;
376    ///
377    /// let dt = NaiveDateTime::parse_from_str("2024-01-15 12:00:00", "%Y-%m-%d %H:%M:%S").unwrap().and_utc();
378    /// let mut calc = AstronomicalCalculator::new(
379    ///     dt, Some(69.0), 0.0, 0.0, 0.0, 0.0, 15.0, 1013.0,
380    ///     None, Refraction::ApSolposBennetNA
381    /// ).unwrap();
382    ///
383    /// let jd = calc.get_julian_day();
384    /// println!("Julian Date: {}", jd.jd);
385    /// ```
386    pub fn get_julian_day(&mut self) -> &JulianDate {
387        self.julian_date
388            .get_or_init(|| JulianDate::new(self.ut, self.delta_t, self.delta_ut1))
389    }
390
391    pub(crate) fn get_geocentric_position(&mut self) -> &GeoCentricSolPos {
392        let julian_date = *self.get_julian_day();
393        self.geocentric_position
394            .get_or_init(|| GeoCentricSolPos::new(&julian_date))
395    }
396    /// Returns the topocentric solar position (zenith and azimuth angles).
397    ///
398    /// Computes the Sun's position as seen from the observer's location, accounting for
399    /// atmospheric refraction, parallax, nutation, and aberration.
400    ///
401    /// # Returns
402    ///
403    /// A reference to the computed [`SolarPosition`] with zenith and azimuth angles in radians.
404    /// The result is cached for efficient repeated access.
405    pub fn get_solar_position(&mut self) -> &SolarPosition {
406        let julian_date = *self.get_julian_day();
407        let gp = *self.get_geocentric_position();
408        self.solar_position.get_or_init(|| {
409            let dtau = PI * (-20.4898f64 / 3600.0) / 180.0 / gp.rad;
410            let (dpsi, deps) = nutation_lon_obliquity(julian_date);
411            let eps =
412                deps + polynomial(&ECLIPTIC_MEAN_OBLIQUITY, julian_date.jme / 10.0) * (PI * (1.0 / 3600.0) / 180.0);
413            let lambda = gp.lon + dpsi + dtau;
414            let mut v = polynomial(&GSTA, julian_date.jc) + PI * 360.98564736629f64 / 180.0 * (julian_date.jd - JD0);
415            v += dpsi * eps.cos();
416            let mut alpha = (lambda.sin() * eps.cos() - gp.lat.tan() * eps.sin()).atan2(lambda.cos());
417            if alpha < 0.0 {
418                alpha += 2.0 * PI;
419            }
420            let delta = (gp.lat.sin() * eps.cos() + gp.lat.cos() * eps.sin() * lambda.sin()).asin();
421            let hh = v + self.lon_radians - alpha;
422            let xi = PI * (8.794f64 / 3600.0) / 180.0 / gp.rad;
423            let u = (0.99664719f64 * self.lat_radians.tan()).atan();
424            let x = u.cos() + self.elevation * self.lat_radians.cos() / EARTH_R;
425            let y = 0.99664719f64 * u.sin() + self.elevation * self.lat_radians.sin() / EARTH_R;
426            let dalpha = (-x * xi.sin() * hh.sin()).atan2(delta.cos() - x * xi.sin() * hh.cos());
427            let delta_prime =
428                ((delta.sin() - y * xi.sin()) * dalpha.cos()).atan2(delta.cos() - x * xi.sin() * hh.cos());
429            let h_prime = hh - dalpha;
430            let h = (self.lat_radians.sin() * delta_prime.sin()
431                + self.lat_radians.cos() * delta_prime.cos() * h_prime.cos())
432            .asin();
433            let mut z = PI / 2.0 - h;
434            let mut a = (PI
435                + h_prime
436                    .sin()
437                    .atan2(h_prime.cos() * self.lat_radians.sin() - delta_prime.tan() * self.lat_radians.cos()))
438            .rem(2.0 * PI);
439            if z < 0.0 {
440                z = -z;
441                a = (a + PI).rem(2.0 * PI);
442            }
443            if z > PI {
444                z = 2.0 * PI - z;
445                a = (a + 2.0 * PI).rem(2.0 * PI);
446            }
447            SolarPosition { zenith: z, azimuth: a }
448        })
449    }
450    /// Returns the solar time (apparent solar time) for the given datetime.
451    ///
452    /// Solar time differs from clock time due to the equation of time and longitude offset.
453    /// At solar noon, the Sun crosses the observer's meridian (highest point in the sky).
454    ///
455    /// # Returns
456    ///
457    /// A `Result` containing the solar time as a [`DateTime<Utc>`], or an error if time conversion fails.
458    pub fn get_solar_time(&mut self) -> Result<DateTime<Utc>, CalculationError> {
459        let e = equation_of_time(*self.get_julian_day(), *self.get_geocentric_position());
460        julian_date_to_datetime(self.get_julian_day().jd + (self.lon_radians + e) / PI / 2.0)
461    }
462
463    /// Returns the time of solar transit (solar noon).
464    ///
465    /// Solar transit is the moment when the Sun crosses the observer's meridian,
466    /// reaching its highest point in the sky for the day. This is also known as solar noon.
467    ///
468    /// # Returns
469    ///
470    /// A `Result` containing the Unix timestamp (seconds since 1970-01-01 00:00:00 UTC)
471    /// of the solar transit, or an error if the calculation fails.
472    pub fn get_solar_transit(&mut self) -> Result<i64, CalculationError> {
473        self._get_solar_transit().map(|i| i.timestamp)
474    }
475    fn _get_solar_transit(&mut self) -> Result<SolarInfo, CalculationError> {
476        let r = self.solar_transit.get_or_init(|| {
477            let t = datetime_to_unix(self.ut);
478
479            let tc = find_solar_time(t, 12, 0, 0, self.delta_t, self.delta_ut1, self.lon_radians)?;
480            let mut calculator = self.with_time(unix_to_datetime(tc)?);
481            let pos = self.refraction.apply(
482                *calculator.get_solar_position(),
483                self.gdip,
484                self.elevation,
485                self.pressure,
486                self.temperature,
487            )?;
488            Ok(SolarInfo {
489                position: pos,
490                timestamp: tc,
491            })
492        });
493        *r
494    }
495
496    /// Returns the time of the previous solar midnight (solar anti-transit).
497    ///
498    /// Solar midnight is the moment when the Sun crosses the observer's anti-meridian,
499    /// reaching its lowest point below the horizon.
500    ///
501    /// # Returns
502    ///
503    /// A `Result` containing the Unix timestamp of the previous solar midnight,
504    /// or an error if the calculation fails.
505    pub fn get_prev_solar_midnight(&mut self) -> Result<i64, CalculationError> {
506        self._get_prev_solar_midnight().map(|i| i.timestamp)
507    }
508
509    fn _get_prev_solar_midnight(&mut self) -> Result<SolarInfo, CalculationError> {
510        let solar_transit = self.get_solar_transit()?;
511        let r = self.prev_solar_midnight.get_or_init(|| {
512            let tc = find_solar_time(
513                solar_transit - 43200,
514                0,
515                0,
516                0,
517                self.delta_t,
518                self.delta_ut1,
519                self.lon_radians,
520            )?;
521            let mut calculator = self.with_time(unix_to_datetime(tc)?);
522            let pos = self.refraction.apply(
523                *calculator.get_solar_position(),
524                self.gdip,
525                self.elevation,
526                self.pressure,
527                self.temperature,
528            )?;
529            Ok(SolarInfo {
530                position: pos,
531                timestamp: tc,
532            })
533        });
534        *r
535    }
536    /// Returns the time of the next solar midnight (solar anti-transit).
537    ///
538    /// Solar midnight is the moment when the Sun crosses the observer's anti-meridian,
539    /// reaching its lowest point below the horizon.
540    ///
541    /// # Returns
542    ///
543    /// A `Result` containing the Unix timestamp of the next solar midnight,
544    /// or an error if the calculation fails.
545    pub fn get_next_solar_midnight(&mut self) -> Result<i64, CalculationError> {
546        self._get_next_solar_midnight().map(|i| i.timestamp)
547    }
548
549    fn _get_next_solar_midnight(&mut self) -> Result<SolarInfo, CalculationError> {
550        let solar_transit = self.get_solar_transit()?;
551        let r = self.next_solar_midnight.get_or_init(|| {
552            let tc = find_solar_time(
553                solar_transit + 43200,
554                0,
555                0,
556                0,
557                self.delta_t,
558                self.delta_ut1,
559                self.lon_radians,
560            )?;
561            let mut calculator = self.with_time(unix_to_datetime(tc)?);
562            let pos = self.refraction.apply(
563                *calculator.get_solar_position(),
564                self.gdip,
565                self.elevation,
566                self.pressure,
567                self.temperature,
568            )?;
569            Ok(SolarInfo {
570                position: pos,
571                timestamp: tc,
572            })
573        });
574        *r
575    }
576
577    /// Returns the time of sunrise.
578    ///
579    /// Sunrise is defined as the moment when the top of the Sun's disk appears at the horizon,
580    /// using the standard horizon angle of -0.8333° (accounts for Sun's angular radius and atmospheric refraction).
581    ///
582    /// # Returns
583    ///
584    /// A `Result` containing a [`SolarEventResult`]:
585    /// - `Occurs(timestamp)`: Sunrise occurs at the given Unix timestamp
586    /// - `AllDay`: Sun never sets (midnight sun / polar day)
587    /// - `AllNight`: Sun never rises (polar night)
588    pub fn get_sunrise(&mut self) -> Result<SolarEventResult, CalculationError> {
589        if let Some(r) = self.sunrise.get() {
590            return *r;
591        }
592
593        let prev_midnight = self.get_prev_solar_midnight()?;
594        let transit = self.get_solar_transit()?;
595
596        // Get solar positions at boundaries - safe because we just computed them
597        let z1 = self._get_prev_solar_midnight()?.position.zenith;
598        let z2 = self._get_solar_transit()?.position.zenith;
599        let dip = self.compute_dip();
600        let target_zenith = dip + PI / 2.0 + SUN_RADIUS;
601
602        let result = self.find_solar_event(prev_midnight, transit, z1, z2, target_zenith);
603        let _ = self.sunrise.set(result);
604        result
605    }
606
607    /// Returns the time of sunset.
608    ///
609    /// Sunset is defined as the moment when the top of the Sun's disk disappears below the horizon,
610    /// using the standard horizon angle of -0.8333° (accounts for Sun's angular radius and atmospheric refraction).
611    ///
612    /// # Returns
613    ///
614    /// A `Result` containing a [`SolarEventResult`]:
615    /// - `Occurs(timestamp)`: Sunset occurs at the given Unix timestamp
616    /// - `AllDay`: Sun never sets (midnight sun / polar day)
617    /// - `AllNight`: Sun never rises (polar night)
618    pub fn get_sunset(&mut self) -> Result<SolarEventResult, CalculationError> {
619        if let Some(r) = self.sunset.get() {
620            return *r;
621        }
622
623        let transit = self.get_solar_transit()?;
624        let next_midnight = self.get_next_solar_midnight()?;
625
626        // Get solar positions at boundaries
627        let z1 = self._get_solar_transit()?.position.zenith;
628        let z2 = self._get_next_solar_midnight()?.position.zenith;
629
630        let dip = self.compute_dip();
631        let target_zenith = dip + PI / 2.0 + SUN_RADIUS;
632
633        let result = self.find_solar_event(transit, next_midnight, z1, z2, target_zenith);
634        let _ = self.sunset.set(result);
635        result
636    }
637
638    /// Get sunrise time offset by degrees below the horizon.
639    ///
640    /// Calculates the time when the sun reaches a specific angle below the horizon before sunrise.
641    /// This is useful for calculating custom twilight events or dawn times.
642    ///
643    /// # Arguments
644    ///
645    /// * `degrees` - The number of degrees below the horizon (e.g., 6.0 for civil dawn, 12.0 for nautical dawn, 18.0 for astronomical dawn)
646    /// * `geometric` - Whether to use the geometric horizon (true) or the elevated horizon (false).    
647    ///
648    /// # Returns
649    ///
650    /// A `Result` containing a [`SolarEventResult`]:
651    /// - `Occurs(timestamp)`: The event occurs at the given Unix timestamp
652    /// - `AllDay`: Sun is always above the threshold
653    /// - `AllNight`: Sun never reaches the threshold
654    ///
655    /// # Note
656    ///
657    /// This function does not cache results. For better performance when calling multiple times,
658    /// use the specific methods like `get_civil_dawn()`, `get_nautical_dawn()`, etc.
659    pub fn get_sunrise_offset_by_degrees(
660        &mut self,
661        degrees: f64,
662        geometric: bool,
663    ) -> Result<SolarEventResult, CalculationError> {
664        let prev_midnight = self.get_prev_solar_midnight()?;
665        let transit = self.get_solar_transit()?;
666
667        let z1 = self
668            ._get_prev_solar_midnight()
669            .map(|i| i.position.zenith)
670            .unwrap_or(PI / 2.0);
671        let z2 = self._get_solar_transit()?.position.zenith;
672
673        let dip = if geometric { 0.0 } else { self.compute_dip() };
674
675        let target_zenith = dip + PI / 2.0 + PI * degrees / 180.0;
676
677        self.find_solar_event(prev_midnight, transit, z1, z2, target_zenith)
678    }
679
680    /// Get sunset time offset by degrees below the horizon.
681    ///
682    /// Calculates the time when the sun reaches a specific angle below the horizon after sunset.
683    /// This is useful for calculating custom twilight events or dusk times.
684    ///
685    /// # Arguments
686    ///
687    /// * `degrees` - The number of degrees below the horizon (e.g., 6.0 for civil dusk, 12.0 for nautical dusk, 18.0 for astronomical dusk)
688    /// * `geometric` - Whether to use the geometric horizon (true) or the elevated horizon (false).
689    ///
690    /// # Returns
691    ///
692    /// A `Result` containing a [`SolarEventResult`]:
693    /// - `Occurs(timestamp)`: The event occurs at the given Unix timestamp
694    /// - `AllDay`: Sun is always above the threshold
695    /// - `AllNight`: Sun never reaches the threshold
696    ///
697    ///
698    /// # Note
699    ///
700    /// This function does not cache results. For better performance when calling multiple times,
701    /// use the specific methods like `get_civil_dusk()`, `get_nautical_dusk()`, etc.
702    pub fn get_sunset_offset_by_degrees(
703        &mut self,
704        degrees: f64,
705        geometric: bool,
706    ) -> Result<SolarEventResult, CalculationError> {
707        let transit = self.get_solar_transit()?;
708        let next_midnight = self.get_next_solar_midnight()?;
709
710        let z1 = self._get_solar_transit()?.position.zenith;
711        let z2 = self._get_next_solar_midnight()?.position.zenith;
712
713        let dip = if geometric { 0.0 } else { self.compute_dip() };
714
715        let target_zenith = dip + PI / 2.0 + PI * degrees / 180.0;
716
717        self.find_solar_event(transit, next_midnight, z1, z2, target_zenith)
718    }
719
720    /// Get sea-level sunrise time.
721    ///
722    /// Calculates sunrise at sea level (elevation = 0 meters), without elevation adjustment.
723    /// This is important for twilight calculations, as the level of light during twilight
724    /// is not affected by elevation. Sea-level sunrise forms the base for dawn calculations
725    /// that are calculated as a dip below the horizon before sunrise.
726    ///
727    /// # Returns
728    ///
729    /// A `Result` containing a [`SolarEventResult`]:
730    /// - `Occurs(timestamp)`: Sea-level sunrise occurs at the given Unix timestamp
731    /// - `AllDay`: Sun never sets (midnight sun / polar day)
732    /// - `AllNight`: Sun never rises (polar night)
733    ///
734    /// # See Also
735    ///
736    /// - [`get_sunrise()`](Self::get_sunrise) - Elevation-adjusted sunrise
737    pub fn get_sea_level_sunrise(&mut self) -> Result<SolarEventResult, CalculationError> {
738        if let Some(r) = self.sea_level_sunrise.get() {
739            return *r;
740        }
741
742        // Create a temporary calculator with elevation=0
743        let mut sea_level_calc = self.with_elevation(0.0);
744        let result = sea_level_calc.get_sunrise();
745        let _ = self.sea_level_sunrise.set(result);
746        result
747    }
748
749    /// Get sea-level sunset time.
750    ///
751    /// Calculates sunset at sea level (elevation = 0 meters), without elevation adjustment.
752    /// This is important for twilight calculations, as the level of light during twilight
753    /// is not affected by elevation. Sea-level sunset forms the base for dusk calculations
754    /// that are calculated as a dip below the horizon after sunset.
755    ///
756    /// # Returns
757    ///
758    /// A `Result` containing a [`SolarEventResult`]:
759    /// - `Occurs(timestamp)`: Sea-level sunset occurs at the given Unix timestamp
760    /// - `AllDay`: Sun never sets (midnight sun / polar day)
761    /// - `AllNight`: Sun never rises (polar night)
762    ///
763    /// # See Also
764    ///
765    /// - [`get_sunset()`](Self::get_sunset) - Elevation-adjusted sunset
766    pub fn get_sea_level_sunset(&mut self) -> Result<SolarEventResult, CalculationError> {
767        if let Some(r) = self.sea_level_sunset.get() {
768            return *r;
769        }
770
771        // Create a temporary calculator with elevation=0
772        let mut sea_level_calc = self.with_elevation(0.0);
773        let result = sea_level_calc.get_sunset();
774        let _ = self.sea_level_sunset.set(result);
775        result
776    }
777
778    /// Get civil dawn time (sun 6° below horizon).
779    ///
780    /// Uses zenith angle: 90° + 6° (measured from geometric horizon).
781    /// Returns the time of civil dawn (morning civil twilight).
782    ///
783    /// Civil dawn is when the Sun is 6° below the horizon in the morning.
784    /// At this time, there is enough light for most outdoor activities without artificial lighting.
785    ///
786    /// # Returns
787    ///
788    /// A `Result` containing a [`SolarEventResult`]:
789    /// - `Occurs(timestamp)`: Civil dawn occurs at the given Unix timestamp
790    /// - `AllDay`: Sun is always above civil twilight threshold
791    /// - `AllNight`: Sun never reaches civil twilight threshold
792    pub fn get_civil_dawn(&mut self) -> Result<SolarEventResult, CalculationError> {
793        if let Some(r) = self.civil_dawn.get() {
794            return *r;
795        }
796
797        let result = self.get_sunrise_offset_by_degrees(6.0, true);
798        let _ = self.civil_dawn.set(result);
799        result
800    }
801
802    /// Get civil dusk time (sun 6° below horizon).
803    ///
804    /// Uses zenith angle: 90° + 6° + geometric dip.
805    /// Returns the time of civil dusk (evening civil twilight).
806    ///
807    /// Civil dusk is when the Sun is 6° below the horizon in the evening.
808    /// After this time, artificial lighting is typically needed for outdoor activities.
809    ///
810    /// # Returns
811    ///
812    /// A `Result` containing a [`SolarEventResult`]:
813    /// - `Occurs(timestamp)`: Civil dusk occurs at the given Unix timestamp
814    /// - `AllDay`: Sun is always above civil twilight threshold
815    /// - `AllNight`: Sun never reaches civil twilight threshold
816    pub fn get_civil_dusk(&mut self) -> Result<SolarEventResult, CalculationError> {
817        if let Some(r) = self.civil_dusk.get() {
818            return *r;
819        }
820
821        let result = self.get_sunset_offset_by_degrees(6.0, true);
822        let _ = self.civil_dusk.set(result);
823        result
824    }
825
826    /// Get nautical dawn time (sun 12° below horizon).
827    ///
828    /// Uses zenith angle: 90° + 12° + geometric dip.
829    /// Returns the time of nautical dawn (morning nautical twilight).
830    ///
831    /// Nautical dawn is when the Sun is 12° below the horizon in the morning.
832    /// At this time, the horizon becomes visible at sea, allowing navigation by horizon observations.
833    ///
834    /// # Returns
835    ///
836    /// A `Result` containing a [`SolarEventResult`]:
837    /// - `Occurs(timestamp)`: Nautical dawn occurs at the given Unix timestamp
838    /// - `AllDay`: Sun is always above nautical twilight threshold
839    /// - `AllNight`: Sun never reaches nautical twilight threshold
840    pub fn get_nautical_dawn(&mut self) -> Result<SolarEventResult, CalculationError> {
841        if let Some(r) = self.nautical_dawn.get() {
842            return *r;
843        }
844
845        let result = self.get_sunrise_offset_by_degrees(12.0, true);
846        let _ = self.nautical_dawn.set(result);
847        result
848    }
849
850    /// Get nautical dusk time (sun 12° below horizon).
851    ///
852    /// Uses zenith angle: 90° + 12° + geometric dip.
853    /// Returns the time of nautical dusk (evening nautical twilight).
854    ///
855    /// Nautical dusk is when the Sun is 12° below the horizon in the evening.
856    /// After this time, the horizon is no longer visible at sea.
857    ///
858    /// # Returns
859    ///
860    /// A `Result` containing a [`SolarEventResult`]:
861    /// - `Occurs(timestamp)`: Nautical dusk occurs at the given Unix timestamp
862    /// - `AllDay`: Sun is always above nautical twilight threshold
863    /// - `AllNight`: Sun never reaches nautical twilight threshold
864    pub fn get_nautical_dusk(&mut self) -> Result<SolarEventResult, CalculationError> {
865        if let Some(r) = self.nautical_dusk.get() {
866            return *r;
867        }
868
869        let result = self.get_sunset_offset_by_degrees(12.0, true);
870        let _ = self.nautical_dusk.set(result);
871        result
872    }
873
874    /// Get astronomical dawn time (sun 18° below horizon).
875    ///
876    /// Uses zenith angle: 90° + 18° + geometric dip.
877    /// Returns the time of astronomical dawn (morning astronomical twilight).
878    ///
879    /// Astronomical dawn is when the Sun is 18° below the horizon in the morning.
880    /// This marks the beginning of astronomical twilight, when the sky begins to be illuminated
881    /// and faint stars start to disappear.
882    ///
883    /// # Returns
884    ///
885    /// A `Result` containing a [`SolarEventResult`]:
886    /// - `Occurs(timestamp)`: Astronomical dawn occurs at the given Unix timestamp
887    /// - `AllDay`: Sun is always above astronomical twilight threshold
888    /// - `AllNight`: Sun never reaches astronomical twilight threshold (true astronomical night)
889    pub fn get_astronomical_dawn(&mut self) -> Result<SolarEventResult, CalculationError> {
890        if let Some(r) = self.astronomical_dawn.get() {
891            return *r;
892        }
893
894        let result = self.get_sunrise_offset_by_degrees(18.0, true);
895        let _ = self.astronomical_dawn.set(result);
896        result
897    }
898
899    /// Get astronomical dusk time (sun 18° below horizon).
900    ///
901    /// Uses zenith angle: 90° + 18° + geometric dip.
902    /// Returns the time of astronomical dusk (evening astronomical twilight).
903    ///
904    /// Astronomical dusk is when the Sun is 18° below the horizon in the evening.
905    /// After this time, true astronomical night begins, and the sky is completely dark
906    /// for astronomical observations.
907    ///
908    /// # Returns
909    ///
910    /// A `Result` containing a [`SolarEventResult`]:
911    /// - `Occurs(timestamp)`: Astronomical dusk occurs at the given Unix timestamp
912    /// - `AllDay`: Sun is always above astronomical twilight threshold
913    /// - `AllNight`: Sun never reaches astronomical twilight threshold (true astronomical night)
914    pub fn get_astronomical_dusk(&mut self) -> Result<SolarEventResult, CalculationError> {
915        if let Some(r) = self.astronomical_dusk.get() {
916            return *r;
917        }
918
919        let result = self.get_sunset_offset_by_degrees(18.0, true);
920        let _ = self.astronomical_dusk.set(result);
921        result
922    }
923    /// Helper function for creating a copy of this `AstronomicalCalculator` with a new elevation
924    fn with_elevation(&self, elevation: f64) -> Self {
925        Self {
926            ut: self.ut,
927            delta_t: self.delta_t,
928            delta_ut1: self.delta_ut1,
929            lon_radians: self.lon_radians,
930            lat_radians: self.lat_radians,
931            elevation,
932            temperature: self.temperature,
933            pressure: self.pressure,
934            gdip: None, // Reset gdip when changing elevation
935            refraction: self.refraction,
936            julian_date: OnceCell::new(),
937            geocentric_position: OnceCell::new(),
938            solar_position: OnceCell::new(),
939            solar_transit: OnceCell::new(),
940            prev_solar_midnight: OnceCell::new(),
941            next_solar_midnight: OnceCell::new(),
942            sunrise: OnceCell::new(),
943            sunset: OnceCell::new(),
944            sea_level_sunrise: OnceCell::new(),
945            sea_level_sunset: OnceCell::new(),
946            civil_dawn: OnceCell::new(),
947            civil_dusk: OnceCell::new(),
948            nautical_dawn: OnceCell::new(),
949            nautical_dusk: OnceCell::new(),
950            astronomical_dawn: OnceCell::new(),
951            astronomical_dusk: OnceCell::new(),
952        }
953    }
954
955    /// Helper function for creating a copy of this `AstronomicalCalculator` with a new time
956    fn with_time(&self, time: DateTime<Utc>) -> Self {
957        Self {
958            ut: time,
959            delta_t: self.delta_t,
960            delta_ut1: self.delta_ut1,
961            lon_radians: self.lon_radians,
962            lat_radians: self.lat_radians,
963            elevation: self.elevation,
964            temperature: self.temperature,
965            pressure: self.pressure,
966            gdip: self.gdip,
967            refraction: self.refraction,
968            julian_date: OnceCell::new(),
969            geocentric_position: OnceCell::new(),
970            solar_position: OnceCell::new(),
971            solar_transit: OnceCell::new(),
972            prev_solar_midnight: OnceCell::new(),
973            next_solar_midnight: OnceCell::new(),
974            sunrise: OnceCell::new(),
975            sunset: OnceCell::new(),
976            sea_level_sunrise: OnceCell::new(),
977            sea_level_sunset: OnceCell::new(),
978            civil_dawn: OnceCell::new(),
979            civil_dusk: OnceCell::new(),
980            nautical_dawn: OnceCell::new(),
981            nautical_dusk: OnceCell::new(),
982            astronomical_dawn: OnceCell::new(),
983            astronomical_dusk: OnceCell::new(),
984        }
985    }
986
987    /// Compute the geometric dip angle based on elevation or explicit gdip
988    fn compute_dip(&self) -> f64 {
989        if let Some(gdip) = self.gdip {
990            gdip
991        } else if self.elevation > 0.0 {
992            (EARTH_R / (EARTH_R + self.elevation)).acos()
993        } else {
994            0.0
995        }
996    }
997
998    /// Helper to find a solar event using bisection search
999    fn find_solar_event(
1000        &mut self,
1001        t1: i64,
1002        t2: i64,
1003        z1: f64,
1004        z2: f64,
1005        target_zenith: f64,
1006    ) -> Result<SolarEventResult, CalculationError> {
1007        // Check if the sun is always above or below the target zenith
1008        if target_zenith < z1 && target_zenith < z2 {
1009            return Ok(SolarEventResult::AllNight);
1010        }
1011        if target_zenith > z1 && target_zenith > z2 {
1012            return Ok(SolarEventResult::AllDay);
1013        }
1014
1015        // Use cosine interpolation to estimate crossing time
1016        let w = PI / (t2 - t1) as f64;
1017        let b_denom = (t1 as f64 * w).cos() - (t2 as f64 * w).cos();
1018        let a = -((t2 as f64 * w).cos() * z1 - (t1 as f64 * w).cos() * z2) / b_denom;
1019        let b = (z1 - z2) / b_denom;
1020        let direction = if z2 < z1 { 1.0 } else { -1.0 };
1021
1022        // Initial guess
1023        let mut timestamp = t1 + ((target_zenith / b - a / b).acos() / w).round() as i64;
1024        if timestamp < t1 || timestamp > t2 {
1025            timestamp = (t1 + t2) / 2;
1026        }
1027
1028        // Calculate solar position at initial guess
1029        let datetime = unix_to_datetime(timestamp)?;
1030        let position = self.refraction.apply(
1031            *self.with_time(datetime).get_solar_position(),
1032            self.gdip,
1033            self.elevation,
1034            self.pressure,
1035            self.temperature,
1036        )?;
1037        let mut best_timestamp = timestamp;
1038        let mut best_error = position.zenith - target_zenith;
1039
1040        if best_error.abs() < Z_EPS {
1041            return Ok(SolarEventResult::Occurs(best_timestamp));
1042        }
1043
1044        // Set up bisection search bounds
1045        let (mut t_min, mut z_min, mut t_max, mut z_max) = if direction * (position.zenith - target_zenith) > 0.0 {
1046            (timestamp, position.zenith, t2, z2)
1047        } else {
1048            (t1, z1, timestamp, position.zenith)
1049        };
1050
1051        // Bisection search with linear interpolation
1052        let mut iter = 0;
1053        while t_max - t_min > 1 && iter < Z_MAXITER {
1054            // Linear interpolation for next guess
1055            timestamp = (((target_zenith - z_min) * t_max as f64 + (z_max - target_zenith) * t_min as f64)
1056                / (target_zenith - z_min + (z_max - target_zenith)))
1057                .round() as i64;
1058
1059            // Keep within bounds
1060            if timestamp < t1 || timestamp > t2 {
1061                timestamp = (t1 + t2) / 2;
1062            }
1063
1064            // Avoid skewed divisions
1065            if timestamp - t_min > MAXRAT * (t_max - timestamp) || MAXRAT * (timestamp - t_min) < t_max - timestamp {
1066                timestamp = (t_min + t_max) / 2;
1067            }
1068
1069            // Calculate solar position
1070            let datetime = unix_to_datetime(timestamp)?;
1071            let mut calculator = self.with_time(datetime);
1072            let position = self.refraction.apply(
1073                *calculator.get_solar_position(),
1074                self.gdip,
1075                self.elevation,
1076                self.pressure,
1077                self.temperature,
1078            )?;
1079            // Update best result
1080            if (position.zenith - target_zenith).abs() < best_error.abs() {
1081                best_error = position.zenith - target_zenith;
1082                best_timestamp = timestamp;
1083            }
1084
1085            if best_error.abs() < Z_EPS {
1086                return Ok(SolarEventResult::Occurs(best_timestamp));
1087            }
1088
1089            // Update bisection bounds
1090            if direction * (position.zenith - target_zenith) > 0.0 {
1091                t_min = timestamp;
1092                z_min = position.zenith;
1093            } else {
1094                t_max = timestamp;
1095                z_max = position.zenith;
1096            }
1097            iter += 1;
1098        }
1099
1100        Ok(SolarEventResult::Occurs(best_timestamp))
1101    }
1102}
1103
1104#[derive(Copy, Clone, Debug)]
1105/// Solar position in local horizontal coordinates.
1106///
1107/// Represents the position of the Sun as seen from an observer's location,
1108/// specified by zenith and azimuth angles.
1109///
1110/// # Fields
1111///
1112/// - `zenith`: Zenith angle in radians (0 = directly overhead, π/2 = horizon, π = nadir)
1113/// - `azimuth`: Azimuth angle in radians, measured clockwise from North (0 = N, π/2 = E, π = S, 3π/2 = W)
1114pub struct SolarPosition {
1115    /// Zenith angle in radians (0 = overhead, π/2 = horizon)
1116    pub zenith: f64,
1117    /// Azimuth angle in radians, clockwise from North (0 = N, π/2 = E)
1118    pub azimuth: f64,
1119}
1120
1121/// Atmospheric refraction model selection.
1122///
1123/// Atmospheric refraction causes the apparent position of the Sun to differ from its geometric position,
1124/// especially near the horizon. Different models provide varying levels of accuracy.
1125///
1126/// # Variants
1127///
1128/// - `ApSolposBennet`: Standard Bennett refraction model, suitable for most applications
1129/// - `ApSolposBennetNA`: Bennett refraction model optimized for accuracy and matches closer with The Nautical Almanac.
1130///   Recommended for precision astronomical calculations.
1131/// - `NoRefraction`: No refraction
1132#[derive(Copy, Clone, Debug, PartialEq, Eq)]
1133pub enum Refraction {
1134    /// Bennett refraction model (suitable for most applications)
1135    ApSolposBennet,
1136    /// Bennett refraction model optimized for accuracy, matches closer with The Nautical Almanac (recommended for precision astronomical calculations)
1137    ApSolposBennetNA,
1138    /// No refraction
1139    NoRefraction,
1140}
1141
1142impl Refraction {
1143    fn apply(
1144        self,
1145        position: SolarPosition,
1146        gdip: Option<f64>,
1147        elevation: f64,
1148        pressure: f64,
1149        temperature: f64,
1150    ) -> Result<SolarPosition, CalculationError> {
1151        match self {
1152            Refraction::ApSolposBennet => apply_refraction(
1153                bennet_refraction,
1154                inverse_bennet_refraction,
1155                position,
1156                gdip,
1157                elevation,
1158                pressure,
1159                temperature,
1160            ),
1161            Refraction::ApSolposBennetNA => apply_refraction(
1162                bennet_na_refraction,
1163                inverse_bennet_na_refraction,
1164                position,
1165                gdip,
1166                elevation,
1167                pressure,
1168                temperature,
1169            ),
1170            Refraction::NoRefraction => Ok(position),
1171        }
1172    }
1173}
1174
1175/// Calculate ΔT (Terrestrial Time minus Universal Time) for a given date.
1176///
1177/// ΔT represents the difference between Terrestrial Time (TT) and Universal Time (UT).
1178/// This value changes over time due to variations in Earth's rotation rate.
1179///
1180/// # Arguments
1181///
1182/// * `ut` - The datetime in Universal Time
1183///
1184/// # Returns
1185///
1186/// ΔT in seconds. As of 2024, this is approximately 69 seconds.
1187///
1188/// # Notes
1189///
1190/// - For dates within the table range (1657-2024), uses interpolated historical values
1191/// - For dates outside this range, uses polynomial approximation
1192/// - Historical values: ~64s (2000), ~57s (1990)
1193/// - For high-precision calculations, consult [IERS Bulletin A](https://www.iers.org/IERS/EN/Publications/Bulletins/bulletins.html)
1194///
1195/// # Example
1196///
1197/// ```
1198/// use astronomical_calculator::get_delta_t;
1199/// use chrono::NaiveDateTime;
1200///
1201/// let dt = NaiveDateTime::parse_from_str("2024-01-15 12:00:00", "%Y-%m-%d %H:%M:%S").unwrap().and_utc();
1202/// let delta_t = get_delta_t(&dt);
1203/// println!("ΔT for 2024: {:.2} seconds", delta_t);
1204/// // Output: approximately 69 seconds
1205/// ```
1206pub fn get_delta_t(ut: &DateTime<Utc>) -> f64 {
1207    let mut imin: i64 = 0;
1208    let mut imax: i64 = 1244;
1209    let dyear = ut.year() as f64 + ut.month() as f64 / 12.0 + (ut.day() as f64 - 1.0) / 365.0;
1210
1211    // Use polynomial approximation for dates outside table range
1212    if FREESPA_DELTA_T_TABLE[0] > dyear || FREESPA_DELTA_T_TABLE[(2 * imax) as usize] < dyear {
1213        let t = (dyear - 1820.0) / 100.0;
1214        return 32.0 * t * t - 20.0;
1215    }
1216
1217    // Binary search in the table
1218    while imax - imin > 1 {
1219        let i = (imin + imax) / 2;
1220        let table_year = FREESPA_DELTA_T_TABLE[(2 * i) as usize];
1221        if table_year > dyear {
1222            imax = i;
1223        } else if table_year < dyear {
1224            imin = i;
1225        } else {
1226            return FREESPA_DELTA_T_TABLE[(2 * i + 1) as usize];
1227        }
1228    }
1229
1230    // Linear interpolation between table entries
1231    FREESPA_DELTA_T_TABLE[(2 * imin + 1) as usize]
1232        + (dyear - FREESPA_DELTA_T_TABLE[(2 * imin) as usize])
1233            * (FREESPA_DELTA_T_TABLE[(2 * imax + 1) as usize] - FREESPA_DELTA_T_TABLE[(2 * imin + 1) as usize])
1234            / (FREESPA_DELTA_T_TABLE[(2 * imax) as usize] - FREESPA_DELTA_T_TABLE[(2 * imin) as usize])
1235}
1236
1237/// Evaluate polynomial using Horner's method
1238fn polynomial(coefficients: &[f64], x: f64) -> f64 {
1239    let mut result = coefficients[0];
1240    for coeff in coefficients.iter().skip(1) {
1241        result = coeff + x * result;
1242    }
1243    result
1244}
1245
1246/// Calculate equation of time
1247fn equation_of_time(jd: JulianDate, gp: GeoCentricSolPos) -> f64 {
1248    let dtau = PI * (-20.4898f64 / 3600.0f64) / 180.0f64 / gp.rad;
1249    let (dpsi, deps) = nutation_lon_obliquity(jd);
1250    let eps = deps + polynomial(&ECLIPTIC_MEAN_OBLIQUITY, jd.jme / 10.0) * (PI * (1.0 / 3600.0) / 180.0);
1251    let lambda = gp.lon + dpsi + dtau;
1252    let alpha = (lambda.sin() * eps.cos() - gp.lat.tan() * eps.sin()).atan2(lambda.cos());
1253    let m = polynomial(&SMLON, jd.jme);
1254    let mut e = (m - PI * 0.0057183f64 / 180.0f64 - alpha + dpsi * eps.cos()).rem(2.0 * PI);
1255    if e > PI * 5.0 / 180.0 {
1256        e -= 2.0 * PI;
1257    }
1258    if e < -(PI * 5.0 / 180.0f64) {
1259        e += 2.0 * PI;
1260    }
1261    e
1262}
1263
1264/// Calculate nutation in longitude and obliquity
1265fn nutation_lon_obliquity(jd: JulianDate) -> (f64, f64) {
1266    let mut sum_psi: f64 = 0.0;
1267    let mut sum_eps: f64 = 0.0;
1268
1269    // Calculate fundamental arguments
1270    let x = [
1271        polynomial(&MEAN_ELONGATION_MOON_SUN, jd.jce),
1272        polynomial(&MEAN_ANOMALY_SUN, jd.jce),
1273        polynomial(&MEAN_ANOMALY_MOON, jd.jce),
1274        polynomial(&ARG_LAT_MOON, jd.jce),
1275        polynomial(&ASC_LON_MOON, jd.jce),
1276    ];
1277
1278    // Sum nutation terms
1279    for i in 0..NY {
1280        let sum: f64 = x
1281            .iter()
1282            .zip(&Y_TERMS[i as usize])
1283            .map(|(x_val, y_term)| x_val * (*y_term as f64))
1284            .sum();
1285
1286        let pe = &PE_TERMS[i as usize];
1287        sum_psi += (pe[0] + jd.jce * pe[1]) * sum.sin();
1288        sum_eps += (pe[2] + jd.jce * pe[3]) * sum.cos();
1289    }
1290
1291    (
1292        sum_psi * (PI * (1.0 / 36000000.0) / 180.0),
1293        sum_eps * (PI * (1.0 / 36000000.0) / 180.0),
1294    )
1295}
1296
1297/// Sum periodic terms for heliocentric calculations
1298fn sum_periodic_terms(terms: &[PTerm], jd: &JulianDate) -> f64 {
1299    terms.iter().map(|term| term.a * (term.p + term.w * jd.jme).cos()).sum()
1300}
1301
1302/// Calculate heliocentric longitude
1303fn heliocentric_longitude(jd: &JulianDate) -> f64 {
1304    let mut lon = sum_periodic_terms(&EARTH_LON0, jd);
1305    let mut power = jd.jme;
1306
1307    lon += sum_periodic_terms(&EARTH_LON1, jd) * power;
1308    power *= jd.jme;
1309    lon += sum_periodic_terms(&EARTH_LON2, jd) * power;
1310    power *= jd.jme;
1311    lon += sum_periodic_terms(&EARTH_LON3, jd) * power;
1312    power *= jd.jme;
1313    lon += sum_periodic_terms(&EARTH_LON4, jd) * power;
1314    power *= jd.jme;
1315    lon += sum_periodic_terms(&EARTH_LON5, jd) * power;
1316
1317    lon / 1.0e8
1318}
1319
1320/// Calculate heliocentric latitude
1321fn heliocentric_latitude(jd: &JulianDate) -> f64 {
1322    let lat = sum_periodic_terms(&EARTH_LAT0, jd) + sum_periodic_terms(&EARTH_LAT1, jd) * jd.jme;
1323    lat / 1.0e8
1324}
1325
1326/// Calculate heliocentric radius vector
1327fn heliocentric_radius(jd: &JulianDate) -> f64 {
1328    let mut rad = sum_periodic_terms(&EARTH_RAD0, jd);
1329    let mut power = jd.jme;
1330
1331    rad += sum_periodic_terms(&EARTH_RAD1, jd) * power;
1332    power *= jd.jme;
1333    rad += sum_periodic_terms(&EARTH_RAD2, jd) * power;
1334    power *= jd.jme;
1335    rad += sum_periodic_terms(&EARTH_RAD3, jd) * power;
1336    power *= jd.jme;
1337    rad += sum_periodic_terms(&EARTH_RAD4, jd) * power;
1338
1339    rad / 1.0e8
1340}
1341
1342/// Convert Julian date to DateTime<Utc>
1343fn julian_date_to_datetime(julian_day: f64) -> Result<DateTime<Utc>, CalculationError> {
1344    let unix_millis = jd_to_timestamp(julian_day);
1345    Utc.timestamp_millis_opt(unix_millis)
1346        .single()
1347        .ok_or(CalculationError::TimeConversionError)
1348}
1349
1350/// Convert Unix timestamp to DateTime<Utc>
1351fn unix_to_datetime(timestamp: i64) -> Result<DateTime<Utc>, CalculationError> {
1352    julian_date_to_datetime((timestamp - ETJD0) as f64 / 86400.0 + JD0)
1353}
1354
1355// ============================================================================
1356// Atmospheric Refraction
1357// ============================================================================
1358
1359/// Generic refraction calculation using specified coefficients
1360fn calculate_refraction(coefficients: &[f64], pressure: f64, temperature: f64, altitude: f64) -> f64 {
1361    let pressure_ratio = pressure / AP0;
1362    let temp_ratio = (AT0 - ABSOLUTEZERO) / (temperature - ABSOLUTEZERO);
1363    let angle_term = (altitude + coefficients[1] / (altitude + coefficients[2])).tan();
1364
1365    pressure_ratio * temp_ratio * coefficients[0] / angle_term
1366}
1367
1368/// Bennet refraction model
1369fn bennet_refraction(pressure: f64, temperature: f64, altitude: f64) -> f64 {
1370    calculate_refraction(&BENNET, pressure, temperature, altitude)
1371}
1372
1373/// Inverse Bennet refraction model
1374fn inverse_bennet_refraction(pressure: f64, temperature: f64, altitude: f64) -> f64 {
1375    calculate_refraction(&IBENNET, pressure, temperature, altitude)
1376}
1377
1378/// Bennet refraction model (optimized for accuracy)
1379fn bennet_na_refraction(pressure: f64, temperature: f64, altitude: f64) -> f64 {
1380    calculate_refraction(&BENNETNA, pressure, temperature, altitude)
1381}
1382
1383/// Inverse Bennet refraction model (optimized for accuracy)
1384fn inverse_bennet_na_refraction(pressure: f64, temperature: f64, altitude: f64) -> f64 {
1385    calculate_refraction(&IBENNETNA, pressure, temperature, altitude)
1386}
1387
1388/// Apply atmospheric refraction to solar position
1389fn apply_refraction(
1390    refraction_fn: fn(f64, f64, f64) -> f64,
1391    inverse_refraction_fn: fn(f64, f64, f64) -> f64,
1392    mut position: SolarPosition,
1393    gdip: Option<f64>,
1394    elevation: f64,
1395    pressure: f64,
1396    temperature: f64,
1397) -> Result<SolarPosition, CalculationError> {
1398    // Calculate geometric dip
1399    let dip = if let Some(gdip) = gdip {
1400        if gdip.abs() > PI / 2.0 {
1401            return Err(CalculationError::GeometricDipOutOfRange);
1402        }
1403        gdip
1404    } else if elevation > 0.0 {
1405        (EARTH_R / (EARTH_R + elevation)).acos()
1406    } else {
1407        0.0
1408    };
1409
1410    // Calculate refraction correction
1411    let atmospheric_refraction = refraction_fn(pressure, temperature, -dip);
1412    let altitude = PI / 2.0 - position.zenith;
1413    let altitude_correction = if altitude >= -atmospheric_refraction - SUN_RADIUS - dip {
1414        inverse_refraction_fn(pressure, temperature, altitude)
1415    } else {
1416        0.0
1417    };
1418
1419    // Apply correction
1420    position.zenith -= altitude_correction;
1421    position.zenith = position.zenith.rem(2.0 * PI);
1422
1423    // Normalize zenith angle
1424    if position.zenith < 0.0 {
1425        position.zenith = -position.zenith;
1426        position.azimuth = (position.azimuth + PI).rem(2.0 * PI);
1427    }
1428    if position.zenith > PI {
1429        position.zenith = 2.0 * PI - position.zenith;
1430        position.azimuth = (position.azimuth + PI).rem(2.0 * PI);
1431    }
1432
1433    Ok(position)
1434}
1435
1436// ============================================================================
1437// Julian Date and Geocentric Position
1438// ============================================================================
1439
1440impl JulianDate {
1441    fn new(ut: DateTime<Utc>, delta_t: Option<f64>, delta_ut1: f64) -> Self {
1442        let jd = timestamp_to_jd((ut.timestamp_millis() as f64 + (delta_ut1 * 1000.0)) as i64);
1443        let dt = if let Some(delta_t) = delta_t {
1444            delta_t
1445        } else {
1446            get_delta_t(&ut)
1447        };
1448        let jde = jd + dt / 86400.0;
1449        let jc = (jd - JD0) / 36525.0;
1450        let jce = (jde - JD0) / 36525.0;
1451        let jme = jce / 10.0;
1452        Self { jd, jde, jc, jce, jme }
1453    }
1454
1455    fn from_unix_time(unix_time: i64, delta_t: Option<f64>, delta_ut1: f64) -> Result<Self, CalculationError> {
1456        unix_to_datetime(unix_time).map(|ut| Self::new(ut, delta_t, delta_ut1))
1457    }
1458}
1459
1460/// Geocentric solar position coordinates.
1461///
1462/// Represents the Sun's position as seen from Earth's center in ecliptic coordinates.
1463/// These values are computed using VSOP87 theory and converted from heliocentric to geocentric coordinates.
1464///
1465/// # Fields
1466///
1467/// - `lat`: Geocentric latitude in radians (ecliptic coordinate system)
1468/// - `lon`: Geocentric longitude in radians (ecliptic coordinate system)
1469/// - `rad`: Radius vector (Sun-Earth distance) in Astronomical Units (AU)
1470#[derive(Copy, Clone, Debug)]
1471struct GeoCentricSolPos {
1472    /// Geocentric latitude in radians
1473    lat: f64,
1474    /// Geocentric longitude in radians
1475    lon: f64,
1476    /// Sun-Earth distance in AU
1477    rad: f64,
1478}
1479
1480impl GeoCentricSolPos {
1481    fn new(jd: &JulianDate) -> Self {
1482        // Convert heliocentric to geocentric coordinates
1483        let lat = (-heliocentric_latitude(jd)).rem(2.0 * PI);
1484        let mut lon = (heliocentric_longitude(jd) + PI).rem(2.0 * PI);
1485        if lon < 0.0 {
1486            lon += 2.0 * PI;
1487        }
1488        let rad = heliocentric_radius(jd);
1489        GeoCentricSolPos { lat, lon, rad }
1490    }
1491}
1492
1493/// Find the time when the sun is at a specific hour angle (solar time)
1494pub(crate) fn find_solar_time(
1495    timestamp: i64,
1496    hour: i64,
1497    min: i64,
1498    sec: i64,
1499    delta_t: Option<f64>,
1500    delta_ut1: f64,
1501    longitude: f64,
1502) -> Result<i64, CalculationError> {
1503    let mut jd = JulianDate::from_unix_time(timestamp, delta_t, delta_ut1)?;
1504    #[cfg(test)]
1505    {
1506        extern crate std;
1507        std::println!("jd: {:?}", jd);
1508    }
1509    let mut datetime = true_solar_time(unix_to_datetime(timestamp)?, delta_t, delta_ut1, longitude)?;
1510
1511    // Calculate initial time offset
1512    let mut time_delta = (hour - datetime.hour() as i64) as f64 / 24.0;
1513    time_delta += (min - datetime.minute() as i64) as f64 / 1440.0;
1514    time_delta += (sec - datetime.second() as i64) as f64 / 86400.0;
1515
1516    // Normalize to [-0.5, 0.5] day range
1517    if time_delta > 0.5 {
1518        time_delta -= 1.0;
1519    }
1520    if time_delta < -0.5 {
1521        time_delta += 1.0;
1522    }
1523
1524    jd.jd += time_delta;
1525    time_delta = 1.0;
1526
1527    // Iterate to find exact solar time
1528    let mut iter = 0;
1529    while time_delta.abs() > FRACDAYSEC && iter < MAX_FPITER {
1530        let mut jd_new = jd;
1531        let geocentric_pos = GeoCentricSolPos::new(&jd);
1532        let eot = equation_of_time(jd, geocentric_pos);
1533
1534        jd_new.jd += (longitude + eot) / PI / 2.0;
1535        datetime = julian_date_to_datetime(jd_new.jd)?;
1536
1537        time_delta = (hour - datetime.hour() as i64) as f64 / 24.0;
1538        time_delta += (min - datetime.minute() as i64) as f64 / 1440.0;
1539        time_delta += (sec - datetime.second() as i64) as f64 / 86400.0;
1540
1541        if time_delta > 0.5 {
1542            time_delta -= 1.0;
1543        }
1544        if time_delta < -0.5 {
1545            time_delta += 1.0;
1546        }
1547
1548        jd.jd += time_delta;
1549        iter += 1;
1550    }
1551
1552    Ok(julian_date_to_unix(&jd))
1553}
1554
1555/// Convert Julian date to Unix timestamp
1556fn julian_date_to_unix(jd: &JulianDate) -> i64 {
1557    ((jd.jd - JD0) * 86400.0).round() as i64 + ETJD0
1558}
1559
1560/// Convert DateTime<Utc> to Unix timestamp
1561fn datetime_to_unix(datetime: DateTime<Utc>) -> i64 {
1562    let jd = JulianDate::new(datetime, None, 0.0);
1563    ((jd.jd - JD0) * 86400.0).round() as i64 + ETJD0
1564}
1565
1566/// Julian date and related time values for astronomical calculations.
1567///
1568/// This struct contains various time representations used internally for astronomical computations.
1569/// All values are computed relative to the J2000.0 epoch (JD 2451545.0).
1570///
1571/// # Fields
1572///
1573/// - `jd`: Julian Date in Universal Time (UT)
1574/// - `jde`: Julian Ephemeris Date in Terrestrial Time (TT), used for ephemeris calculations
1575/// - `jc`: Julian Century from J2000.0 in UT
1576/// - `jce`: Julian Century from J2000.0 in TT
1577/// - `jme`: Julian Millennium from J2000.0 in TT
1578/// - `e`: Unix timestamp (seconds since 1970-01-01 00:00:00 UTC)
1579///
1580/// # Note
1581///
1582/// This struct is primarily used internally but is exposed for advanced use cases.
1583/// Most users should interact with [`AstronomicalCalculator`] methods instead.
1584#[derive(Copy, Clone, Debug)]
1585pub struct JulianDate {
1586    /// Julian Date (UT)
1587    pub jd: f64,
1588    /// Julian Ephemeris Date (TT)
1589    pub jde: f64,
1590    /// Julian Century from J2000.0 (UT)
1591    pub jc: f64,
1592    /// Julian Century from J2000.0 (TT)
1593    pub jce: f64,
1594    /// Julian Millennium from J2000.0 (TT)
1595    pub jme: f64,
1596}
1597
1598/// Errors that can occur during solar position calculations.
1599///
1600/// All input parameters are validated when creating an [`AstronomicalCalculator`].
1601/// These errors indicate that a parameter falls outside its valid range.
1602///
1603/// # Variants
1604///
1605/// - `DeltaUt1OutOfRange`: ΔUT1 must be in range [-1.0, 1.0] seconds
1606/// - `LongitudeOutOfRange`: Longitude must be in range [-π, π] radians ([-180°, 180°])
1607/// - `LatitudeOutOfRange`: Latitude must be in range [-π/2, π/2] radians ([-90°, 90°])
1608/// - `ElevationOutOfRange`: Elevation must be > -6,500,000 meters
1609/// - `PressureOutOfRange`: Pressure must be in range (0.0, 5000.0] millibars
1610/// - `TemperatureOutOfRange`: Temperature must be > -273.15°C (absolute zero)
1611/// - `GeometricDipOutOfRange`: Geometric dip must be in range [-5.0, 5.0] degrees
1612/// - `TimeConversionError`: Invalid datetime or timestamp conversion
1613#[derive(Error, Debug, Clone, Copy)]
1614pub enum CalculationError {
1615    /// ΔUT1 parameter out of valid range [-1.0, 1.0] seconds
1616    #[error("ΔUT1 out of range")]
1617    DeltaUt1OutOfRange,
1618
1619    /// Longitude out of valid range [-π, π] radians
1620    #[error("Longitude out of range")]
1621    LongitudeOutOfRange,
1622
1623    /// Latitude out of valid range [-π/2, π/2] radians
1624    #[error("Latitude out of range")]
1625    LatitudeOutOfRange,
1626
1627    /// Elevation below minimum value (-6,500,000 meters)
1628    #[error("Elevation out of range")]
1629    ElevationOutOfRange,
1630
1631    /// Pressure out of valid range (0.0, 5000.0] millibars
1632    #[error("Pressure out of range")]
1633    PressureOutOfRange,
1634
1635    /// Temperature below absolute zero (-273.15°C)
1636    #[error("Temperature out of range")]
1637    TemperatureOutOfRange,
1638
1639    /// Geometric dip angle out of valid range [-5.0, 5.0] degrees
1640    #[error("Geometric dip out of range")]
1641    GeometricDipOutOfRange,
1642
1643    /// Error converting between time representations
1644    #[error("Time conversion error")]
1645    TimeConversionError,
1646}
1647
1648fn true_solar_time(
1649    ut: DateTime<Utc>,
1650    delta_t: Option<f64>,
1651    delta_ut1: f64,
1652    lon: f64,
1653) -> Result<DateTime<Utc>, CalculationError> {
1654    let mut jd = JulianDate::new(ut, delta_t, delta_ut1);
1655    let geocentric_pos = GeoCentricSolPos::new(&jd);
1656    let eot = equation_of_time(jd, geocentric_pos);
1657    jd.jd += (lon + eot) / PI / 2.0;
1658    // jd.jd = (jd.jd - ETJD0 as f64) / 86400.0f64 + JD0;
1659    let datetime = julian_date_to_datetime(jd.jd)?;
1660    Ok(datetime)
1661}
1662
1663pub(crate) fn jd_to_timestamp(jd: f64) -> i64 {
1664    ((jd - 2440587.5) * 86400000.0).round() as i64
1665}
1666
1667pub(crate) fn timestamp_to_jd(ms: i64) -> f64 {
1668    (ms as f64 / 86400000.0) + 2440587.5
1669}