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}