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