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/// Hours since midnight UTC that can extend beyond a single day.
223///
224/// Used for sunrise/sunset times without the chrono dependency.
225/// Values represent hours since midnight UTC (0 UT) for the calculation date:
226/// - Negative values indicate the previous day
227/// - 0.0 to < 24.0 indicates the current day
228/// - ≥ 24.0 indicates the next day
229///
230/// # Example
231/// ```
232/// # use solar_positioning::types::HoursUtc;
233/// let morning = HoursUtc::from_hours(6.5); // 06:30 current day
234/// let late_evening = HoursUtc::from_hours(23.5); // 23:30 current day
235/// let after_midnight = HoursUtc::from_hours(24.5); // 00:30 next day
236/// let before_midnight_prev = HoursUtc::from_hours(-0.5); // 23:30 previous day
237/// ```
238#[derive(Debug, Clone, Copy, PartialEq)]
239pub struct HoursUtc(f64);
240
241impl HoursUtc {
242    /// Creates a new `HoursUtc` from hours since midnight UTC.
243    ///
244    /// Values can be negative (previous day) or ≥ 24.0 (next day).
245    #[must_use]
246    pub const fn from_hours(hours: f64) -> Self {
247        Self(hours)
248    }
249
250    /// Gets the raw hours value.
251    ///
252    /// Can be negative (previous day) or ≥ 24.0 (next day).
253    #[must_use]
254    pub const fn hours(&self) -> f64 {
255        self.0
256    }
257
258    /// Gets the day offset (-1, 0, or 1) and normalized hours (0.0 to < 24.0).
259    ///
260    /// # Returns
261    /// Tuple of (`day_offset`, `hours_in_day`) where:
262    /// - `day_offset`: -1 = previous day, 0 = current day, 1 = next day
263    /// - `hours_in_day`: 0.0 to < 24.0
264    ///
265    /// # Example
266    /// ```
267    /// # use solar_positioning::types::HoursUtc;
268    /// let time = HoursUtc::from_hours(25.5);
269    /// let (day_offset, hours) = time.day_and_hours();
270    /// assert_eq!(day_offset, 1);
271    /// assert!((hours - 1.5).abs() < 1e-10);
272    /// ```
273    #[must_use]
274    pub fn day_and_hours(&self) -> (i32, f64) {
275        let hours = self.0;
276        if hours < 0.0 {
277            (-1, hours + 24.0)
278        } else if hours >= 24.0 {
279            (1, hours - 24.0)
280        } else {
281            (0, hours)
282        }
283    }
284}
285
286/// Result of sunrise/sunset calculations for a given day.
287///
288/// Solar events can vary significantly based on location and time of year,
289/// especially at extreme latitudes where polar days and nights occur.
290#[derive(Debug, Clone, PartialEq, Eq)]
291#[cfg_attr(
292    feature = "std",
293    doc = "Default generic parameter is `chrono::DateTime<chrono::Utc>` when `std` feature is enabled."
294)]
295pub enum SunriseResult<T = ()> {
296    /// Regular day with distinct sunrise, transit (noon), and sunset times
297    RegularDay {
298        /// Time of sunrise
299        sunrise: T,
300        /// Time of solar transit (when sun crosses meridian, solar noon)
301        transit: T,
302        /// Time of sunset
303        sunset: T,
304    },
305    /// Polar day - sun remains above the specified horizon all day
306    AllDay {
307        /// Time of solar transit (closest approach to zenith)
308        transit: T,
309    },
310    /// Polar night - sun remains below the specified horizon all day
311    AllNight {
312        /// Time of solar transit (when sun is highest, though still below horizon)
313        transit: T,
314    },
315}
316
317impl<T> SunriseResult<T> {
318    /// Gets the transit time (solar noon) for any sunrise result.
319    pub const fn transit(&self) -> &T {
320        match self {
321            Self::RegularDay { transit, .. }
322            | Self::AllDay { transit }
323            | Self::AllNight { transit } => transit,
324        }
325    }
326
327    /// Checks if this represents a regular day with sunrise and sunset.
328    pub const fn is_regular_day(&self) -> bool {
329        matches!(self, Self::RegularDay { .. })
330    }
331
332    /// Checks if this represents a polar day (sun never sets).
333    pub const fn is_polar_day(&self) -> bool {
334        matches!(self, Self::AllDay { .. })
335    }
336
337    /// Checks if this represents a polar night (sun never rises).
338    pub const fn is_polar_night(&self) -> bool {
339        matches!(self, Self::AllNight { .. })
340    }
341
342    /// Gets sunrise time if this is a regular day.
343    pub const fn sunrise(&self) -> Option<&T> {
344        if let Self::RegularDay { sunrise, .. } = self {
345            Some(sunrise)
346        } else {
347            None
348        }
349    }
350
351    /// Gets sunset time if this is a regular day.
352    pub const fn sunset(&self) -> Option<&T> {
353        if let Self::RegularDay { sunset, .. } = self {
354            Some(sunset)
355        } else {
356            None
357        }
358    }
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364
365    #[test]
366    fn test_horizon_elevation_angles() {
367        assert_eq!(Horizon::SunriseSunset.elevation_angle(), -0.83337);
368        assert_eq!(Horizon::CivilTwilight.elevation_angle(), -6.0);
369        assert_eq!(Horizon::NauticalTwilight.elevation_angle(), -12.0);
370        assert_eq!(Horizon::AstronomicalTwilight.elevation_angle(), -18.0);
371
372        let custom = Horizon::custom(-3.0).unwrap();
373        assert_eq!(custom.elevation_angle(), -3.0);
374
375        assert!(Horizon::custom(-95.0).is_err());
376        assert!(Horizon::custom(95.0).is_err());
377    }
378
379    #[test]
380    fn test_solar_position_creation() {
381        let pos = SolarPosition::new(180.0, 45.0).unwrap();
382        assert_eq!(pos.azimuth(), 180.0);
383        assert_eq!(pos.zenith_angle(), 45.0);
384        assert_eq!(pos.elevation_angle(), 45.0);
385        assert!(pos.is_sun_up());
386        assert!(!pos.is_sun_down());
387
388        // Test normalization
389        let pos = SolarPosition::new(-90.0, 90.0).unwrap();
390        assert_eq!(pos.azimuth(), 270.0);
391        assert_eq!(pos.elevation_angle(), 0.0);
392
393        // Test validation
394        assert!(SolarPosition::new(0.0, -1.0).is_err());
395        assert!(SolarPosition::new(0.0, 181.0).is_err());
396    }
397
398    #[test]
399    fn test_solar_position_sun_state() {
400        let above_horizon = SolarPosition::new(180.0, 30.0).unwrap();
401        assert!(above_horizon.is_sun_up());
402        assert!(!above_horizon.is_sun_down());
403
404        let on_horizon = SolarPosition::new(180.0, 90.0).unwrap();
405        assert!(!on_horizon.is_sun_up());
406        assert!(on_horizon.is_sun_down());
407
408        let below_horizon = SolarPosition::new(180.0, 120.0).unwrap();
409        assert!(!below_horizon.is_sun_up());
410        assert!(below_horizon.is_sun_down());
411    }
412
413    #[test]
414    fn test_sunrise_result_regular_day() {
415        use chrono::{DateTime, Utc};
416
417        let sunrise = "2023-06-21T05:30:00Z".parse::<DateTime<Utc>>().unwrap();
418        let transit = "2023-06-21T12:00:00Z".parse::<DateTime<Utc>>().unwrap();
419        let sunset = "2023-06-21T18:30:00Z".parse::<DateTime<Utc>>().unwrap();
420
421        let result = SunriseResult::RegularDay {
422            sunrise,
423            transit,
424            sunset,
425        };
426
427        assert!(result.is_regular_day());
428        assert!(!result.is_polar_day());
429        assert!(!result.is_polar_night());
430        assert_eq!(result.transit(), &transit);
431        assert_eq!(result.sunrise(), Some(&sunrise));
432        assert_eq!(result.sunset(), Some(&sunset));
433    }
434
435    #[test]
436    fn test_sunrise_result_polar_day() {
437        use chrono::{DateTime, Utc};
438
439        let transit = "2023-06-21T12:00:00Z".parse::<DateTime<Utc>>().unwrap();
440        let result = SunriseResult::AllDay { transit };
441
442        assert!(!result.is_regular_day());
443        assert!(result.is_polar_day());
444        assert!(!result.is_polar_night());
445        assert_eq!(result.transit(), &transit);
446        assert_eq!(result.sunrise(), None);
447        assert_eq!(result.sunset(), None);
448    }
449
450    #[test]
451    fn test_sunrise_result_polar_night() {
452        use chrono::{DateTime, Utc};
453
454        let transit = "2023-12-21T12:00:00Z".parse::<DateTime<Utc>>().unwrap();
455        let result = SunriseResult::AllNight { transit };
456
457        assert!(!result.is_regular_day());
458        assert!(!result.is_polar_day());
459        assert!(result.is_polar_night());
460        assert_eq!(result.transit(), &transit);
461        assert_eq!(result.sunrise(), None);
462        assert_eq!(result.sunset(), None);
463    }
464
465    #[test]
466    fn test_refraction_correction() {
467        // Test standard conditions
468        let standard = RefractionCorrection::standard();
469        assert_eq!(standard.pressure(), 1013.25);
470        assert_eq!(standard.temperature(), 15.0);
471
472        // Test custom conditions
473        let custom = RefractionCorrection::new(1000.0, 20.0).unwrap();
474        assert_eq!(custom.pressure(), 1000.0);
475        assert_eq!(custom.temperature(), 20.0);
476
477        // Test validation
478        assert!(RefractionCorrection::new(-1.0, 15.0).is_err()); // Invalid pressure
479        assert!(RefractionCorrection::new(1013.25, -300.0).is_err()); // Invalid temperature
480        assert!(RefractionCorrection::new(3000.0, 15.0).is_err()); // Too high pressure
481        assert!(RefractionCorrection::new(1013.25, 150.0).is_err()); // Too high temperature
482    }
483}