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