Skip to main content

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&deg;. 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&deg;
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&deg;. 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&deg;
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&deg; 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&deg; 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&deg;. Treated as sunrise in polar regions.
217    East,
218    /// Due west, an azimuth of 270&deg;. 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&deg;) or due [west](Azimuth::West) (azimuth 270&deg;).
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&deg; of the globe divided by 24 calculates to 15&deg; 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&deg;
306/// away from -75&deg; (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&deg; 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}