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