solar_positioning/
time.rs

1//! Time-related calculations for solar positioning.
2//!
3//! This module provides Julian date calculations and ΔT (Delta T) estimation
4//! following the algorithms from NREL SPA and Espenak & Meeus.
5
6#![allow(clippy::unreadable_literal)]
7#![allow(clippy::many_single_char_names)]
8
9use crate::math::{floor, polynomial};
10use crate::{Error, Result};
11use chrono::{Datelike, TimeZone, Timelike};
12
13/// Seconds per day (86,400)
14const SECONDS_PER_DAY: f64 = 86_400.0;
15
16/// Julian Day Number for J2000.0 epoch (2000-01-01 12:00:00 UTC)
17const J2000_JDN: f64 = 2_451_545.0;
18
19/// Days per Julian century
20const DAYS_PER_CENTURY: f64 = 36_525.0;
21
22/// Julian date representation for astronomical calculations.
23///
24/// Follows the SPA algorithm described in Reda & Andreas (2003).
25/// Supports both Julian Date (JD) and Julian Ephemeris Date (JDE) calculations.
26#[derive(Debug, Clone, Copy, PartialEq)]
27pub struct JulianDate {
28    /// Julian Date (JD) - referenced to UT1
29    jd: f64,
30    /// Delta T in seconds - difference between TT and UT1
31    delta_t: f64,
32}
33
34impl JulianDate {
35    /// Creates a new Julian date from a timezone-aware chrono `DateTime`.
36    ///
37    /// This function properly handles timezone-aware datetimes by converting the entire
38    /// datetime to UTC for accurate Julian Date calculations. Solar calculations require
39    /// proper handling of the Earth's rotation relative to the Sun, which is referenced
40    /// to Universal Time.
41    ///
42    /// # Arguments
43    /// * `datetime` - Timezone-aware date and time
44    /// * `delta_t` - ΔT in seconds (difference between TT and UT1)
45    ///
46    /// # Returns
47    /// Julian date or error if the date is invalid
48    ///
49    /// # Errors
50    /// Returns error if the date/time components are invalid (e.g., invalid month, day, hour).
51    pub fn from_datetime<Tz: TimeZone>(
52        datetime: &chrono::DateTime<Tz>,
53        delta_t: f64,
54    ) -> Result<Self> {
55        // Convert the entire datetime to UTC for proper Julian Date calculation
56        let utc_datetime = datetime.with_timezone(&chrono::Utc);
57        Self::from_utc(
58            utc_datetime.year(),
59            utc_datetime.month(),
60            utc_datetime.day(),
61            utc_datetime.hour(),
62            utc_datetime.minute(),
63            f64::from(utc_datetime.second()) + f64::from(utc_datetime.nanosecond()) / 1e9,
64            delta_t,
65        )
66    }
67
68    /// Creates a new Julian date from year, month, day, hour, minute, and second in UTC.
69    ///
70    /// # Arguments
71    /// * `year` - Year (can be negative for BCE years)
72    /// * `month` - Month (1-12)
73    /// * `day` - Day of month (1-31)
74    /// * `hour` - Hour (0-23)
75    /// * `minute` - Minute (0-59)
76    /// * `second` - Second (0-59, can include fractional seconds)
77    /// * `delta_t` - ΔT in seconds (difference between TT and UT1)
78    ///
79    /// # Returns
80    /// Julian date or error if the date is invalid
81    ///
82    /// # Errors
83    /// Returns error if any date/time component is outside valid ranges (month 1-12, day 1-31, hour 0-23, minute 0-59, second 0-59.999).
84    ///
85    /// # Example
86    /// ```
87    /// # use solar_positioning::time::JulianDate;
88    /// let jd = JulianDate::from_utc(2023, 6, 21, 12, 0, 0.0, 69.0).unwrap();
89    /// assert!(jd.julian_date() > 2_460_000.0);
90    /// ```
91    pub fn from_utc(
92        year: i32,
93        month: u32,
94        day: u32,
95        hour: u32,
96        minute: u32,
97        second: f64,
98        delta_t: f64,
99    ) -> Result<Self> {
100        // Validate input ranges
101        if !(1..=12).contains(&month) {
102            return Err(Error::invalid_datetime("month must be between 1 and 12"));
103        }
104        if !(1..=31).contains(&day) {
105            return Err(Error::invalid_datetime("day must be between 1 and 31"));
106        }
107        if hour > 23 {
108            return Err(Error::invalid_datetime("hour must be between 0 and 23"));
109        }
110        if minute > 59 {
111            return Err(Error::invalid_datetime("minute must be between 0 and 59"));
112        }
113        if !(0.0..60.0).contains(&second) {
114            return Err(Error::invalid_datetime(
115                "second must be between 0 and 59.999...",
116            ));
117        }
118
119        let jd = calculate_julian_date(year, month, day, hour, minute, second);
120        Ok(Self { jd, delta_t })
121    }
122
123    /// Creates a Julian date assuming ΔT = 0.
124    ///
125    /// # Arguments
126    /// * `year`, `month`, `day`, `hour`, `minute`, `second` - UTC date/time components
127    ///
128    /// # Returns
129    /// Julian date with ΔT = 0
130    ///
131    /// # Errors
132    /// Returns error if the date/time components are outside valid ranges.
133    pub fn from_utc_simple(
134        year: i32,
135        month: u32,
136        day: u32,
137        hour: u32,
138        minute: u32,
139        second: f64,
140    ) -> Result<Self> {
141        Self::from_utc(year, month, day, hour, minute, second, 0.0)
142    }
143
144    /// Gets the Julian Date (JD) value.
145    ///
146    /// # Returns
147    /// Julian Date referenced to UT1
148    #[must_use]
149    pub const fn julian_date(&self) -> f64 {
150        self.jd
151    }
152
153    /// Gets the ΔT value in seconds.
154    ///
155    /// # Returns
156    /// ΔT (Delta T) in seconds
157    #[must_use]
158    pub const fn delta_t(&self) -> f64 {
159        self.delta_t
160    }
161
162    /// Calculates the Julian Ephemeris Day (JDE).
163    ///
164    /// JDE = JD + ΔT/86400
165    ///
166    /// # Returns
167    /// Julian Ephemeris Day
168    #[must_use]
169    pub fn julian_ephemeris_day(&self) -> f64 {
170        self.jd + self.delta_t / SECONDS_PER_DAY
171    }
172
173    /// Calculates the Julian Century (JC) from J2000.0.
174    ///
175    /// JC = (JD - 2451545.0) / 36525
176    ///
177    /// # Returns
178    /// Julian centuries since J2000.0 epoch
179    #[must_use]
180    pub fn julian_century(&self) -> f64 {
181        (self.jd - J2000_JDN) / DAYS_PER_CENTURY
182    }
183
184    /// Calculates the Julian Ephemeris Century (JCE) from J2000.0.
185    ///
186    /// JCE = (JDE - 2451545.0) / 36525
187    ///
188    /// # Returns
189    /// Julian ephemeris centuries since J2000.0 epoch
190    #[must_use]
191    pub fn julian_ephemeris_century(&self) -> f64 {
192        (self.julian_ephemeris_day() - J2000_JDN) / DAYS_PER_CENTURY
193    }
194
195    /// Calculates the Julian Ephemeris Millennium (JME) from J2000.0.
196    ///
197    /// JME = JCE / 10
198    ///
199    /// # Returns
200    /// Julian ephemeris millennia since J2000.0 epoch
201    #[must_use]
202    pub fn julian_ephemeris_millennium(&self) -> f64 {
203        self.julian_ephemeris_century() / 10.0
204    }
205
206    /// Add days to the Julian date (like Java constructor: new `JulianDate(jd.julianDate()` + i - 1, 0))
207    pub(crate) fn add_days(self, days: f64) -> Self {
208        Self {
209            jd: self.jd + days,
210            delta_t: self.delta_t,
211        }
212    }
213}
214
215/// Calculates Julian Date from UTC date/time components.
216///
217/// This follows the algorithm from Reda & Andreas (2003), which is based on
218/// Meeus, "Astronomical Algorithms", 2nd edition.
219fn calculate_julian_date(
220    year: i32,
221    month: u32,
222    day: u32,
223    hour: u32,
224    minute: u32,
225    second: f64,
226) -> f64 {
227    let mut y = year;
228    let mut m = i32::try_from(month).expect("month should be valid i32");
229
230    // Adjust for January and February being treated as months 13 and 14 of previous year
231    if m < 3 {
232        y -= 1;
233        m += 12;
234    }
235
236    // Calculate fractional day
237    let d = f64::from(day) + (f64::from(hour) + (f64::from(minute) + second / 60.0) / 60.0) / 24.0;
238
239    // Basic Julian Date calculation
240    let mut jd =
241        floor(365.25 * (f64::from(y) + 4716.0)) + floor(30.6001 * f64::from(m + 1)) + d - 1524.5;
242
243    // Gregorian calendar correction (after October 15, 1582)
244    // JDN 2299161 corresponds to October 15, 1582
245    if jd >= 2_299_161.0 {
246        let a = floor(f64::from(y) / 100.0);
247        let b = 2.0 - a + floor(a / 4.0);
248        jd += b;
249    }
250
251    jd
252}
253
254/// Utilities for estimating ΔT (Delta T) values.
255///
256/// ΔT represents the difference between Terrestrial Time (TT) and Universal Time (UT1).
257/// These estimates are based on Espenak and Meeus polynomial fits updated in 2014.
258pub struct DeltaT;
259
260impl DeltaT {
261    /// Estimates ΔT for a given decimal year.
262    ///
263    /// Based on polynomial fits from Espenak & Meeus, updated 2014.
264    /// See: <https://www.eclipsewise.com/help/deltatpoly2014.html>
265    ///
266    /// # Arguments
267    /// * `decimal_year` - Year with fractional part (e.g., 2024.5 for mid-2024)
268    ///
269    /// # Returns
270    /// Estimated ΔT in seconds
271    ///
272    /// # Errors
273    /// Returns error for years outside the valid range (-500 to 3000 CE)
274    ///
275    /// # Example
276    /// ```
277    /// # use solar_positioning::time::DeltaT;
278    /// let delta_t = DeltaT::estimate(2024.0).unwrap();
279    /// assert!(delta_t > 60.0 && delta_t < 80.0); // Reasonable range for 2024
280    /// ```
281    #[allow(clippy::too_many_lines)] // Comprehensive polynomial fit across historical periods
282    pub fn estimate(decimal_year: f64) -> Result<f64> {
283        let year = decimal_year;
284
285        if !year.is_finite() {
286            return Err(Error::invalid_datetime("year must be finite"));
287        }
288
289        let delta_t = if year < -500.0 {
290            let u = (year - 1820.0) / 100.0;
291            polynomial(&[-20.0, 0.0, 32.0], u)
292        } else if year < 500.0 {
293            let u = year / 100.0;
294            polynomial(
295                &[
296                    10583.6,
297                    -1014.41,
298                    33.78311,
299                    -5.952053,
300                    -0.1798452,
301                    0.022174192,
302                    0.0090316521,
303                ],
304                u,
305            )
306        } else if year < 1600.0 {
307            let u = (year - 1000.0) / 100.0;
308            polynomial(
309                &[
310                    1574.2,
311                    -556.01,
312                    71.23472,
313                    0.319781,
314                    -0.8503463,
315                    -0.005050998,
316                    0.0083572073,
317                ],
318                u,
319            )
320        } else if year < 1700.0 {
321            let t = year - 1600.0;
322            polynomial(&[120.0, -0.9808, -0.01532, 1.0 / 7129.0], t)
323        } else if year < 1800.0 {
324            let t = year - 1700.0;
325            polynomial(
326                &[8.83, 0.1603, -0.0059285, 0.00013336, -1.0 / 1_174_000.0],
327                t,
328            )
329        } else if year < 1860.0 {
330            let t = year - 1800.0;
331            polynomial(
332                &[
333                    13.72,
334                    -0.332447,
335                    0.0068612,
336                    0.0041116,
337                    -0.00037436,
338                    0.0000121272,
339                    -0.0000001699,
340                    0.000000000875,
341                ],
342                t,
343            )
344        } else if year < 1900.0 {
345            let t = year - 1860.0;
346            polynomial(
347                &[
348                    7.62,
349                    0.5737,
350                    -0.251754,
351                    0.01680668,
352                    -0.0004473624,
353                    1.0 / 233_174.0,
354                ],
355                t,
356            )
357        } else if year < 1920.0 {
358            let t = year - 1900.0;
359            polynomial(&[-2.79, 1.494119, -0.0598939, 0.0061966, -0.000197], t)
360        } else if year < 1941.0 {
361            let t = year - 1920.0;
362            polynomial(&[21.20, 0.84493, -0.076100, 0.0020936], t)
363        } else if year < 1961.0 {
364            let t = year - 1950.0;
365            polynomial(&[29.07, 0.407, -1.0 / 233.0, 1.0 / 2547.0], t)
366        } else if year < 1986.0 {
367            let t = year - 1975.0;
368            polynomial(&[45.45, 1.067, -1.0 / 260.0, -1.0 / 718.0], t)
369        } else if year < 2005.0 {
370            let t = year - 2000.0;
371            polynomial(
372                &[
373                    63.86,
374                    0.3345,
375                    -0.060374,
376                    0.0017275,
377                    0.000651814,
378                    0.00002373599,
379                ],
380                t,
381            )
382        } else if year < 2015.0 {
383            let t = year - 2005.0;
384            polynomial(&[64.69, 0.2930], t)
385        } else if year <= 3000.0 {
386            let t = year - 2015.0;
387            polynomial(&[67.62, 0.3645, 0.0039755], t)
388        } else {
389            return Err(Error::invalid_datetime(
390                "ΔT estimates not available beyond year 3000",
391            ));
392        };
393
394        Ok(delta_t)
395    }
396
397    /// Estimates ΔT from year and month.
398    ///
399    /// Calculates decimal year as: year + (month - 0.5) / 12
400    ///
401    /// # Arguments
402    /// * `year` - Year
403    /// * `month` - Month (1-12)
404    ///
405    /// # Returns
406    /// Estimated ΔT in seconds
407    ///
408    /// # Errors
409    /// Returns error if month is outside the range 1-12.
410    pub fn estimate_from_date(year: i32, month: u32) -> Result<f64> {
411        if !(1..=12).contains(&month) {
412            return Err(Error::invalid_datetime("month must be between 1 and 12"));
413        }
414
415        let decimal_year = f64::from(year) + (f64::from(month) - 0.5) / 12.0;
416        Self::estimate(decimal_year)
417    }
418
419    /// Estimates ΔT from any date-like type.
420    ///
421    /// Convenience method that extracts the year and month from any chrono type
422    /// that implements `Datelike` (`DateTime`, `NaiveDateTime`, `NaiveDate`, etc.).
423    ///
424    /// # Arguments
425    /// * `date` - Any date-like type
426    ///
427    /// # Returns
428    /// Estimated ΔT in seconds
429    ///
430    /// # Errors
431    /// Returns error if the date components are invalid.
432    ///
433    /// # Example
434    /// ```
435    /// # use solar_positioning::time::DeltaT;
436    /// # use chrono::{DateTime, FixedOffset, NaiveDate};
437    ///
438    /// // Works with DateTime
439    /// let datetime = "2024-06-21T12:00:00-07:00".parse::<DateTime<FixedOffset>>().unwrap();
440    /// let delta_t = DeltaT::estimate_from_date_like(&datetime).unwrap();
441    /// assert!(delta_t > 60.0 && delta_t < 80.0);
442    ///
443    /// // Also works with NaiveDate
444    /// let date = NaiveDate::from_ymd_opt(2024, 6, 21).unwrap();
445    /// let delta_t2 = DeltaT::estimate_from_date_like(&date).unwrap();
446    /// assert_eq!(delta_t, delta_t2);
447    /// ```
448    pub fn estimate_from_date_like<D: Datelike>(date: &D) -> Result<f64> {
449        Self::estimate_from_date(date.year(), date.month())
450    }
451}
452
453#[cfg(test)]
454mod tests {
455    use super::*;
456
457    const EPSILON: f64 = 1e-10;
458
459    #[test]
460    fn test_julian_date_creation() {
461        let jd = JulianDate::from_utc(2000, 1, 1, 12, 0, 0.0, 0.0).unwrap();
462
463        // J2000.0 epoch should be exactly 2451545.0
464        assert!((jd.julian_date() - J2000_JDN).abs() < EPSILON);
465        assert_eq!(jd.delta_t(), 0.0);
466    }
467
468    #[test]
469    fn test_julian_date_validation() {
470        assert!(JulianDate::from_utc(2024, 13, 1, 0, 0, 0.0, 0.0).is_err()); // Invalid month
471        assert!(JulianDate::from_utc(2024, 1, 32, 0, 0, 0.0, 0.0).is_err()); // Invalid day
472        assert!(JulianDate::from_utc(2024, 1, 1, 24, 0, 0.0, 0.0).is_err()); // Invalid hour
473        assert!(JulianDate::from_utc(2024, 1, 1, 0, 60, 0.0, 0.0).is_err()); // Invalid minute
474        assert!(JulianDate::from_utc(2024, 1, 1, 0, 0, 60.0, 0.0).is_err()); // Invalid second
475    }
476
477    #[test]
478    fn test_julian_centuries() {
479        let jd = JulianDate::from_utc(2000, 1, 1, 12, 0, 0.0, 0.0).unwrap();
480
481        // J2000.0 should give JC = 0
482        assert!(jd.julian_century().abs() < EPSILON);
483        assert!(jd.julian_ephemeris_century().abs() < EPSILON);
484        assert!(jd.julian_ephemeris_millennium().abs() < EPSILON);
485    }
486
487    #[test]
488    fn test_julian_ephemeris_day() {
489        let delta_t = 69.0; // seconds
490        let jd = JulianDate::from_utc(2023, 6, 21, 12, 0, 0.0, delta_t).unwrap();
491
492        let jde = jd.julian_ephemeris_day();
493        let expected = jd.julian_date() + delta_t / SECONDS_PER_DAY;
494
495        assert!((jde - expected).abs() < EPSILON);
496    }
497
498    #[test]
499    fn test_gregorian_calendar_correction() {
500        // Test dates before and after Gregorian calendar adoption
501        // October 4, 1582 was followed by October 15, 1582
502        let julian_date = JulianDate::from_utc(1582, 10, 4, 12, 0, 0.0, 0.0).unwrap();
503        let gregorian_date = JulianDate::from_utc(1582, 10, 15, 12, 0, 0.0, 0.0).unwrap();
504
505        // The calendar dates are 11 days apart, but in Julian Day Numbers they should be 1 day apart
506        // because the 10-day gap was artificial
507        let diff = gregorian_date.julian_date() - julian_date.julian_date();
508        assert!(
509            (diff - 1.0).abs() < 1e-6,
510            "Expected 1 day difference in JD, got {diff}"
511        );
512
513        // Test that the Gregorian correction is applied correctly
514        // Dates after October 15, 1582 should have the correction
515        let pre_gregorian = JulianDate::from_utc(1582, 10, 1, 12, 0, 0.0, 0.0).unwrap();
516        let post_gregorian = JulianDate::from_utc(1583, 1, 1, 12, 0, 0.0, 0.0).unwrap();
517
518        // Verify that both exist and the calculation doesn't panic
519        assert!(pre_gregorian.julian_date() > 2_000_000.0);
520        assert!(post_gregorian.julian_date() > pre_gregorian.julian_date());
521    }
522
523    #[test]
524    fn test_delta_t_modern_estimates() {
525        // Test some known ranges
526        let delta_t_2000 = DeltaT::estimate(2000.0).unwrap();
527        let delta_t_2020 = DeltaT::estimate(2020.0).unwrap();
528
529        assert!(delta_t_2000 > 60.0 && delta_t_2000 < 70.0);
530        assert!(delta_t_2020 > 65.0 && delta_t_2020 < 75.0);
531        assert!(delta_t_2020 > delta_t_2000); // ΔT is generally increasing
532    }
533
534    #[test]
535    fn test_delta_t_historical_estimates() {
536        let delta_t_1900 = DeltaT::estimate(1900.0).unwrap();
537        let delta_t_1950 = DeltaT::estimate(1950.0).unwrap();
538
539        assert!(delta_t_1900 < 0.0); // Negative in early 20th century
540        assert!(delta_t_1950 > 25.0 && delta_t_1950 < 35.0);
541    }
542
543    #[test]
544    fn test_delta_t_boundary_conditions() {
545        // Test edge cases
546        assert!(DeltaT::estimate(-500.0).is_ok());
547        assert!(DeltaT::estimate(3000.0).is_ok());
548        assert!(DeltaT::estimate(-501.0).is_ok()); // Should work for ancient dates
549        assert!(DeltaT::estimate(3001.0).is_err()); // Should fail beyond 3000
550    }
551
552    #[test]
553    fn test_delta_t_from_date() {
554        let delta_t = DeltaT::estimate_from_date(2024, 6).unwrap();
555        let delta_t_decimal = DeltaT::estimate(2024.5 - 1.0 / 24.0).unwrap(); // June = month 6, so (6-0.5)/12 ≈ 0.458
556
557        // Should be very close
558        assert!((delta_t - delta_t_decimal).abs() < 0.01);
559
560        // Test invalid month
561        assert!(DeltaT::estimate_from_date(2024, 13).is_err());
562        assert!(DeltaT::estimate_from_date(2024, 0).is_err());
563    }
564
565    #[test]
566    fn test_delta_t_from_date_like() {
567        use chrono::{DateTime, FixedOffset, NaiveDate, Utc};
568
569        // Test with DateTime<FixedOffset>
570        let datetime_fixed = "2024-06-15T12:00:00-07:00"
571            .parse::<DateTime<FixedOffset>>()
572            .unwrap();
573        let delta_t_fixed = DeltaT::estimate_from_date_like(&datetime_fixed).unwrap();
574
575        // Test with DateTime<Utc>
576        let datetime_utc = "2024-06-15T19:00:00Z".parse::<DateTime<Utc>>().unwrap();
577        let delta_t_utc = DeltaT::estimate_from_date_like(&datetime_utc).unwrap();
578
579        // Test with NaiveDate
580        let naive_date = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
581        let delta_t_naive_date = DeltaT::estimate_from_date_like(&naive_date).unwrap();
582
583        // Test with NaiveDateTime
584        let naive_datetime = naive_date.and_hms_opt(12, 0, 0).unwrap();
585        let delta_t_naive_datetime = DeltaT::estimate_from_date_like(&naive_datetime).unwrap();
586
587        // Should all be identical since we only use year/month
588        assert_eq!(delta_t_fixed, delta_t_utc);
589        assert_eq!(delta_t_fixed, delta_t_naive_date);
590        assert_eq!(delta_t_fixed, delta_t_naive_datetime);
591
592        // Should match estimate_from_date
593        let delta_t_date = DeltaT::estimate_from_date(2024, 6).unwrap();
594        assert_eq!(delta_t_fixed, delta_t_date);
595
596        // Verify reasonable range for 2024
597        assert!(delta_t_fixed > 60.0 && delta_t_fixed < 80.0);
598    }
599
600    #[test]
601    fn test_specific_julian_dates() {
602        // Test some well-known dates
603
604        // Unix epoch: 1970-01-01 00:00:00 UTC
605        let unix_epoch = JulianDate::from_utc(1970, 1, 1, 0, 0, 0.0, 0.0).unwrap();
606        assert!((unix_epoch.julian_date() - 2_440_587.5).abs() < 1e-6);
607
608        // Y2K: 2000-01-01 00:00:00 UTC
609        let y2k = JulianDate::from_utc(2000, 1, 1, 0, 0, 0.0, 0.0).unwrap();
610        assert!((y2k.julian_date() - 2_451_544.5).abs() < 1e-6);
611    }
612}