solar_positioning/
types.rs

1//! Core data types for solar positioning calculations.
2
3use crate::error::{check_azimuth, check_zenith_angle};
4use crate::{Error, Result};
5
6/// Predefined elevation angles for sunrise/sunset calculations.
7///
8/// These correspond to different definitions of twilight and allow for
9/// consistent calculations of sunrise, sunset, and twilight times.
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    ///
29    /// # Returns
30    /// Elevation angle in degrees
31    #[must_use]
32    pub const fn elevation_angle(&self) -> f64 {
33        match self {
34            Self::SunriseSunset => -0.83337, // Accounts for refraction and sun's radius
35            Self::CivilTwilight => -6.0,
36            Self::NauticalTwilight => -12.0,
37            Self::AstronomicalTwilight => -18.0,
38            Self::Custom(angle) => *angle,
39        }
40    }
41
42    /// Creates a custom horizon with the specified elevation angle.
43    ///
44    /// # Arguments
45    /// * `elevation_degrees` - Elevation angle in degrees (-90 to +90)
46    ///
47    /// # Returns
48    /// Custom horizon or error if angle is invalid
49    ///
50    /// # Errors
51    /// Returns `InvalidElevationAngle` if elevation is outside -90 to +90 degrees.
52    pub fn custom(elevation_degrees: f64) -> Result<Self> {
53        if !(-90.0..=90.0).contains(&elevation_degrees) {
54            return Err(Error::invalid_elevation_angle(elevation_degrees));
55        }
56        Ok(Self::Custom(elevation_degrees))
57    }
58}
59
60impl Eq for Horizon {}
61
62impl std::hash::Hash for Horizon {
63    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
64        match self {
65            Self::SunriseSunset => 0.hash(state),
66            Self::CivilTwilight => 1.hash(state),
67            Self::NauticalTwilight => 2.hash(state),
68            Self::AstronomicalTwilight => 3.hash(state),
69            Self::Custom(angle) => {
70                4.hash(state);
71                // Use bit representation for deterministic hashing of f64
72                angle.to_bits().hash(state);
73            }
74        }
75    }
76}
77
78/// Solar position in topocentric coordinates.
79///
80/// Represents the sun's position as seen from a specific point on Earth's surface.
81/// Uses the standard astronomical coordinate system where:
82/// - Azimuth: 0° = North, measured clockwise to 360°
83/// - Zenith angle: 0° = directly overhead (zenith), 90° = horizon, 180° = nadir
84/// - Elevation angle: 90° = directly overhead, 0° = horizon, -90° = nadir
85#[derive(Debug, Clone, Copy, PartialEq)]
86#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
87pub struct SolarPosition {
88    /// Azimuth angle in degrees (0° to 360°, 0° = North, increasing clockwise)
89    azimuth: f64,
90    /// Zenith angle in degrees (0° to 180°, 0° = zenith, 90° = horizon)
91    zenith_angle: f64,
92}
93
94impl SolarPosition {
95    /// Creates a new solar position from azimuth and zenith angle.
96    ///
97    /// # Arguments
98    /// * `azimuth` - Azimuth angle in degrees (will be normalized to 0-360°)
99    /// * `zenith_angle` - Zenith angle in degrees (must be 0-180°)
100    ///
101    /// # Returns
102    /// Solar position or error if zenith angle is invalid
103    ///
104    /// # Errors
105    /// Returns error if azimuth or zenith angles are outside valid ranges.
106    ///
107    /// # Example
108    /// ```
109    /// # use solar_positioning::types::SolarPosition;
110    /// let position = SolarPosition::new(180.0, 30.0).unwrap();
111    /// assert_eq!(position.azimuth(), 180.0);
112    /// assert_eq!(position.zenith_angle(), 30.0);
113    /// assert_eq!(position.elevation_angle(), 60.0);
114    /// ```
115    pub fn new(azimuth: f64, zenith_angle: f64) -> Result<Self> {
116        let normalized_azimuth = check_azimuth(azimuth)?;
117        let validated_zenith = check_zenith_angle(zenith_angle)?;
118
119        Ok(Self {
120            azimuth: normalized_azimuth,
121            zenith_angle: validated_zenith,
122        })
123    }
124
125    /// Gets the azimuth angle in degrees.
126    ///
127    /// # Returns
128    /// Azimuth angle (0° to 360°, 0° = North, increasing clockwise)
129    #[must_use]
130    pub const fn azimuth(&self) -> f64 {
131        self.azimuth
132    }
133
134    /// Gets the zenith angle in degrees.
135    ///
136    /// # Returns
137    /// Zenith angle (0° to 180°, 0° = zenith, 90° = horizon)
138    #[must_use]
139    pub const fn zenith_angle(&self) -> f64 {
140        self.zenith_angle
141    }
142
143    /// Gets the elevation angle in degrees.
144    ///
145    /// This is the complement of the zenith angle: elevation = 90° - zenith
146    ///
147    /// # Returns
148    /// Elevation angle (-90° to 90°, 90° = zenith, 0° = horizon, -90° = nadir)
149    #[must_use]
150    pub fn elevation_angle(&self) -> f64 {
151        90.0 - self.zenith_angle
152    }
153
154    /// Checks if the sun is above the horizon.
155    ///
156    /// # Returns
157    /// `true` if elevation angle > 0°, `false` otherwise
158    #[must_use]
159    pub fn is_sun_up(&self) -> bool {
160        self.elevation_angle() > 0.0
161    }
162
163    /// Checks if the sun is at or below the horizon.
164    ///
165    /// # Returns
166    /// `true` if elevation angle <= 0°, `false` otherwise
167    #[must_use]
168    pub fn is_sun_down(&self) -> bool {
169        self.elevation_angle() <= 0.0
170    }
171}
172
173/// Result of sunrise/sunset calculations for a given day.
174///
175/// Solar events can vary significantly based on location and time of year,
176/// especially at extreme latitudes where polar days and nights occur.
177#[derive(Debug, Clone, PartialEq, Eq)]
178#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
179pub enum SunriseResult<T = chrono::DateTime<chrono::Utc>> {
180    /// Regular day with distinct sunrise, transit (noon), and sunset times
181    RegularDay {
182        /// Time of sunrise
183        sunrise: T,
184        /// Time of solar transit (when sun crosses meridian, solar noon)
185        transit: T,
186        /// Time of sunset
187        sunset: T,
188    },
189    /// Polar day - sun remains above the specified horizon all day
190    AllDay {
191        /// Time of solar transit (closest approach to zenith)
192        transit: T,
193    },
194    /// Polar night - sun remains below the specified horizon all day
195    AllNight {
196        /// Time of solar transit (when sun is highest, though still below horizon)
197        transit: T,
198    },
199}
200
201impl<T> SunriseResult<T> {
202    /// Gets the transit time (solar noon) for any sunrise result.
203    ///
204    /// # Returns
205    /// Time when the sun crosses the meridian (solar noon)
206    pub const fn transit(&self) -> &T {
207        match self {
208            Self::RegularDay { transit, .. }
209            | Self::AllDay { transit }
210            | Self::AllNight { transit } => transit,
211        }
212    }
213
214    /// Checks if this represents a regular day with sunrise and sunset.
215    ///
216    /// # Returns
217    /// `true` if this is a regular day, `false` for polar conditions
218    pub const fn is_regular_day(&self) -> bool {
219        matches!(self, Self::RegularDay { .. })
220    }
221
222    /// Checks if this represents a polar day (sun never sets).
223    ///
224    /// # Returns
225    /// `true` if sun remains above horizon all day
226    pub const fn is_polar_day(&self) -> bool {
227        matches!(self, Self::AllDay { .. })
228    }
229
230    /// Checks if this represents a polar night (sun never rises).
231    ///
232    /// # Returns
233    /// `true` if sun remains below horizon all day
234    pub const fn is_polar_night(&self) -> bool {
235        matches!(self, Self::AllNight { .. })
236    }
237
238    /// Gets sunrise time if this is a regular day.
239    ///
240    /// # Returns
241    /// `Some(sunrise)` for regular days, `None` for polar conditions
242    pub const fn sunrise(&self) -> Option<&T> {
243        if let Self::RegularDay { sunrise, .. } = self {
244            Some(sunrise)
245        } else {
246            None
247        }
248    }
249
250    /// Gets sunset time if this is a regular day.
251    ///
252    /// # Returns
253    /// `Some(sunset)` for regular days, `None` for polar conditions
254    pub const fn sunset(&self) -> Option<&T> {
255        if let Self::RegularDay { sunset, .. } = self {
256            Some(sunset)
257        } else {
258            None
259        }
260    }
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266
267    #[test]
268    fn test_horizon_elevation_angles() {
269        assert_eq!(Horizon::SunriseSunset.elevation_angle(), -0.83337);
270        assert_eq!(Horizon::CivilTwilight.elevation_angle(), -6.0);
271        assert_eq!(Horizon::NauticalTwilight.elevation_angle(), -12.0);
272        assert_eq!(Horizon::AstronomicalTwilight.elevation_angle(), -18.0);
273
274        let custom = Horizon::custom(-3.0).unwrap();
275        assert_eq!(custom.elevation_angle(), -3.0);
276
277        assert!(Horizon::custom(-95.0).is_err());
278        assert!(Horizon::custom(95.0).is_err());
279    }
280
281    #[test]
282    fn test_solar_position_creation() {
283        let pos = SolarPosition::new(180.0, 45.0).unwrap();
284        assert_eq!(pos.azimuth(), 180.0);
285        assert_eq!(pos.zenith_angle(), 45.0);
286        assert_eq!(pos.elevation_angle(), 45.0);
287        assert!(pos.is_sun_up());
288        assert!(!pos.is_sun_down());
289
290        // Test normalization
291        let pos = SolarPosition::new(-90.0, 90.0).unwrap();
292        assert_eq!(pos.azimuth(), 270.0);
293        assert_eq!(pos.elevation_angle(), 0.0);
294
295        // Test validation
296        assert!(SolarPosition::new(0.0, -1.0).is_err());
297        assert!(SolarPosition::new(0.0, 181.0).is_err());
298    }
299
300    #[test]
301    fn test_solar_position_sun_state() {
302        let above_horizon = SolarPosition::new(180.0, 30.0).unwrap();
303        assert!(above_horizon.is_sun_up());
304        assert!(!above_horizon.is_sun_down());
305
306        let on_horizon = SolarPosition::new(180.0, 90.0).unwrap();
307        assert!(!on_horizon.is_sun_up());
308        assert!(on_horizon.is_sun_down());
309
310        let below_horizon = SolarPosition::new(180.0, 120.0).unwrap();
311        assert!(!below_horizon.is_sun_up());
312        assert!(below_horizon.is_sun_down());
313    }
314
315    #[test]
316    fn test_sunrise_result_regular_day() {
317        use chrono::{DateTime, Utc};
318
319        let sunrise = "2023-06-21T05:30:00Z".parse::<DateTime<Utc>>().unwrap();
320        let transit = "2023-06-21T12:00:00Z".parse::<DateTime<Utc>>().unwrap();
321        let sunset = "2023-06-21T18:30:00Z".parse::<DateTime<Utc>>().unwrap();
322
323        let result = SunriseResult::RegularDay {
324            sunrise,
325            transit,
326            sunset,
327        };
328
329        assert!(result.is_regular_day());
330        assert!(!result.is_polar_day());
331        assert!(!result.is_polar_night());
332        assert_eq!(result.transit(), &transit);
333        assert_eq!(result.sunrise(), Some(&sunrise));
334        assert_eq!(result.sunset(), Some(&sunset));
335    }
336
337    #[test]
338    fn test_sunrise_result_polar_day() {
339        use chrono::{DateTime, Utc};
340
341        let transit = "2023-06-21T12:00:00Z".parse::<DateTime<Utc>>().unwrap();
342        let result = SunriseResult::AllDay { transit };
343
344        assert!(!result.is_regular_day());
345        assert!(result.is_polar_day());
346        assert!(!result.is_polar_night());
347        assert_eq!(result.transit(), &transit);
348        assert_eq!(result.sunrise(), None);
349        assert_eq!(result.sunset(), None);
350    }
351
352    #[test]
353    fn test_sunrise_result_polar_night() {
354        use chrono::{DateTime, Utc};
355
356        let transit = "2023-12-21T12:00:00Z".parse::<DateTime<Utc>>().unwrap();
357        let result = SunriseResult::AllNight { transit };
358
359        assert!(!result.is_regular_day());
360        assert!(!result.is_polar_day());
361        assert!(result.is_polar_night());
362        assert_eq!(result.transit(), &transit);
363        assert_eq!(result.sunrise(), None);
364        assert_eq!(result.sunset(), None);
365    }
366}