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