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