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}