Skip to main content

solar_positioning/
types.rs

1//! Core data types for solar positioning calculations.
2
3use crate::error::{
4    check_azimuth, check_elevation_angle, check_pressure, check_temperature, check_zenith_angle,
5};
6use crate::math::floor;
7use crate::Result;
8
9/// Predefined elevation angles for sunrise/sunset calculations.
10///
11/// Corresponds to different twilight definitions for consistent sunrise, sunset, and twilight calculations.
12#[derive(Debug, Clone, Copy, PartialEq)]
13pub enum Horizon {
14    /// Standard sunrise/sunset (sun's upper limb touches horizon, accounting for refraction)
15    SunriseSunset,
16    /// Civil twilight (sun is 6° below horizon)
17    CivilTwilight,
18    /// Nautical twilight (sun is 12° below horizon)
19    NauticalTwilight,
20    /// Astronomical twilight (sun is 18° below horizon)
21    AstronomicalTwilight,
22    /// Custom elevation angle
23    Custom(f64),
24}
25
26impl Horizon {
27    /// Gets the elevation angle in degrees for this horizon definition.
28    ///
29    /// Negative values indicate the sun is below the horizon.
30    #[must_use]
31    pub const fn elevation_angle(&self) -> f64 {
32        match self {
33            Self::SunriseSunset => -0.83337, // Accounts for refraction and sun's radius
34            Self::CivilTwilight => -6.0,
35            Self::NauticalTwilight => -12.0,
36            Self::AstronomicalTwilight => -18.0,
37            Self::Custom(angle) => *angle,
38        }
39    }
40
41    /// Creates a custom horizon with the specified elevation angle.
42    ///
43    /// # Errors
44    /// Returns `InvalidElevationAngle` if elevation is not finite or outside -90 to +90 degrees.
45    pub fn custom(elevation_degrees: f64) -> Result<Self> {
46        check_elevation_angle(elevation_degrees)?;
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 day_offset_raw = floor(hours / 24.0);
283        let normalized_hours = {
284            let h = hours % 24.0;
285            if h < 0.0 {
286                h + 24.0
287            } else {
288                h
289            }
290        };
291        let day_offset = day_offset_raw.clamp(f64::from(i32::MIN), f64::from(i32::MAX)) as i32;
292
293        (day_offset, normalized_hours)
294    }
295}
296
297/// Result of sunrise/sunset calculations for a given day.
298///
299/// Solar events can vary significantly based on location and time of year,
300/// especially at extreme latitudes where polar days and nights occur.
301#[derive(Debug, Clone, PartialEq, Eq)]
302#[cfg_attr(
303    feature = "std",
304    doc = "Default generic parameter is `()`; chrono helpers return `SunriseResult<chrono::DateTime<Tz>>`."
305)]
306pub enum SunriseResult<T = ()> {
307    /// Regular day with distinct sunrise, transit (noon), and sunset times
308    RegularDay {
309        /// Time of sunrise
310        sunrise: T,
311        /// Time of solar transit (when sun crosses meridian, solar noon)
312        transit: T,
313        /// Time of sunset
314        sunset: T,
315    },
316    /// Polar day - sun remains above the specified horizon all day
317    AllDay {
318        /// Time of solar transit (closest approach to zenith)
319        transit: T,
320    },
321    /// Polar night - sun remains below the specified horizon all day
322    AllNight {
323        /// Time of solar transit (when sun is highest, though still below horizon)
324        transit: T,
325    },
326}
327
328impl<T> SunriseResult<T> {
329    /// Gets the transit time (solar noon) for any sunrise result.
330    pub const fn transit(&self) -> &T {
331        match self {
332            Self::RegularDay { transit, .. }
333            | Self::AllDay { transit }
334            | Self::AllNight { transit } => transit,
335        }
336    }
337
338    /// Checks if this represents a regular day with sunrise and sunset.
339    pub const fn is_regular_day(&self) -> bool {
340        matches!(self, Self::RegularDay { .. })
341    }
342
343    /// Checks if this represents a polar day (sun never sets).
344    pub const fn is_polar_day(&self) -> bool {
345        matches!(self, Self::AllDay { .. })
346    }
347
348    /// Checks if this represents a polar night (sun never rises).
349    pub const fn is_polar_night(&self) -> bool {
350        matches!(self, Self::AllNight { .. })
351    }
352
353    /// Gets sunrise time if this is a regular day.
354    pub const fn sunrise(&self) -> Option<&T> {
355        if let Self::RegularDay { sunrise, .. } = self {
356            Some(sunrise)
357        } else {
358            None
359        }
360    }
361
362    /// Gets sunset time if this is a regular day.
363    pub const fn sunset(&self) -> Option<&T> {
364        if let Self::RegularDay { sunset, .. } = self {
365            Some(sunset)
366        } else {
367            None
368        }
369    }
370}
371
372#[cfg(test)]
373mod tests {
374    use super::*;
375
376    #[test]
377    fn test_horizon_elevation_angles() {
378        assert_eq!(Horizon::SunriseSunset.elevation_angle(), -0.83337);
379        assert_eq!(Horizon::CivilTwilight.elevation_angle(), -6.0);
380        assert_eq!(Horizon::NauticalTwilight.elevation_angle(), -12.0);
381        assert_eq!(Horizon::AstronomicalTwilight.elevation_angle(), -18.0);
382
383        let custom = Horizon::custom(-3.0).unwrap();
384        assert_eq!(custom.elevation_angle(), -3.0);
385
386        assert!(Horizon::custom(-95.0).is_err());
387        assert!(Horizon::custom(95.0).is_err());
388    }
389
390    #[test]
391    #[cfg(feature = "std")]
392    fn test_horizon_hash_normalizes_zero_sign() {
393        use std::collections::HashSet;
394
395        let mut set = HashSet::new();
396        set.insert(Horizon::Custom(0.0));
397        set.insert(Horizon::Custom(-0.0));
398
399        assert_eq!(set.len(), 1, "hashing should treat +0.0 and -0.0 equally");
400    }
401
402    #[test]
403    fn test_solar_position_creation() {
404        let pos = SolarPosition::new(180.0, 45.0).unwrap();
405        assert_eq!(pos.azimuth(), 180.0);
406        assert_eq!(pos.zenith_angle(), 45.0);
407        assert_eq!(pos.elevation_angle(), 45.0);
408        assert!(pos.is_sun_up());
409        assert!(!pos.is_sun_down());
410
411        // Test normalization
412        let pos = SolarPosition::new(-90.0, 90.0).unwrap();
413        assert_eq!(pos.azimuth(), 270.0);
414        assert_eq!(pos.elevation_angle(), 0.0);
415
416        // Test validation
417        assert!(SolarPosition::new(0.0, -1.0).is_err());
418        assert!(SolarPosition::new(0.0, 181.0).is_err());
419    }
420
421    #[test]
422    fn test_solar_position_sun_state() {
423        let above_horizon = SolarPosition::new(180.0, 30.0).unwrap();
424        assert!(above_horizon.is_sun_up());
425        assert!(!above_horizon.is_sun_down());
426
427        let on_horizon = SolarPosition::new(180.0, 90.0).unwrap();
428        assert!(!on_horizon.is_sun_up());
429        assert!(on_horizon.is_sun_down());
430
431        let below_horizon = SolarPosition::new(180.0, 120.0).unwrap();
432        assert!(!below_horizon.is_sun_up());
433        assert!(below_horizon.is_sun_down());
434    }
435
436    #[test]
437    fn test_sunrise_result_regular_day() {
438        use chrono::{DateTime, Utc};
439
440        let sunrise = "2023-06-21T05:30:00Z".parse::<DateTime<Utc>>().unwrap();
441        let transit = "2023-06-21T12:00:00Z".parse::<DateTime<Utc>>().unwrap();
442        let sunset = "2023-06-21T18:30:00Z".parse::<DateTime<Utc>>().unwrap();
443
444        let result = SunriseResult::RegularDay {
445            sunrise,
446            transit,
447            sunset,
448        };
449
450        assert!(result.is_regular_day());
451        assert!(!result.is_polar_day());
452        assert!(!result.is_polar_night());
453        assert_eq!(result.transit(), &transit);
454        assert_eq!(result.sunrise(), Some(&sunrise));
455        assert_eq!(result.sunset(), Some(&sunset));
456    }
457
458    #[test]
459    fn test_sunrise_result_polar_day() {
460        use chrono::{DateTime, Utc};
461
462        let transit = "2023-06-21T12:00:00Z".parse::<DateTime<Utc>>().unwrap();
463        let result = SunriseResult::AllDay { transit };
464
465        assert!(!result.is_regular_day());
466        assert!(result.is_polar_day());
467        assert!(!result.is_polar_night());
468        assert_eq!(result.transit(), &transit);
469        assert_eq!(result.sunrise(), None);
470        assert_eq!(result.sunset(), None);
471    }
472
473    #[test]
474    fn test_sunrise_result_polar_night() {
475        use chrono::{DateTime, Utc};
476
477        let transit = "2023-12-21T12:00:00Z".parse::<DateTime<Utc>>().unwrap();
478        let result = SunriseResult::AllNight { transit };
479
480        assert!(!result.is_regular_day());
481        assert!(!result.is_polar_day());
482        assert!(result.is_polar_night());
483        assert_eq!(result.transit(), &transit);
484        assert_eq!(result.sunrise(), None);
485        assert_eq!(result.sunset(), None);
486    }
487
488    #[test]
489    fn test_refraction_correction() {
490        // Test standard conditions
491        let standard = RefractionCorrection::standard();
492        assert_eq!(standard.pressure(), 1013.25);
493        assert_eq!(standard.temperature(), 15.0);
494
495        // Test custom conditions
496        let custom = RefractionCorrection::new(1000.0, 20.0).unwrap();
497        assert_eq!(custom.pressure(), 1000.0);
498        assert_eq!(custom.temperature(), 20.0);
499
500        // Test validation
501        assert!(RefractionCorrection::new(-1.0, 15.0).is_err()); // Invalid pressure
502        assert!(RefractionCorrection::new(1013.25, -300.0).is_err()); // Invalid temperature
503        assert!(RefractionCorrection::new(3000.0, 15.0).is_err()); // Too high pressure
504        assert!(RefractionCorrection::new(1013.25, 150.0).is_err()); // Too high temperature
505    }
506}