sun/
lib.rs

1//! The `sun` crate is a library for calculating the position of the sun and sun phases
2//! (like sunrise, sunset).
3//! It is a port of the `JavaScript` library
4//! [suncalc](https://github.com/mourner/suncalc).
5//!
6//! # Example
7//!
8//! ```rust
9//! let unixtime = 1_362_441_600_000;
10//! let lat = 48.0;
11//! let lon = 9.0;
12//! let pos = sun::pos(unixtime,lat,lon);
13//! let az  = pos.azimuth.to_degrees();
14//! let alt = pos.altitude.to_degrees();
15//! println!("The position of the sun is {az}/{alt}");
16//!
17//! // calculate time of sunrise
18//! let time_ms = sun::time_at_phase(unixtime, sun::SunPhase::Sunrise, lat, lon, 0.0);
19//! assert_eq!(time_ms, 1_362_463_116_241);
20//! ```
21
22use std::f64::consts::PI;
23
24// date/time constants and conversions
25
26const MILLISECONDS_PER_DAY: f64 = 1_000.0 * 60.0 * 60.0 * 24.0;
27const JULIAN_0: f64 = 0.000_9;
28const JULIAN_1970: f64 = 2_440_588.0;
29const JULIAN_2000: f64 = 2_451_545.0;
30const TO_RAD: f64 = PI / 180.0;
31const OBLIQUITY_OF_EARTH: f64 = 23.439_7 * TO_RAD;
32const PERIHELION_OF_EARTH: f64 = 102.937_2 * TO_RAD;
33
34/// Holds the [azimuth](https://en.wikipedia.org/wiki/Azimuth)
35/// and [altitude](https://en.wikipedia.org/wiki/Horizontal_coordinate_system)
36/// angles of the sun position.
37#[derive(Debug, Clone, Copy)]
38pub struct Position {
39    pub azimuth: f64,
40    pub altitude: f64,
41}
42
43const fn to_julian(unixtime_in_ms: f64) -> f64 {
44    unixtime_in_ms / MILLISECONDS_PER_DAY - 0.5 + JULIAN_1970
45}
46
47#[allow(clippy::cast_possible_truncation)]
48fn from_julian(julian_date: f64) -> i64 {
49    ((julian_date + 0.5 - JULIAN_1970) * MILLISECONDS_PER_DAY).round() as i64
50}
51
52const fn to_days(unixtime_in_ms: f64) -> f64 {
53    to_julian(unixtime_in_ms) - JULIAN_2000
54}
55
56// general calculations for position
57
58fn right_ascension(ecliptic_longitude: f64, ecliptic_latitude: f64) -> f64 {
59    (ecliptic_longitude.sin() * OBLIQUITY_OF_EARTH.cos()
60        - ecliptic_latitude.tan() * OBLIQUITY_OF_EARTH.sin())
61    .atan2(ecliptic_longitude.cos())
62}
63
64fn declination(ecliptic_longitude: f64, ecliptic_latitude: f64) -> f64 {
65    (ecliptic_latitude.sin() * OBLIQUITY_OF_EARTH.cos()
66        + ecliptic_latitude.cos() * OBLIQUITY_OF_EARTH.sin() * ecliptic_longitude.sin())
67    .asin()
68}
69
70fn azimuth(sidereal_time: f64, latitude_rad: f64, declination: f64) -> f64 {
71    sidereal_time
72        .sin()
73        .atan2(sidereal_time.cos() * latitude_rad.sin() - declination.tan() * latitude_rad.cos())
74        + PI
75}
76
77fn altitude(sidereal_time: f64, latitude_rad: f64, declination: f64) -> f64 {
78    (latitude_rad.sin() * declination.sin()
79        + latitude_rad.cos() * declination.cos() * sidereal_time.cos())
80    .asin()
81}
82
83fn sidereal_time(days: f64, longitude_rad: f64) -> f64 {
84    (280.16 + 360.985_623_5 * days).to_radians() - longitude_rad
85}
86
87// general sun calculations
88
89fn solar_mean_anomaly(days: f64) -> f64 {
90    (357.529_1 + 0.985_600_28 * days).to_radians()
91}
92
93fn equation_of_center(solar_mean_anomaly: f64) -> f64 {
94    (1.914_8 * solar_mean_anomaly.sin()
95        + 0.02 * (2.0 * solar_mean_anomaly).sin()
96        + 0.000_3 * (3.0 * solar_mean_anomaly).sin())
97    .to_radians()
98}
99
100fn ecliptic_longitude(solar_mean_anomaly: f64) -> f64 {
101    solar_mean_anomaly + equation_of_center(solar_mean_anomaly) + PERIHELION_OF_EARTH + PI
102}
103
104/// Calculates the sun position for a given date and latitude/longitude.
105/// The angles are calculated as [radians](https://en.wikipedia.org/wiki/Radian).
106///
107/// * `unixtime`  - [unix time](https://en.wikipedia.org/wiki/Unix_time) in milliseconds.
108/// * `lat`       - [latitude](https://en.wikipedia.org/wiki/Latitude) in degrees.
109/// * `lon`       - [longitude](https://en.wikipedia.org/wiki/Longitude) in degrees.
110///
111/// calculates the sun position for a given date and latitude/longitude
112#[must_use]
113pub fn pos(unixtime_in_ms: i64, lat: f64, lon: f64) -> Position {
114    let longitude_rad = -lon.to_radians();
115    let latitude_rad = lat.to_radians();
116    #[allow(clippy::cast_precision_loss)]
117    let days = to_days(unixtime_in_ms as f64);
118    let mean = solar_mean_anomaly(days);
119    let ecliptic_longitude = ecliptic_longitude(mean);
120    let declination = declination(ecliptic_longitude, 0.0);
121    let right_ascension = right_ascension(ecliptic_longitude, 0.0);
122    let sidereal_time = sidereal_time(days, longitude_rad) - right_ascension;
123    let azimuth = azimuth(sidereal_time, latitude_rad, declination);
124    let altitude = altitude(sidereal_time, latitude_rad, declination);
125    Position { azimuth, altitude }
126}
127
128fn julian_cycle(days: f64, longitude_rad: f64) -> f64 {
129    (days - JULIAN_0 - longitude_rad / (2.0 * PI)).round()
130}
131
132const fn approx_transit(hour_angle: f64, longitude_rad: f64, julian_cycle: f64) -> f64 {
133    JULIAN_0 + (hour_angle + longitude_rad) / (2.0 * PI) + julian_cycle
134}
135
136fn solar_transit_julian(
137    approx_transit: f64,
138    solar_mean_anomaly: f64,
139    ecliptic_longitude: f64,
140) -> f64 {
141    JULIAN_2000 + approx_transit + 0.005_3 * solar_mean_anomaly.sin()
142        - 0.006_9 * (2.0 * ecliptic_longitude).sin()
143}
144
145fn solar_hour_angle(altitude_angle: f64, latitude_rad: f64, declination: f64) -> f64 {
146    ((altitude_angle.sin() - latitude_rad.sin() * declination.sin())
147        / (latitude_rad.cos() * declination.cos()))
148    .acos()
149}
150
151fn observer_angle(height: f64) -> f64 {
152    -2.076 * height.sqrt() / 60.0
153}
154
155/// Returns set time for the given sun altitude.
156fn sunset_julian(
157    altitude_angle: f64,
158    longitude_rad: f64,
159    latitude_rad: f64,
160    declination: f64,
161    julian_cycle: f64,
162    mean: f64,
163    ecliptic_longitude: f64,
164) -> f64 {
165    let hour_angle = solar_hour_angle(altitude_angle, latitude_rad, declination);
166    let approx_transit = approx_transit(hour_angle, longitude_rad, julian_cycle);
167    solar_transit_julian(approx_transit, mean, ecliptic_longitude)
168}
169
170/// Calculates the time for the given [`SunPhase`] at a given date, height and Latitude/Longitude.
171/// The returned time is the [unix time](https://en.wikipedia.org/wiki/Unix_time) in milliseconds.
172///
173/// # Arguments
174///
175/// * `unixtime`  - [unix time](https://en.wikipedia.org/wiki/Unix_time) in milliseconds.
176/// * `sun_phase` - [`SunPhase`] to calcuate time for
177/// * `lat`       - [latitude](https://en.wikipedia.org/wiki/Latitude) in degrees.
178/// * `lon`       - [longitude](https://en.wikipedia.org/wiki/Longitude) in degrees.
179/// * `height`    - Observer height in meters above the horizon
180///
181/// # Examples
182///
183/// ```rust
184/// // calculate time of sunrise
185/// let unixtime = 1_362_441_600_000;
186/// let lat = 48.0;
187/// let lon = 9.0;
188/// let height = 0.0;
189/// let time_ms = sun::time_at_phase(unixtime, sun::SunPhase::Sunrise, lat, lon, height);
190/// assert_eq!(time_ms, 1_362_463_116_241);
191/// ```
192
193#[must_use]
194pub fn time_at_phase(
195    unixtime_in_ms: i64,
196    sun_phase: SunPhase,
197    lat: f64,
198    lon: f64,
199    height: f64,
200) -> i64 {
201    let longitude_rad = -lon.to_radians();
202    let latitude_rad = lat.to_radians();
203    let observer_angle = observer_angle(height);
204    #[allow(clippy::cast_precision_loss)]
205    let days = to_days(unixtime_in_ms as f64);
206    let julian_cycle = julian_cycle(days, longitude_rad);
207    let approx_transit = approx_transit(0.0, longitude_rad, julian_cycle);
208    let solar_mean_anomaly = solar_mean_anomaly(approx_transit);
209    let ecliptic_longitude = ecliptic_longitude(solar_mean_anomaly);
210    let declination = declination(ecliptic_longitude, 0.0);
211    let julian_noon = solar_transit_julian(approx_transit, solar_mean_anomaly, ecliptic_longitude);
212
213    let altitude_angle = (sun_phase.angle_deg() + observer_angle).to_radians();
214    let julian_set = sunset_julian(
215        altitude_angle,
216        longitude_rad,
217        latitude_rad,
218        declination,
219        julian_cycle,
220        solar_mean_anomaly,
221        ecliptic_longitude,
222    );
223
224    if sun_phase.is_rise() {
225        let julian_rise = julian_noon - (julian_set - julian_noon);
226        from_julian(julian_rise)
227    } else {
228        from_julian(julian_set)
229    }
230}
231
232/// Sun phases for use with [`time_at_phase`].
233#[derive(Clone, Copy, Debug)]
234pub enum SunPhase {
235    Sunrise,
236    Sunset,
237    SunriseEnd,
238    SunsetStart,
239    Dawn,
240    Dusk,
241    NauticalDawn,
242    NauticalDusk,
243    NightEnd,
244    Night,
245    GoldenHourEnd,
246    GoldenHour,
247    Custom(f64, bool),
248}
249
250impl SunPhase {
251    /// Create a custom sun phase
252    ///
253    /// # Arguments
254    /// * `angle_deg` - Angle in degrees of the sun above the horizon. Use negative
255    ///                 numbers for angles below the horizon.
256    /// * `rise`      - `true` when this sun phase applies to the sun rising, `false`
257    ///                 if it's setting.
258    #[must_use]
259    pub const fn custom(angle_deg: f64, rise: bool) -> Self {
260        SunPhase::Custom(angle_deg, rise)
261    }
262
263    const fn angle_deg(&self) -> f64 {
264        match self {
265            SunPhase::Sunrise | SunPhase::Sunset => -0.833,
266            SunPhase::SunriseEnd | SunPhase::SunsetStart => -0.5,
267            SunPhase::Dawn | SunPhase::Dusk => -6.0,
268            SunPhase::NauticalDawn | SunPhase::NauticalDusk => -12.0,
269            SunPhase::NightEnd | SunPhase::Night => -18.0,
270            SunPhase::GoldenHourEnd | SunPhase::GoldenHour => 6.0,
271            SunPhase::Custom(angle, _) => *angle,
272        }
273    }
274
275    const fn is_rise(&self) -> bool {
276        match self {
277            SunPhase::Sunrise
278            | SunPhase::SunriseEnd
279            | SunPhase::Dawn
280            | SunPhase::NauticalDawn
281            | SunPhase::NightEnd
282            | SunPhase::GoldenHourEnd => true,
283            SunPhase::Sunset
284            | SunPhase::SunsetStart
285            | SunPhase::Dusk
286            | SunPhase::NauticalDusk
287            | SunPhase::Night
288            | SunPhase::GoldenHour => false,
289            SunPhase::Custom(_, rise) => *rise,
290        }
291    }
292}
293
294#[cfg(test)]
295mod tests {
296
297    use super::*;
298
299    #[test]
300    fn test_pos() {
301        // 2013-03-05 UTC
302        let date = 1362441600000;
303        let pos = pos(date, 50.5, 30.5);
304        assert_eq!(0.6412750628729547, pos.azimuth);
305        assert_eq!(-0.7000406838781611, pos.altitude);
306    }
307
308    #[test]
309    fn test_time_at_angle() {
310        // 2013-03-05 UTC
311        let date = 1362441600000;
312
313        assert_eq!(
314            time_at_phase(date, SunPhase::Sunrise, 50.5, 30.5, 0.0),
315            1362458096440
316        );
317        assert_eq!(
318            time_at_phase(date, SunPhase::Sunset, 50.5, 30.5, 0.0),
319            1362498417875
320        );
321
322        // equal to Dusk
323        assert_eq!(
324            time_at_phase(date, SunPhase::custom(-6.0, false), 50.5, 30.5, 0.0),
325            1362500376781
326        );
327        // equal to Dawn
328        assert_eq!(
329            time_at_phase(date, SunPhase::custom(-6.0, true), 50.5, 30.5, 0.0),
330            1362456137534
331        );
332    }
333
334    #[test]
335    fn test_to_julian() {
336        // 1. Jan. 2015
337        assert_eq!(2457054.5, to_julian(1422748800000.0));
338    }
339
340    #[test]
341    fn test_from_julian() {
342        // 1. Jan. 2015
343        assert_eq!(from_julian(2457054.5), 1422748800000);
344    }
345
346    #[test]
347    fn test_to_days() {
348        // 1. Jan. 2015
349        assert_eq!(5509.5, to_days(1422748800000.0));
350    }
351}