Skip to main content

solar_positioning/
grena3.rs

1//! Grena3 algorithm implementation.
2//!
3//! Implements algorithm #3 from Grena (2012).
4//!
5//! Designed for 2010-2110 with 0.01° accuracy. ~10x faster than SPA.
6//!
7//! Reference: Grena, R. (2012). Five new algorithms for the computation of sun position from 2010 to 2110.
8//! Solar Energy, 86(5), 1323-1337. DOI: <http://dx.doi.org/10.1016/j.solener.2012.01.024>
9
10#![allow(clippy::unreadable_literal)]
11#![allow(clippy::many_single_char_names)]
12#![allow(clippy::suboptimal_flops)]
13
14use crate::error::check_coordinates;
15use crate::math::{
16    asin, atan2, cos, degrees_to_radians, floor, normalize_degrees_0_to_360, radians_to_degrees,
17    rem_euclid, sin, sqrt, tan, PI,
18};
19use crate::time::validate_utc_components;
20use crate::{RefractionCorrection, Result, SolarPosition};
21#[cfg(feature = "chrono")]
22use chrono::{DateTime, Datelike, TimeZone, Timelike};
23
24/// Calculate solar position using the Grena3 algorithm.
25///
26/// This is a simplified algorithm designed for years 2010-2110 with maximum error of 0.01°.
27/// It's much faster than SPA but less accurate and has a limited time range.
28///
29/// # Arguments
30/// * `datetime` - Timezone-aware date and time
31/// * `latitude` - Observer latitude in degrees (-90° to +90°)
32/// * `longitude` - Observer longitude in degrees (-180° to +180°)
33/// * `delta_t` - ΔT in seconds (difference between TT and UT1)
34/// * `refraction` - Optional atmospheric refraction correction
35///
36/// # Returns
37/// Returns `Ok(SolarPosition)` with azimuth and zenith angles on success.
38///
39/// # Errors
40/// Returns error for invalid coordinates (latitude outside ±90°, longitude outside ±180°).
41///
42/// # Panics
43/// This function does not panic.
44///
45/// # Example
46/// ```rust
47/// use solar_positioning::{grena3, RefractionCorrection};
48/// use chrono::{DateTime, FixedOffset};
49///
50/// let datetime = "2023-06-21T12:00:00-07:00".parse::<DateTime<FixedOffset>>().unwrap();
51///
52/// // Without refraction correction
53/// let position = grena3::solar_position(
54///     datetime,
55///     37.7749,     // San Francisco latitude
56///     -122.4194,   // San Francisco longitude
57///     69.0,        // deltaT (seconds)
58///     None,        // no atmospheric refraction correction
59/// ).unwrap();
60///
61/// // With refraction correction
62/// let position_with_refraction = grena3::solar_position(
63///     datetime,
64///     37.7749,     // San Francisco latitude
65///     -122.4194,   // San Francisco longitude
66///     69.0,        // deltaT (seconds)
67///     Some(RefractionCorrection::standard()), // standard atmospheric conditions
68/// ).unwrap();
69///
70/// println!("Azimuth: {:.3}°", position.azimuth());
71/// println!("Elevation: {:.3}°", position.elevation_angle());
72/// ```
73#[cfg(feature = "chrono")]
74#[cfg_attr(docsrs, doc(cfg(feature = "chrono")))]
75#[allow(clippy::needless_pass_by_value)]
76pub fn solar_position<Tz: TimeZone>(
77    datetime: DateTime<Tz>,
78    latitude: f64,
79    longitude: f64,
80    delta_t: f64,
81    refraction: Option<RefractionCorrection>,
82) -> Result<SolarPosition> {
83    // Calculate t (days since 2000-01-01 12:00:00 TT)
84    let t = calc_t(&datetime)?;
85    solar_position_from_t(t, latitude, longitude, delta_t, refraction)
86}
87
88/// Calculate solar position from time parameter t.
89///
90/// Core implementation for `no_std` compatibility.
91///
92/// # Arguments
93/// * `t` - Days since 2000-01-01 12:00:00 UT
94/// * `latitude` - Observer latitude in degrees (-90° to +90°)
95/// * `longitude` - Observer longitude in degrees (-180° to +180°)
96/// * `delta_t` - ΔT in seconds (difference between TT and UT1)
97/// * `refraction` - Optional atmospheric refraction correction
98///
99/// # Returns
100/// Returns `Ok(SolarPosition)` with azimuth and zenith angles on success.
101///
102/// # Errors
103/// Returns error for invalid coordinates.
104pub fn solar_position_from_t(
105    t: f64,
106    latitude: f64,
107    longitude: f64,
108    delta_t: f64,
109    refraction: Option<RefractionCorrection>,
110) -> Result<SolarPosition> {
111    check_coordinates(latitude, longitude)?;
112
113    let t_e = t + 1.1574e-5 * delta_t;
114    let omega_at_e = 0.0172019715 * t_e;
115
116    // Calculate apparent sun longitude (lambda)
117    let lambda = -1.388803
118        + 1.720279216e-2 * t_e
119        + 3.3366e-2 * sin(omega_at_e - 0.06172)
120        + 3.53e-4 * sin(2.0 * omega_at_e - 0.1163);
121
122    // Calculate obliquity of ecliptic (epsilon)
123    let epsilon = 4.089567e-1 - 6.19e-9 * t_e;
124
125    let s_lambda = sin(lambda);
126    let c_lambda = cos(lambda);
127    let s_epsilon = sin(epsilon);
128    let c_epsilon = sqrt(1.0 - s_epsilon * s_epsilon);
129
130    // Calculate right ascension (alpha)
131    let alpha = rem_euclid(atan2(s_lambda * c_epsilon, c_lambda), 2.0 * PI);
132
133    // Calculate declination (delta)
134    let delta = asin(s_lambda * s_epsilon);
135
136    // Calculate hour angle (H)
137    let h = rem_euclid(
138        1.7528311 + 6.300388099 * t + degrees_to_radians(longitude) - alpha + PI,
139        2.0 * PI,
140    ) - PI;
141
142    // Calculate topocentric coordinates
143    let s_phi = sin(degrees_to_radians(latitude));
144    let c_phi = sqrt(1.0 - s_phi * s_phi);
145    let s_delta = sin(delta);
146    let c_delta = sqrt(1.0 - s_delta * s_delta);
147    let s_h = sin(h);
148    let c_h = cos(h);
149
150    let s_epsilon0 = s_phi * s_delta + c_phi * c_delta * c_h;
151    let e_p = asin(s_epsilon0) - 4.26e-5 * sqrt(1.0 - s_epsilon0 * s_epsilon0);
152    let gamma = atan2(s_h, c_h * s_phi - s_delta * c_phi / c_delta);
153
154    // Apply refraction correction if provided and sun is visible
155    let delta_re = refraction.map_or(0.0, |correction| {
156        if e_p > 0.0 {
157            let pressure = correction.pressure();
158            let temperature = correction.temperature();
159            (0.08422 * (pressure / 1000.0))
160                / ((273.0 + temperature) * tan(e_p + 0.003138 / (e_p + 0.08919)))
161        } else {
162            0.0
163        }
164    });
165
166    let z = PI / 2.0 - e_p - delta_re;
167
168    let azimuth = normalize_degrees_0_to_360(radians_to_degrees(gamma + PI));
169    let zenith = radians_to_degrees(z);
170
171    SolarPosition::new(azimuth, zenith)
172}
173
174/// Calculate t parameter from date/time components (days since 2000-01-01 12:00:00 UT).
175///
176/// This is the core calculation that doesn't require chrono.
177///
178/// # Errors
179/// Returns error if date/time components are outside valid ranges or not finite.
180pub fn calc_t_from_components(
181    year: i32,
182    month: u32,
183    day: u32,
184    hour: u32,
185    minute: u32,
186    second: f64,
187) -> Result<f64> {
188    validate_utc_components(year, month, day, hour, minute, second)?;
189
190    let mut m = month;
191    let mut y = year;
192    let h = f64::from(hour) + f64::from(minute) / 60.0 + second / 3600.0;
193
194    if m <= 2 {
195        m += 12;
196        y -= 1;
197    }
198
199    Ok(
200        floor(365.25 * f64::from(y - 2000)) + floor(30.6001 * f64::from(m + 1))
201            - floor(0.01 * f64::from(y))
202            + f64::from(day)
203            + 0.0416667 * h
204            - 21958.0,
205    )
206}
207
208/// Calculate t parameter (days since 2000-01-01 12:00:00 TT)
209#[cfg(feature = "chrono")]
210fn calc_t<Tz: TimeZone>(datetime: &DateTime<Tz>) -> Result<f64> {
211    // Convert to UTC for proper astronomical calculations
212    let utc_datetime = datetime.with_timezone(&chrono::Utc);
213    calc_t_from_components(
214        utc_datetime.year(),
215        utc_datetime.month(),
216        utc_datetime.day(),
217        utc_datetime.hour(),
218        utc_datetime.minute(),
219        f64::from(utc_datetime.second()) + f64::from(utc_datetime.nanosecond()) / 1e9,
220    )
221}
222
223#[cfg(all(test, feature = "chrono"))]
224mod tests {
225    use super::*;
226    use chrono::{DateTime, FixedOffset};
227
228    #[test]
229    fn test_grena3_basic_functionality() {
230        let datetime = "2023-06-21T12:00:00-07:00"
231            .parse::<DateTime<FixedOffset>>()
232            .unwrap();
233
234        let result = solar_position(datetime, 37.7749, -122.4194, 69.0, None);
235
236        assert!(result.is_ok());
237        let position = result.unwrap();
238        assert!(position.azimuth() >= 0.0 && position.azimuth() <= 360.0);
239        assert!(position.zenith_angle() >= 0.0 && position.zenith_angle() <= 180.0);
240    }
241
242    #[test]
243    fn test_grena3_with_refraction() {
244        let datetime = "2023-06-21T12:00:00-07:00"
245            .parse::<DateTime<FixedOffset>>()
246            .unwrap();
247
248        let result = solar_position(
249            datetime,
250            37.7749,
251            -122.4194,
252            69.0,
253            Some(RefractionCorrection::new(1013.25, 15.0).unwrap()),
254        );
255
256        assert!(result.is_ok());
257        let position = result.unwrap();
258        assert!(position.azimuth() >= 0.0 && position.azimuth() <= 360.0);
259        assert!(position.zenith_angle() >= 0.0 && position.zenith_angle() <= 180.0);
260    }
261
262    #[test]
263    fn test_grena3_coordinate_validation() {
264        let datetime = "2023-06-21T12:00:00-07:00"
265            .parse::<DateTime<FixedOffset>>()
266            .unwrap();
267
268        // Invalid latitude
269        assert!(solar_position(datetime, 95.0, 0.0, 0.0, None).is_err());
270
271        // Invalid longitude
272        assert!(solar_position(datetime, 0.0, 185.0, 0.0, None).is_err());
273    }
274
275    #[test]
276    fn test_calc_t() {
277        // Test with a known date
278        let datetime = "2023-06-21T12:00:00-07:00"
279            .parse::<DateTime<FixedOffset>>()
280            .unwrap();
281        let t = calc_t(&datetime).unwrap();
282
283        // The Grena3 algorithm uses a specific reference point that may result in negative values
284        // This is correct behavior - just ensure the calculation is consistent
285        assert!(t.is_finite(), "t should be finite");
286
287        // Test that the calculation is stable
288        let t2 = calc_t(&datetime).unwrap();
289        assert!(
290            (t - t2).abs() < f64::EPSILON,
291            "calc_t should be deterministic"
292        );
293
294        // Test that different dates give different results
295        let datetime2 = "2023-06-22T12:00:00Z"
296            .parse::<DateTime<FixedOffset>>()
297            .unwrap();
298        let t3 = calc_t(&datetime2).unwrap();
299        assert!(
300            (t - t3).abs() > 0.5,
301            "Different dates should give different t values"
302        );
303    }
304
305    #[test]
306    fn test_calc_t_pre_2000_flooring() {
307        let t = calc_t_from_components(1999, 12, 31, 0, 0, 0.0).unwrap();
308        assert_eq!(t, -21915.0);
309    }
310
311    #[test]
312    fn test_calc_t_validation() {
313        assert!(calc_t_from_components(2024, 13, 1, 0, 0, 0.0).is_err());
314        assert!(calc_t_from_components(2024, 1, 32, 0, 0, 0.0).is_err());
315        assert!(calc_t_from_components(2024, 2, 30, 0, 0, 0.0).is_err());
316        assert!(calc_t_from_components(1582, 10, 10, 0, 0, 0.0).is_err());
317        assert!(calc_t_from_components(2024, 1, 1, 24, 0, 0.0).is_err());
318        assert!(calc_t_from_components(2024, 1, 1, 0, 60, 0.0).is_err());
319        assert!(calc_t_from_components(2024, 1, 1, 0, 0, 60.0).is_err());
320    }
321}