sunrise_sunset_calculator/
lib.rs

1//! Rust port of [Sunrise Sunset Calculator](https://github.com/jacob-pro/sunrise-sunset-calculator/tree/master/c)
2//!
3//! A library to calculate times of sunrise and sunset on Earth based on latitude/longitude.
4#![deny(unsafe_code)]
5pub mod spa;
6
7use crate::spa::{SpaData, SpaError};
8
9pub const SSC_DEFAULT_ATMOSPHERIC_REFRACTION: f64 = 0.5667;
10pub const SSC_DEFAULT_TEMPERATURE: f64 = 16.0;
11pub const SSC_DEFAULT_PRESSURE: f64 = 1013.25;
12pub const SSC_DEFAULT_ELEVATION: f64 = 0.0;
13
14/// Input parameters to the calculator
15#[derive(Debug, Copy, Clone)]
16pub struct SunriseSunsetParameters {
17    /// Unix timestamp to calculate sunrise and sunset times around
18    pub time: i64,
19    /// The latitude (N) of the location to calculate for
20    pub latitude: f64,
21    /// The longitude (E) of the location to calculate for
22    pub longitude: f64,
23    /// Difference between earth rotation time and terrestrial time
24    pub delta_t: f64,
25    /// Observer elevation \[meters\]
26    pub elevation: f64,
27    /// Annual average local pressure \[millibars\]
28    pub pressure: f64,
29    /// Annual average local temperature \[degrees Celsius\]
30    pub temperature: f64,
31    /// Atmospheric refraction at sunrise and sunset
32    pub atmos_refract: f64,
33    /// Step size in seconds to use in the search.
34    /// It should be less than the length of the shortest day or night or otherwise it is
35    /// possible that a sunrise/sunset may be skipped.
36    /// It should not be too small or otherwise or the search will take an unreasonable
37    /// amount of time.
38    pub step_size: u32,
39}
40
41impl SunriseSunsetParameters {
42    /// Creates a [SunriseSunsetParameters] struct with required and default values.
43    ///
44    /// Uses [default_step_size()](Self::default_step_size) to generate step size.
45    ///
46    /// # Arguments
47    ///
48    /// * `time`: Unix timestamp to calculate sunrise and sunset times around
49    /// * `latitude`: The latitude (N) of the location to calculate for
50    /// * `longitude`: The longitude (E) of the location to calculate for
51    pub fn new(time: i64, latitude: f64, longitude: f64) -> Self {
52        SunriseSunsetParameters {
53            time,
54            latitude,
55            longitude,
56            delta_t: 0.0,
57            elevation: SSC_DEFAULT_ELEVATION,
58            pressure: SSC_DEFAULT_PRESSURE,
59            temperature: SSC_DEFAULT_TEMPERATURE,
60            atmos_refract: SSC_DEFAULT_ATMOSPHERIC_REFRACTION,
61            step_size: Self::default_step_size(latitude),
62        }
63    }
64
65    /// Calculate a sensible default step size for a given latitude
66    ///
67    ///  - Absolute latitude less than 60 = 4 hour step
68    ///  - Absolute latitude less than 64 = 1 hour step
69    ///  - Absolute latitude greater than 64 = 10 minute step
70    ///
71    /// Note that at extreme latitudes it is possible that very short days/nights may be skipped.
72    /// See: [Shortest Day of the Year](http://time.unitarium.com/events/shortest-day.html)
73    ///
74    /// # Arguments
75    ///
76    /// * `latitude`: The latitude of the location to calculate sunrise and sunset times
77    pub fn default_step_size(latitude: f64) -> u32 {
78        let latitude_abs = latitude.abs();
79        if latitude_abs < 60.0 {
80            14400
81        } else if latitude_abs < 64.0 {
82            3600
83        } else {
84            600
85        }
86    }
87
88    /// Calculate sunrise and sunset times using these parameters
89    pub fn calculate(self) -> Result<SunriseSunsetResult, SpaError> {
90        let mut data = SpaData {
91            jd: jd_from_unix(self.time),
92            delta_t: self.delta_t,
93            longitude: self.longitude,
94            latitude: self.latitude,
95            elevation: self.elevation,
96            pressure: self.pressure,
97            temperature: self.temperature,
98            atmos_refract: self.atmos_refract,
99            ..Default::default()
100        };
101        data.calculate()?;
102
103        let visible = sun_is_up(&data);
104        let step_signed = self.step_size as i64;
105
106        let backward_result =
107            search_for_change_in_visibility(&mut data, self.time, -step_signed, visible)?;
108
109        let forward_result =
110            search_for_change_in_visibility(&mut data, self.time, step_signed, visible)?;
111
112        Ok(SunriseSunsetResult {
113            set: if visible {
114                forward_result
115            } else {
116                backward_result
117            },
118            rise: if visible {
119                backward_result
120            } else {
121                forward_result
122            },
123            visible,
124        })
125    }
126}
127
128/// Output values from the calculator
129#[derive(Debug, Copy, Clone, Eq, PartialEq)]
130pub struct SunriseSunsetResult {
131    /// Unix timestamp of the closest sunset
132    pub set: i64,
133    /// Unix timestamp of the closest sunrise
134    pub rise: i64,
135    /// If the sun is currently visible
136    pub visible: bool,
137}
138
139#[inline]
140fn jd_from_unix(t: i64) -> f64 {
141    t as f64 / 86400.0f64 + 2440587.5f64
142}
143
144#[inline]
145fn sun_is_up(result: &SpaData) -> bool {
146    result.e >= -0.8333f64
147}
148
149#[inline]
150fn search_for_change_in_visibility(
151    mut data: &mut SpaData,
152    mut start: i64,
153    mut step_size: i64,
154    mut currently_visible: bool,
155) -> Result<i64, SpaError> {
156    while step_size != 0 {
157        data.jd = jd_from_unix(start);
158        data.calculate()?;
159        if sun_is_up(data) != currently_visible {
160            step_size = -(step_size / 2i64);
161            currently_visible = !currently_visible;
162        } else {
163            start += step_size;
164        }
165    }
166    Ok(start)
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172    use approx::assert_abs_diff_eq;
173    use chrono::{FixedOffset, TimeZone};
174
175    const ACCURACY_SECONDS: i64 = 60;
176
177    const BRISTOL_LAT: f64 = 51.4545;
178    const BRISTOL_LON: f64 = -2.5879;
179
180    const STLOUIS_LAT: f64 = 38.6272;
181    const STLOUIS_LON: f64 = -90.1978;
182
183    const SVALBARD_LAT: f64 = 79.0;
184    const SVALBARD_LON: f64 = 17.0;
185
186    const ADELAIDE_LAT: f64 = -34.92;
187    const ADELAIDE_LON: f64 = 138.59;
188
189    fn timestamp(year: i32, month: u32, day: u32, hour: u32, min: u32, tz_seconds: i32) -> i64 {
190        let tz = FixedOffset::east_opt(tz_seconds).unwrap();
191        tz.with_ymd_and_hms(year, month, day, hour, min, 0)
192            .unwrap()
193            .timestamp()
194    }
195
196    fn validate_result(
197        res: &SunriseSunsetResult,
198        expected_rise: i64,
199        expected_set: i64,
200        currently_visible: bool,
201    ) {
202        assert_abs_diff_eq!(res.rise, expected_rise, epsilon = ACCURACY_SECONDS);
203        assert_abs_diff_eq!(res.set, expected_set, epsilon = ACCURACY_SECONDS);
204        assert_eq!(
205            res.visible, currently_visible,
206            "Solar visibility did not match"
207        );
208    }
209
210    fn calculate(timestamp: i64, latitude: f64, longitude: f64) -> SunriseSunsetResult {
211        SunriseSunsetParameters::new(timestamp, latitude, longitude)
212            .calculate()
213            .unwrap()
214    }
215
216    #[test]
217    fn test_bristol() {
218        let early19 = timestamp(2018, 11, 19, 5, 30, 0);
219        let res = calculate(early19, BRISTOL_LAT, BRISTOL_LON);
220        let set18 = timestamp(2018, 11, 18, 16, 17, 0);
221        let rise19 = timestamp(2018, 11, 19, 7, 35, 0);
222        validate_result(&res, rise19, set18, false);
223
224        let midday19 = timestamp(2018, 11, 19, 12, 0, 0);
225        let res = calculate(midday19, BRISTOL_LAT, BRISTOL_LON);
226        let set19 = timestamp(2018, 11, 19, 16, 16, 0);
227        validate_result(&res, rise19, set19, true);
228
229        let late19 = timestamp(2018, 11, 19, 20, 25, 0);
230        let rise20 = timestamp(2018, 11, 20, 7, 36, 0);
231        let res = calculate(late19, BRISTOL_LAT, BRISTOL_LON);
232        validate_result(&res, rise20, set19, false);
233    }
234
235    fn test_outer_bounds_impl(
236        mut start: i64,
237        latitude: f64,
238        longitude: f64,
239        days: u32,
240        increment: i64,
241    ) {
242        let end = start + (days as i64 * 24 * 60 * 60);
243        let mut params = SunriseSunsetParameters::new(start, latitude, longitude);
244        while start <= end {
245            params.time = start;
246            let res = params.calculate().unwrap();
247            assert!(
248                (res.rise <= start && start <= res.set) || (res.set <= start && start <= res.rise),
249                "Sunrise and Sunset are not on either side of the input time"
250            );
251            assert_eq!((start > res.rise), res.visible, "Visibility incorrect");
252            start += increment;
253        }
254    }
255
256    #[test]
257    fn test_outer_bounds() {
258        let start = timestamp(2021, 07, 28, 10, 0, 0);
259        test_outer_bounds_impl(start, STLOUIS_LAT, STLOUIS_LON, 10, 3600);
260        test_outer_bounds_impl(start, BRISTOL_LAT, BRISTOL_LON, 10, 3600);
261
262        let svalbard_summer = timestamp(2021, 07, 1, 0, 0, 0);
263        test_outer_bounds_impl(svalbard_summer, SVALBARD_LAT, SVALBARD_LON, 5, 3600);
264
265        let svalbard_winter = timestamp(2021, 01, 1, 0, 0, 0);
266        test_outer_bounds_impl(svalbard_winter, SVALBARD_LAT, SVALBARD_LON, 5, 3600);
267
268        let svalbard_spring = timestamp(2021, 02, 16, 0, 0, 0);
269        test_outer_bounds_impl(svalbard_spring, SVALBARD_LAT, SVALBARD_LON, 10, 3600);
270    }
271
272    #[test]
273    fn test_adelaide() {
274        let tz = (10.5 * 60.0 * 60.0) as i32; // UTC+10:30
275        let mid = timestamp(2021, 11, 13, 19, 00, tz);
276        let res = calculate(mid, ADELAIDE_LAT, ADELAIDE_LON);
277        let set = timestamp(2021, 11, 13, 19, 57, tz);
278        let rise = timestamp(2021, 11, 13, 6, 03, tz);
279        validate_result(&res, rise, set, true);
280    }
281}