Skip to main content

solar_positioning/
types.rs

1//! Core data types for solar positioning calculations.
2
3use crate::error::{check_azimuth, check_pressure, check_temperature, check_zenith_angle};
4use crate::math::{floor, rem_euclid};
5use crate::Result;
6
7/// Predefined elevation angles for sunrise/sunset calculations.
8///
9/// Corresponds to different twilight definitions for consistent sunrise, sunset, and twilight calculations.
10#[derive(Debug, Clone, Copy, PartialEq)]
11pub enum Horizon {
12    /// Standard sunrise/sunset (sun's upper limb touches horizon, accounting for refraction)
13    SunriseSunset,
14    /// Civil twilight (sun is 6° below horizon)
15    CivilTwilight,
16    /// Nautical twilight (sun is 12° below horizon)
17    NauticalTwilight,
18    /// Astronomical twilight (sun is 18° below horizon)
19    AstronomicalTwilight,
20    /// Custom elevation angle
21    Custom(f64),
22}
23
24impl Horizon {
25    /// Gets the elevation angle in degrees for this horizon definition.
26    ///
27    /// Negative values indicate the sun is below the horizon.
28    #[must_use]
29    pub const fn elevation_angle(&self) -> f64 {
30        match self {
31            Self::SunriseSunset => -0.83337, // Accounts for refraction and sun's radius
32            Self::CivilTwilight => -6.0,
33            Self::NauticalTwilight => -12.0,
34            Self::AstronomicalTwilight => -18.0,
35            Self::Custom(angle) => *angle,
36        }
37    }
38}
39
40/// Atmospheric conditions for refraction correction in solar position calculations.
41///
42/// Atmospheric refraction bends light rays, causing the apparent sun position to differ
43/// from its true geometric position by up to ~0.6° near the horizon.
44///
45/// # Example
46/// ```
47/// # use solar_positioning::types::RefractionCorrection;
48/// // Standard atmospheric conditions at sea level
49/// let standard = RefractionCorrection::standard();
50/// assert_eq!(standard.pressure(), 1013.25);
51/// assert_eq!(standard.temperature(), 15.0);
52///
53/// // Custom conditions for high altitude or different climate
54/// let custom = RefractionCorrection::new(900.0, -5.0).unwrap();
55/// assert_eq!(custom.pressure(), 900.0);
56/// assert_eq!(custom.temperature(), -5.0);
57/// ```
58#[derive(Debug, Clone, Copy, PartialEq)]
59pub struct RefractionCorrection {
60    /// Atmospheric pressure in millibars (hPa)
61    pressure: f64,
62    /// Temperature in degrees Celsius
63    temperature: f64,
64}
65
66impl RefractionCorrection {
67    /// Creates a new refraction correction with the specified atmospheric conditions.
68    ///
69    /// # Errors
70    /// Returns `InvalidPressure` or `InvalidTemperature` for out-of-range values.
71    ///
72    /// # Example
73    /// ```
74    /// # use solar_positioning::types::RefractionCorrection;
75    /// let correction = RefractionCorrection::new(1013.25, 15.0).unwrap();
76    /// assert_eq!(correction.pressure(), 1013.25);
77    /// assert_eq!(correction.temperature(), 15.0);
78    /// ```
79    pub fn new(pressure: f64, temperature: f64) -> Result<Self> {
80        check_pressure(pressure)?;
81        check_temperature(temperature)?;
82        Ok(Self {
83            pressure,
84            temperature,
85        })
86    }
87
88    /// Creates refraction correction using standard atmospheric conditions.
89    ///
90    /// Uses standard sea-level conditions:
91    /// - Pressure: 1013.25 millibars (standard atmosphere)
92    /// - Temperature: 15.0°C (59°F)
93    ///
94    /// # Example
95    /// ```
96    /// # use solar_positioning::types::RefractionCorrection;
97    /// let standard = RefractionCorrection::standard();
98    /// assert_eq!(standard.pressure(), 1013.25);
99    /// assert_eq!(standard.temperature(), 15.0);
100    /// ```
101    #[must_use]
102    pub const fn standard() -> Self {
103        Self {
104            pressure: 1013.25,
105            temperature: 15.0,
106        }
107    }
108
109    /// Gets the atmospheric pressure in millibars.
110    #[must_use]
111    pub const fn pressure(&self) -> f64 {
112        self.pressure
113    }
114
115    /// Gets the temperature in degrees Celsius.
116    #[must_use]
117    pub const fn temperature(&self) -> f64 {
118        self.temperature
119    }
120}
121
122/// Solar position in topocentric coordinates.
123///
124/// Represents the sun's position as seen from a specific point on Earth's surface.
125/// Uses the standard astronomical coordinate system where:
126/// - Azimuth: 0° = North, measured clockwise to 360°
127/// - Zenith angle: 0° = directly overhead (zenith), 90° = horizon, 180° = nadir
128/// - Elevation angle: 90° = directly overhead, 0° = horizon, -90° = nadir
129#[derive(Debug, Clone, Copy, PartialEq)]
130pub struct SolarPosition {
131    /// Azimuth angle in degrees (0° to 360°, 0° = North, increasing clockwise)
132    azimuth: f64,
133    /// Zenith angle in degrees (0° to 180°, 0° = zenith, 90° = horizon)
134    zenith_angle: f64,
135}
136
137impl SolarPosition {
138    /// Creates a new solar position from azimuth and zenith angle.
139    ///
140    /// # Errors
141    /// Returns error if azimuth or zenith angles are outside valid ranges.
142    ///
143    /// # Example
144    /// ```
145    /// # use solar_positioning::types::SolarPosition;
146    /// let position = SolarPosition::new(180.0, 30.0).unwrap();
147    /// assert_eq!(position.azimuth(), 180.0);
148    /// assert_eq!(position.zenith_angle(), 30.0);
149    /// assert_eq!(position.elevation_angle(), 60.0);
150    /// ```
151    pub fn new(azimuth: f64, zenith_angle: f64) -> Result<Self> {
152        let normalized_azimuth = check_azimuth(azimuth)?;
153        let validated_zenith = check_zenith_angle(zenith_angle)?;
154
155        Ok(Self {
156            azimuth: normalized_azimuth,
157            zenith_angle: validated_zenith,
158        })
159    }
160
161    /// Gets the azimuth angle in degrees (0° to 360°, 0° = North, increasing clockwise).
162    #[must_use]
163    pub const fn azimuth(&self) -> f64 {
164        self.azimuth
165    }
166
167    /// Gets the zenith angle in degrees (0° to 180°, 0° = zenith, 90° = horizon).
168    #[must_use]
169    pub const fn zenith_angle(&self) -> f64 {
170        self.zenith_angle
171    }
172
173    /// Gets the elevation angle in degrees.
174    ///
175    /// This is the complement of the zenith angle: elevation = 90° - zenith.
176    #[must_use]
177    pub fn elevation_angle(&self) -> f64 {
178        90.0 - self.zenith_angle
179    }
180
181    /// Checks if the sun is above the horizon (elevation angle > 0°).
182    #[must_use]
183    pub fn is_sun_up(&self) -> bool {
184        self.elevation_angle() > 0.0
185    }
186
187    /// Checks if the sun is at or below the horizon (elevation angle ≤ 0°).
188    #[must_use]
189    pub fn is_sun_down(&self) -> bool {
190        self.elevation_angle() <= 0.0
191    }
192}
193
194/// Hours since midnight UTC that can extend beyond a single day.
195///
196/// Used for sunrise/sunset times without the chrono dependency.
197/// Values represent hours since midnight UTC (0 UT) for the calculation date:
198/// - Negative values indicate the previous day
199/// - 0.0 to < 24.0 indicates the current day
200/// - ≥ 24.0 indicates the next day
201///
202/// # Example
203/// ```
204/// # use solar_positioning::types::HoursUtc;
205/// let morning = HoursUtc::from_hours(6.5); // 06:30 current day
206/// let late_evening = HoursUtc::from_hours(23.5); // 23:30 current day
207/// let after_midnight = HoursUtc::from_hours(24.5); // 00:30 next day
208/// let before_midnight_prev = HoursUtc::from_hours(-0.5); // 23:30 previous day
209/// ```
210#[derive(Debug, Clone, Copy, PartialEq)]
211pub struct HoursUtc(f64);
212
213impl HoursUtc {
214    /// Creates a new `HoursUtc` from hours since midnight UTC.
215    ///
216    /// Values can be negative (previous day) or ≥ 24.0 (next day).
217    #[must_use]
218    pub const fn from_hours(hours: f64) -> Self {
219        Self(hours)
220    }
221
222    /// Gets the raw hours value.
223    ///
224    /// Can be negative (previous day) or ≥ 24.0 (next day).
225    #[must_use]
226    pub const fn hours(&self) -> f64 {
227        self.0
228    }
229
230    /// Gets the day offset and normalized hours (0.0 to < 24.0).
231    ///
232    /// # Returns
233    /// Tuple of (`day_offset`, `hours_in_day`) where:
234    /// - `day_offset`: whole days offset from the calculation date (negative = previous days, positive = following days)
235    /// - `hours_in_day`: 0.0 to < 24.0
236    ///
237    /// # Example
238    /// ```
239    /// # use solar_positioning::types::HoursUtc;
240    /// let time = HoursUtc::from_hours(25.5);
241    /// let (day_offset, hours) = time.day_and_hours();
242    /// assert_eq!(day_offset, 1);
243    /// assert!((hours - 1.5).abs() < 1e-10);
244    /// ```
245    #[must_use]
246    pub fn day_and_hours(&self) -> (i32, f64) {
247        let hours = self.0;
248        (floor(hours / 24.0) as i32, rem_euclid(hours, 24.0))
249    }
250}
251
252/// Result of sunrise/sunset calculations for a given day.
253///
254/// Solar events can vary significantly based on location and time of year,
255/// especially at extreme latitudes where polar days and nights occur.
256#[derive(Debug, Clone, PartialEq, Eq)]
257#[cfg_attr(
258    feature = "std",
259    doc = "Default generic parameter is `()`; chrono helpers return `SunriseResult<chrono::DateTime<Tz>>`."
260)]
261pub enum SunriseResult<T = ()> {
262    /// Regular day with distinct sunrise, transit (noon), and sunset times
263    RegularDay {
264        /// Time of sunrise
265        sunrise: T,
266        /// Time of solar transit (when sun crosses meridian, solar noon)
267        transit: T,
268        /// Time of sunset
269        sunset: T,
270    },
271    /// Polar day - sun remains above the specified horizon all day
272    AllDay {
273        /// Time of solar transit (closest approach to zenith)
274        transit: T,
275    },
276    /// Polar night - sun remains below the specified horizon all day
277    AllNight {
278        /// Time of solar transit (when sun is highest, though still below horizon)
279        transit: T,
280    },
281}
282
283impl<T> SunriseResult<T> {
284    /// Gets the transit time (solar noon) for any sunrise result.
285    pub const fn transit(&self) -> &T {
286        match self {
287            Self::RegularDay { transit, .. }
288            | Self::AllDay { transit }
289            | Self::AllNight { transit } => transit,
290        }
291    }
292
293    /// Checks if this represents a regular day with sunrise and sunset.
294    pub const fn is_regular_day(&self) -> bool {
295        matches!(self, Self::RegularDay { .. })
296    }
297
298    /// Checks if this represents a polar day (sun never sets).
299    pub const fn is_polar_day(&self) -> bool {
300        matches!(self, Self::AllDay { .. })
301    }
302
303    /// Checks if this represents a polar night (sun never rises).
304    pub const fn is_polar_night(&self) -> bool {
305        matches!(self, Self::AllNight { .. })
306    }
307
308    /// Gets sunrise time if this is a regular day.
309    pub const fn sunrise(&self) -> Option<&T> {
310        if let Self::RegularDay { sunrise, .. } = self {
311            Some(sunrise)
312        } else {
313            None
314        }
315    }
316
317    /// Gets sunset time if this is a regular day.
318    pub const fn sunset(&self) -> Option<&T> {
319        if let Self::RegularDay { sunset, .. } = self {
320            Some(sunset)
321        } else {
322            None
323        }
324    }
325}
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330
331    #[test]
332    fn test_horizon_elevation_angles() {
333        assert_eq!(Horizon::SunriseSunset.elevation_angle(), -0.83337);
334        assert_eq!(Horizon::CivilTwilight.elevation_angle(), -6.0);
335        assert_eq!(Horizon::NauticalTwilight.elevation_angle(), -12.0);
336        assert_eq!(Horizon::AstronomicalTwilight.elevation_angle(), -18.0);
337
338        assert_eq!(Horizon::Custom(-3.0).elevation_angle(), -3.0);
339    }
340
341    #[test]
342    fn test_solar_position_creation() {
343        let pos = SolarPosition::new(180.0, 45.0).unwrap();
344        assert_eq!(pos.azimuth(), 180.0);
345        assert_eq!(pos.zenith_angle(), 45.0);
346        assert_eq!(pos.elevation_angle(), 45.0);
347        assert!(pos.is_sun_up());
348        assert!(!pos.is_sun_down());
349
350        // Test normalization
351        let pos = SolarPosition::new(-90.0, 90.0).unwrap();
352        assert_eq!(pos.azimuth(), 270.0);
353        assert_eq!(pos.elevation_angle(), 0.0);
354
355        // Test validation
356        assert!(SolarPosition::new(0.0, -1.0).is_err());
357        assert!(SolarPosition::new(0.0, 181.0).is_err());
358    }
359
360    #[test]
361    fn test_solar_position_sun_state() {
362        let above_horizon = SolarPosition::new(180.0, 30.0).unwrap();
363        assert!(above_horizon.is_sun_up());
364        assert!(!above_horizon.is_sun_down());
365
366        let on_horizon = SolarPosition::new(180.0, 90.0).unwrap();
367        assert!(!on_horizon.is_sun_up());
368        assert!(on_horizon.is_sun_down());
369
370        let below_horizon = SolarPosition::new(180.0, 120.0).unwrap();
371        assert!(!below_horizon.is_sun_up());
372        assert!(below_horizon.is_sun_down());
373    }
374
375    #[test]
376    fn test_sunrise_result_regular_day() {
377        use chrono::{DateTime, Utc};
378
379        let sunrise = "2023-06-21T05:30:00Z".parse::<DateTime<Utc>>().unwrap();
380        let transit = "2023-06-21T12:00:00Z".parse::<DateTime<Utc>>().unwrap();
381        let sunset = "2023-06-21T18:30:00Z".parse::<DateTime<Utc>>().unwrap();
382
383        let result = SunriseResult::RegularDay {
384            sunrise,
385            transit,
386            sunset,
387        };
388
389        assert!(result.is_regular_day());
390        assert!(!result.is_polar_day());
391        assert!(!result.is_polar_night());
392        assert_eq!(result.transit(), &transit);
393        assert_eq!(result.sunrise(), Some(&sunrise));
394        assert_eq!(result.sunset(), Some(&sunset));
395    }
396
397    #[test]
398    fn test_sunrise_result_polar_day() {
399        use chrono::{DateTime, Utc};
400
401        let transit = "2023-06-21T12:00:00Z".parse::<DateTime<Utc>>().unwrap();
402        let result = SunriseResult::AllDay { transit };
403
404        assert!(!result.is_regular_day());
405        assert!(result.is_polar_day());
406        assert!(!result.is_polar_night());
407        assert_eq!(result.transit(), &transit);
408        assert_eq!(result.sunrise(), None);
409        assert_eq!(result.sunset(), None);
410    }
411
412    #[test]
413    fn test_sunrise_result_polar_night() {
414        use chrono::{DateTime, Utc};
415
416        let transit = "2023-12-21T12:00:00Z".parse::<DateTime<Utc>>().unwrap();
417        let result = SunriseResult::AllNight { transit };
418
419        assert!(!result.is_regular_day());
420        assert!(!result.is_polar_day());
421        assert!(result.is_polar_night());
422        assert_eq!(result.transit(), &transit);
423        assert_eq!(result.sunrise(), None);
424        assert_eq!(result.sunset(), None);
425    }
426
427    #[test]
428    fn test_refraction_correction() {
429        // Test standard conditions
430        let standard = RefractionCorrection::standard();
431        assert_eq!(standard.pressure(), 1013.25);
432        assert_eq!(standard.temperature(), 15.0);
433
434        // Test custom conditions
435        let custom = RefractionCorrection::new(1000.0, 20.0).unwrap();
436        assert_eq!(custom.pressure(), 1000.0);
437        assert_eq!(custom.temperature(), 20.0);
438
439        // Test validation
440        assert!(RefractionCorrection::new(-1.0, 15.0).is_err()); // Invalid pressure
441        assert!(RefractionCorrection::new(1013.25, -300.0).is_err()); // Invalid temperature
442        assert!(RefractionCorrection::new(3000.0, 15.0).is_err()); // Too high pressure
443        assert!(RefractionCorrection::new(1013.25, 150.0).is_err()); // Too high temperature
444    }
445}