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}