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