1use chrono::{DateTime, NaiveDate, TimeZone, Timelike, Utc};
2
3const UNIX_EPOCH: JulianDate = JulianDate(2440587.5);
4const SECONDS_PER_DAY: u64 = 24 * 60 * 60;
5const JAN_2000: JulianDate = JulianDate(2451545.0);
6const LEAP_SECONDS: JulianDate = JulianDate(0.0008);
7const OBLIQUITY_OF_THE_ECLIPTIC: f64 = 23.44;
8
9#[derive(Debug, Clone, Copy)]
10struct JulianDate(f64);
11
12impl JulianDate {
13 fn ceil_days(&self) -> f64 {
14 self.0.ceil()
15 }
16
17 fn to_datetime(self) -> Option<DateTime<Utc>> {
18 Utc.timestamp_opt(
19 ((self - UNIX_EPOCH).0 * SECONDS_PER_DAY as f64).round() as i64,
20 0,
21 )
22 .single()
23 }
24}
25
26impl From<DateTime<Utc>> for JulianDate {
27 fn from(date: DateTime<Utc>) -> Self {
28 Self((date.timestamp() as f64 / SECONDS_PER_DAY as f64) + UNIX_EPOCH.0)
29 }
30}
31
32impl std::ops::Sub<JulianDate> for JulianDate {
33 type Output = Self;
34
35 fn sub(self, rhs: JulianDate) -> Self::Output {
36 Self(self.0 - rhs.0)
37 }
38}
39
40impl std::ops::Add<JulianDate> for JulianDate {
41 type Output = Self;
42
43 fn add(self, rhs: JulianDate) -> Self::Output {
44 Self(self.0 + rhs.0)
45 }
46}
47
48pub fn sun_times(
74 date: NaiveDate,
75 latitude: f64,
76 longitude: f64,
77 elevation: f64,
78) -> Option<(DateTime<Utc>, DateTime<Utc>)> {
79 const ARGUMENT_OF_PERIHELION: f64 = 102.9372;
82
83 let julian_date = JulianDate::from(
84 date.and_hms_opt(0, 0, 0)?
85 .and_local_timezone(Utc)
86 .single()?,
87 );
88
89 let elevation = elevation.max(0.0);
92 let elevation_correction = -2.076 * (elevation.sqrt()) / 60.0;
93
94 let days_since_2000 = (julian_date - JAN_2000 + LEAP_SECONDS).ceil_days();
95
96 let mean_solar_time = days_since_2000 - (longitude / 360.0);
97 let solar_mean_anomaly = (357.5291 + 0.98560028 * mean_solar_time).rem_euclid(360.0);
98 let center = 1.9148 * solar_mean_anomaly.to_radians().sin()
99 + 0.0200 * (2.0 * solar_mean_anomaly).to_radians().sin()
100 + 0.0003 * (3.0 * solar_mean_anomaly).to_radians().sin();
101 let ecliptic_longitude =
102 (solar_mean_anomaly + center + 180.0 + ARGUMENT_OF_PERIHELION).rem_euclid(360.0);
103
104 let declination = (ecliptic_longitude.to_radians().sin()
105 * OBLIQUITY_OF_THE_ECLIPTIC.to_radians().sin())
106 .asin();
107 let event_hour_angle = (((-0.83 + elevation_correction).to_radians().sin()
108 - (latitude.to_radians().sin() * declination.sin()))
109 / (latitude.to_radians().cos() * declination.cos()))
110 .acos()
111 .to_degrees();
112
113 if event_hour_angle.is_nan() {
114 return None;
115 }
116
117 let solar_transit =
118 JAN_2000.0 + mean_solar_time + 0.0053 * solar_mean_anomaly.to_radians().sin()
119 - 0.0069 * (2.0 * ecliptic_longitude).to_radians().sin();
120 let solar_transit_julian = JulianDate(solar_transit);
121
122 let julian_rise = JulianDate(solar_transit_julian.0 - event_hour_angle / 360.0);
123 let julian_set = JulianDate(solar_transit_julian.0 + event_hour_angle / 360.0);
124 let rise = julian_rise.to_datetime();
125 let set = julian_set.to_datetime();
126 if let (Some(rise), Some(set)) = (rise, set) {
127 Some((rise, set))
128 } else {
129 None
130 }
131}
132
133pub fn altitude(date_time: DateTime<Utc>, latitude: f64, longitude: f64) -> f64 {
156 const ARGUMENT_OF_PERIHELION: f64 = 102.9372;
161
162 let julian_date = JulianDate::from(date_time);
163
164 let days_since_2000 = (julian_date - JAN_2000 + LEAP_SECONDS).ceil_days();
165
166 let mean_solar_time = days_since_2000 - (longitude / 360.0);
167 let solar_mean_anomaly = (357.5291 + 0.98560028 * mean_solar_time).rem_euclid(360.0);
168 let center = 1.9148 * solar_mean_anomaly.to_radians().sin()
169 + 0.0200 * (2.0 * solar_mean_anomaly).to_radians().sin()
170 + 0.0003 * (3.0 * solar_mean_anomaly).to_radians().sin();
171 let ecliptic_longitude =
172 (solar_mean_anomaly + center + 180.0 + ARGUMENT_OF_PERIHELION).rem_euclid(360.0);
173
174 let sin_declination =
175 ecliptic_longitude.to_radians().sin() * OBLIQUITY_OF_THE_ECLIPTIC.to_radians().sin();
176 let declination = sin_declination.asin();
177
178 let right_ascension = (ecliptic_longitude.to_radians().sin()
179 * OBLIQUITY_OF_THE_ECLIPTIC.to_radians().cos())
180 .atan2(ecliptic_longitude.to_radians().cos())
181 .to_degrees();
182
183 let greenwich_sidereal_time = mean_solar_time + 0.0;
184 let local_sideral_time = greenwich_sidereal_time
185 + (date_time.time().hour() as f64
186 + (date_time.time().minute() as f64 / 60.0)
187 + (date_time.time().second() as f64 / 60.0 * 60.0))
188 * 15.0
189 + longitude.to_degrees();
190 let local_hour_angle = local_sideral_time - right_ascension;
191
192 let sin_altitude = (latitude.to_radians().sin() * declination.sin())
193 + (latitude.to_radians().cos() * declination.cos() * local_hour_angle.to_radians().cos());
194 sin_altitude.asin().to_degrees()
195}
196
197#[cfg(test)]
198mod tests {
199 use chrono::{Duration, NaiveDate};
200
201 #[test]
202 fn sunrise_and_sunset_land_on_requested_day() {
204 let date_range =
205 std::iter::successors(Some(NaiveDate::from_ymd_opt(2022, 1, 1).unwrap()), |date| {
206 let next = *date + Duration::days(1);
207 if next > NaiveDate::from_ymd_opt(2022, 12, 31).unwrap() {
208 None
209 } else {
210 Some(next)
211 }
212 });
213 for date in date_range {
214 let times = super::sun_times(date, 53.38, -1.48, 0.0);
215 assert!(times.is_some());
216 let times = times.unwrap();
217 assert_eq!(date, times.0.naive_utc().date());
218 assert_eq!(date, times.1.naive_utc().date());
219 }
220 }
221}