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