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}