1use std::f64::consts::PI;
23
24const 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#[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
56fn 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
87fn 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#[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
155fn 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#[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#[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 #[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 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 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 assert_eq!(
324 time_at_phase(date, SunPhase::custom(-6.0, false), 50.5, 30.5, 0.0),
325 1362500376781
326 );
327 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 assert_eq!(2457054.5, to_julian(1422748800000.0));
338 }
339
340 #[test]
341 fn test_from_julian() {
342 assert_eq!(from_julian(2457054.5), 1422748800000);
344 }
345
346 #[test]
347 fn test_to_days() {
348 assert_eq!(5509.5, to_days(1422748800000.0));
350 }
351}