rust_zmanim/astronomical_calculator.rs
1//! Astronomical calculations for sunrise, sunset, twilight, and solar transit
2//! times.
3//!
4//! This module exposes low-level solar event calculations that power higher
5//! level *zmanim* APIs.
6//!
7//! Most functions accept a [`Date`] and [`GeoLocation`] and return either:
8//! - UTC fractional hours (`f64`) for raw event times, or
9//! - localized [`Zoned`] datetimes for ergonomic use.
10//!
11//! Functions that calculate sunrise/sunset generally distinguish between:
12//! - elevation-adjusted calculations, and
13//! - sea-level calculations.
14//!
15//! When an event cannot be computed for the requested date/location, functions
16//! return `None`.
17
18use std::{
19 cmp::Ordering,
20 ops::{Add, Sub},
21};
22
23use jiff::{SignedDuration, Zoned, civil::Date, tz::TimeZone};
24
25use crate::util::{geolocation::GeoLocation, math_helper::HOUR_NANOS, noaa_calculator};
26
27/// 90° below the vertical. Used as a basis for most calculations since the
28/// location of the sun is 90° below the vertical at sunrise and sunset.
29///
30/// **Note**: for sunrise and sunset the [adjusted
31/// zenith](crate::util::zenith_adjustments::adjusted_zenith) is required to
32/// account for the radius of the sun and refraction. See the documentation
33/// there for more details
34pub const GEOMETRIC_ZENITH: f64 = 90.0;
35
36/// Sun's zenith at civil twilight (96°)
37pub const CIVIL_ZENITH: f64 = 96.0;
38
39/// Sun's zenith at nautical twilight (102°)
40pub const NAUTICAL_ZENITH: f64 = 102.0;
41
42/// Sun's zenith at astronomical twilight (108°)
43pub const ASTRONOMICAL_ZENITH: f64 = 108.0;
44
45/// Returns the elevation-adjusted sunrise as a UTC fractional hour.
46///
47/// No local timezone or daylight saving adjustment is applied.
48#[must_use]
49pub fn utc_sunrise(date: Date, zenith: f64, geo_location: &GeoLocation) -> Option<f64> {
50 noaa_calculator::utc_sunrise(date, geo_location, zenith, true)
51}
52
53/// Returns the elevation-adjusted sunset as a UTC fractional hour.
54///
55/// No local timezone or daylight saving adjustment is applied.
56#[must_use]
57pub fn utc_sunset(date: Date, zenith: f64, geo_location: &GeoLocation) -> Option<f64> {
58 noaa_calculator::utc_sunset(date, geo_location, zenith, true)
59}
60
61/// Returns the sea-level sunrise as a UTC fractional hour.
62///
63/// No elevation, local timezone, or daylight saving adjustment is applied.
64#[must_use]
65pub fn utc_sea_level_sunrise(date: Date, zenith: f64, geo_location: &GeoLocation) -> Option<f64> {
66 noaa_calculator::utc_sunrise(date, geo_location, zenith, false)
67}
68
69/// Returns the sea-level sunset as a UTC fractional hour.
70///
71/// No elevation, local timezone, or daylight saving adjustment is applied.
72#[must_use]
73pub fn utc_sea_level_sunset(date: Date, zenith: f64, geo_location: &GeoLocation) -> Option<f64> {
74 noaa_calculator::utc_sunset(date, geo_location, zenith, false)
75}
76
77/// Returns the elevation-adjusted sunrise time.
78///
79/// The zenith used for the calculation uses [geometric
80/// zenith](GEOMETRIC_ZENITH) of 90°. This is
81/// [adjusted](crate::util::zenith_adjustments::adjusted_zenith) to add
82/// approximately 50/60 of a degree to account for 34 arcminutes of refraction
83/// and 16 arcminutes for the sun's radius for a total of 90.83333°
84#[must_use]
85pub fn sunrise(date: Date, geo_location: &GeoLocation) -> Option<Zoned> {
86 date_time_from_time_of_day(
87 date,
88 noaa_calculator::utc_sunrise(date, geo_location, GEOMETRIC_ZENITH, true)?,
89 geo_location,
90 &SolarEvent::Sunrise,
91 )
92}
93
94/// Returns the elevation-adjusted sunset time.
95///
96/// The zenith used for the calculation uses
97/// [geometric zenith](GEOMETRIC_ZENITH) of 90°. This is
98/// [adjusted](crate::util::zenith_adjustments::adjusted_zenith) to add
99/// approximately 50/60 of a degree to account for 34 arcminutes of refraction
100/// and 16 arcminutes for the sun's radius for a total of 90.83333°
101#[must_use]
102pub fn sunset(date: Date, geo_location: &GeoLocation) -> Option<Zoned> {
103 date_time_from_time_of_day(
104 date,
105 noaa_calculator::utc_sunset(date, geo_location, GEOMETRIC_ZENITH, true)?,
106 geo_location,
107 &SolarEvent::Sunset,
108 )
109}
110
111/// Returns the sunrise without elevation adjustment, i.e. at sea level.
112#[must_use]
113pub fn sea_level_sunrise(date: Date, geo_location: &GeoLocation) -> Option<Zoned> {
114 sunrise_offset_by_degrees(date, geo_location, GEOMETRIC_ZENITH)
115}
116
117/// Returns the sunset without elevation adjustment, i.e. at sea level.
118#[must_use]
119pub fn sea_level_sunset(date: Date, geo_location: &GeoLocation) -> Option<Zoned> {
120 sunset_offset_by_degrees(date, geo_location, GEOMETRIC_ZENITH)
121}
122
123/// Returns time of an offset by degrees below or above the horizon of sunrise
124///
125/// A utility function that returns sunrise at an offset zenith.
126///
127/// The offset is measured from the vertical. For example, to calculate
128/// 14° before sunrise, pass `14 + GEOMETRIC_ZENITH` (`104.0`).
129#[must_use]
130pub fn sunrise_offset_by_degrees(
131 date: Date,
132 geo_location: &GeoLocation,
133 offset_zenith: f64,
134) -> Option<Zoned> {
135 date_time_from_time_of_day(
136 date,
137 utc_sea_level_sunrise(date, offset_zenith, geo_location)?,
138 geo_location,
139 &SolarEvent::Sunrise,
140 )
141}
142
143/// Returns time of an offset by degrees below or above the horizon of sunset
144///
145/// A utility function that returns sunset at an offset zenith.
146///
147/// The offset is measured from the vertical. For example, to calculate
148/// 14° after sunset, pass `14 + GEOMETRIC_ZENITH` (`104.0`).
149#[must_use]
150pub fn sunset_offset_by_degrees(
151 date: Date,
152 geo_location: &GeoLocation,
153 offset_zenith: f64,
154) -> Option<Zoned> {
155 date_time_from_time_of_day(
156 date,
157 utc_sea_level_sunset(date, offset_zenith, geo_location)?,
158 geo_location,
159 &SolarEvent::Sunset,
160 )
161}
162
163/// Returns a temporal (solar) hour based on the provided sunrise and sunset.
164#[must_use]
165pub fn temporal_hour(sunrise: &Zoned, sunset: &Zoned) -> SignedDuration {
166 sunset.duration_since(sunrise) / 12
167}
168
169/// Returns solar noon.
170///
171/// Solar noon occurs when the Sun transits the celestial meridian and reaches
172/// its apparent highest point in the sky.
173#[must_use]
174pub fn solar_noon(date: Date, geo_location: &GeoLocation) -> Option<Zoned> {
175 date_time_from_time_of_day(
176 date,
177 noaa_calculator::utc_noon(date, geo_location)?,
178 geo_location,
179 &SolarEvent::Noon,
180 )
181}
182
183/// Returns solar midnight.
184///
185/// Solar midnight occurs when the Sun is closest to the nadir (the direction
186/// directly below the observer).
187#[must_use]
188pub fn solar_midnight(date: Date, geo_location: &GeoLocation) -> Option<Zoned> {
189 date_time_from_time_of_day(
190 date,
191 noaa_calculator::utc_midnight(date, geo_location)?,
192 geo_location,
193 &SolarEvent::Midnight,
194 )
195}
196
197/// Returns the solar azimuth (in degrees, measured clockwise from due north) of
198/// the sun at the given datetime and location.
199#[must_use]
200pub fn solar_azimuth(instant: &Zoned, geo_location: &GeoLocation) -> f64 {
201 noaa_calculator::solar_azimuth(instant, geo_location)
202}
203
204/// Returns the solar elevation (in degrees) of the sun at the given datetime
205/// and location. The value is negative when the sun is below the horizon, and
206/// is based on sea level (not adjusted for altitude).
207#[must_use]
208pub fn solar_elevation(instant: &Zoned, geo_location: &GeoLocation) -> f64 {
209 noaa_calculator::solar_elevation(instant, geo_location)
210}
211
212/// A cardinal direction on the horizon, identifying the two solar azimuths
213/// used by [`time_at_azimuth`] for polar *zmanim*.
214#[derive(Debug, Clone, Copy, PartialEq, Eq)]
215pub enum Azimuth {
216 /// Due east, an azimuth of 90°. Treated as sunrise in polar regions.
217 East,
218 /// Due west, an azimuth of 270°. Treated as sunset in polar regions.
219 West,
220}
221
222/// Returns the time at which the sun is directly due [east](Azimuth::East)
223/// (azimuth 90°) or due [west](Azimuth::West) (azimuth 270°).
224///
225/// This is used in polar regions on days when there is no sunrise or sunset,
226/// where some *halachic* opinions treat sunrise as the time the sun is directly
227/// due east and sunset as the time it is directly due west.
228///
229/// Returns `None` when the azimuth is never reached for the requested date and
230/// location.
231#[must_use]
232pub fn time_at_azimuth(
233 date: Date,
234 geo_location: &GeoLocation,
235 azimuth: Azimuth,
236) -> Option<Zoned> {
237 date_time_from_time_of_day(
238 date,
239 noaa_calculator::utc_time_at_azimuth(date, geo_location, azimuth)?,
240 geo_location,
241 match azimuth {
242 Azimuth::East => &SolarEvent::Sunrise,
243 Azimuth::West => &SolarEvent::Sunset,
244 },
245 )
246}
247
248/// The type of solar event being calculated, used to anchor a UTC time-of-day
249/// to the correct civil date
250enum SolarEvent {
251 Sunrise,
252 Sunset,
253 Noon,
254 Midnight,
255}
256
257/// Returns a `Zoned` datetime in the location's timezone, made from the
258/// (floating-point) number of hours in UTC time.
259///
260/// NOAA returns the UTC time-of-day in `[0, 24)`, but the UTC civil date the
261/// event falls on can be the day before/after the requested local civil date,
262/// depending on the location's longitude and the event. The UTC anchor date is
263/// chosen by the event's apparent solar time, so that e.g. a sunset-type event
264/// before local dawn is anchored to the *following* civil day.
265fn date_time_from_time_of_day(
266 date: Date,
267 time_of_day: f64,
268 geo_location: &GeoLocation,
269 event: &SolarEvent,
270) -> Option<Zoned> {
271 let anchor = noaa_calculator::antimeridian_adjusted_date(
272 &date.to_zoned(geo_location.timezone.clone()).ok()?,
273 geo_location.longitude,
274 );
275
276 // apparent solar time of the event, per the longitude's natural offset
277 let local_time_hours = geo_location.longitude / 15.0 + time_of_day;
278 let anchor = match event {
279 SolarEvent::Sunrise if local_time_hours > 18.0 => anchor.yesterday().ok()?,
280 SolarEvent::Sunset if local_time_hours < 6.0 => anchor.tomorrow().ok()?,
281 SolarEvent::Midnight if local_time_hours < 12.0 => anchor.tomorrow().ok()?,
282 SolarEvent::Noon if local_time_hours < 0.0 => anchor.tomorrow().ok()?,
283 SolarEvent::Noon if local_time_hours > 24.0 => anchor.yesterday().ok()?,
284 _ => anchor,
285 };
286
287 // Create UTC datetime at midnight of the anchor date and add the
288 // nanosecond conversion of time_of_day
289 let total_nanos = (time_of_day * HOUR_NANOS).round() as i64;
290 let utc_dt = anchor
291 .to_zoned(TimeZone::UTC)
292 .ok()?
293 .add(SignedDuration::from_nanos(total_nanos));
294
295 Some(utc_dt.with_time_zone(geo_location.timezone.clone()))
296}
297
298/// Returns local mean time (LMT) for `hours` converted to regular clock time.
299///
300/// The `hours` argument must be in `[0.0, 24.0)`.
301///
302/// This time is adjusted from standard time to account for the local longitude.
303/// The 360° of the globe divided by 24 calculates to 15° per hour with
304/// 4 minutes per degree, so at a longitude of 0 , 15, 30 etc... noon is at
305/// exactly 12:00 PM. Lakewood, N.J., with a longitude of -74.222, is 0.778°
306/// away from -75° (the nearest multiple of 15). This is multiplied by
307/// 4 clock minutes per degree to yield about 3 minutes and 7 seconds, for a
308/// local noon of approximately 11:56:53 AM. This method is not tied to the
309/// theoretical 15° time zones, and it adjusts to the actual timezone and
310/// daylight saving time to return LMT.
311#[must_use]
312pub fn local_mean_time(date: Date, geo_location: &GeoLocation, hours: f64) -> Option<Zoned> {
313 if !(0.0..24.0).contains(&hours) {
314 return None;
315 }
316 let time_of_day = hours - geo_location.local_mean_time_offset();
317 let total_nanos = (time_of_day * HOUR_NANOS).round() as i64;
318 let utc_dt = date
319 .to_zoned(TimeZone::UTC)
320 .ok()?
321 .add(SignedDuration::from_nanos(total_nanos));
322
323 // Unlike the solar events, LMT has no event type to anchor by; re-anchor
324 // to the requested local date by exactly 24 absolute hours, i.e. one UTC
325 // day (civil-day arithmetic would shift by 23/25 hours across a DST
326 // transition)
327 let local_dt = utc_dt.with_time_zone(geo_location.timezone.clone());
328 match local_dt.date().cmp(&date) {
329 Ordering::Less => Some(local_dt.add(SignedDuration::from_hours(24))),
330 Ordering::Greater => Some(local_dt.sub(SignedDuration::from_hours(24))),
331 Ordering::Equal => Some(local_dt),
332 }
333}