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