solar_positioning/
grena3.rs

1//! Grena3 solar position algorithm implementation.
2//!
3//! This follows the no. 3 algorithm described in Grena, 'Five new algorithms for the computation
4//! of sun position from 2010 to 2110', Solar Energy 86 (2012) pp. 1323-1337.
5//!
6//! The algorithm is designed for the years 2010 to 2110, with a maximum error of 0.01 degrees.
7//! It's approximately 10x faster than the SPA algorithm but with reduced accuracy and time range.
8
9#![allow(clippy::unreadable_literal)]
10#![allow(clippy::many_single_char_names)]
11#![allow(clippy::suboptimal_flops)]
12
13use crate::error::{
14    check_coordinates, check_pressure, check_refraction_params_usable, check_temperature,
15};
16use crate::math::{
17    PI, asin, atan2, cos, degrees_to_radians, normalize_degrees_0_to_360, radians_to_degrees, sin,
18    sqrt, tan,
19};
20use crate::{Result, SolarPosition};
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///
34/// # Returns
35/// Solar position or error
36///
37/// # Errors
38/// Returns error for invalid coordinates (latitude outside ±90°, longitude outside ±180°)
39///
40/// # Example
41/// ```rust
42/// use solar_positioning::grena3;
43/// use chrono::{DateTime, FixedOffset};
44///
45/// let datetime = "2023-06-21T12:00:00-07:00".parse::<DateTime<FixedOffset>>().unwrap();
46/// let position = grena3::solar_position(
47///     datetime,
48///     37.7749,     // San Francisco latitude
49///     -122.4194,   // San Francisco longitude
50///     69.0,        // deltaT (seconds)
51/// ).unwrap();
52///
53/// println!("Azimuth: {:.3}°", position.azimuth());
54/// println!("Elevation: {:.3}°", position.elevation_angle());
55/// ```
56pub fn solar_position<Tz: TimeZone>(
57    datetime: DateTime<Tz>,
58    latitude: f64,
59    longitude: f64,
60    delta_t: f64,
61) -> Result<SolarPosition> {
62    solar_position_with_refraction(datetime, latitude, longitude, delta_t, None, None)
63}
64
65/// Calculate solar position using the Grena3 algorithm with optional refraction correction.
66///
67/// # Arguments
68/// * `datetime` - Timezone-aware date and time
69/// * `latitude` - Observer latitude in degrees (-90 to +90)
70/// * `longitude` - Observer longitude in degrees (-180 to +180)
71/// * `delta_t` - ΔT in seconds (difference between TT and UT1)
72/// * `pressure` - Optional atmospheric pressure in millibars (for refraction correction)
73/// * `temperature` - Optional temperature in °C (for refraction correction)
74///
75/// # Returns
76/// Solar position or error
77///
78/// # Errors
79/// Returns error for invalid coordinates (latitude outside ±90°, longitude outside ±180°)
80pub fn solar_position_with_refraction<Tz: TimeZone>(
81    datetime: DateTime<Tz>,
82    latitude: f64,
83    longitude: f64,
84    delta_t: f64,
85    pressure: Option<f64>,
86    temperature: Option<f64>,
87) -> Result<SolarPosition> {
88    check_coordinates(latitude, longitude)?;
89
90    // Validate optional atmospheric parameters if provided
91    if let Some(p) = pressure {
92        check_pressure(p)?;
93    }
94    if let Some(t) = temperature {
95        check_temperature(t)?;
96    }
97
98    // Calculate t (days since 2000-01-01 12:00:00 TT)
99    let t = calc_t(&datetime);
100    let t_e = t + 1.1574e-5 * delta_t;
101    let omega_at_e = 0.0172019715 * t_e;
102
103    // Calculate apparent sun longitude (lambda)
104    let lambda = -1.388803
105        + 1.720279216e-2 * t_e
106        + 3.3366e-2 * sin(omega_at_e - 0.06172)
107        + 3.53e-4 * sin(2.0 * omega_at_e - 0.1163);
108
109    // Calculate obliquity of ecliptic (epsilon)
110    let epsilon = 4.089567e-1 - 6.19e-9 * t_e;
111
112    let s_lambda = sin(lambda);
113    let c_lambda = cos(lambda);
114    let s_epsilon = sin(epsilon);
115    let c_epsilon = sqrt(1.0 - s_epsilon * s_epsilon);
116
117    // Calculate right ascension (alpha)
118    let mut alpha = atan2(s_lambda * c_epsilon, c_lambda);
119    if alpha < 0.0 {
120        alpha += 2.0 * PI;
121    }
122
123    // Calculate declination (delta)
124    let delta = asin(s_lambda * s_epsilon);
125
126    // Calculate hour angle (H)
127    let mut h = 1.7528311 + 6.300388099 * t + degrees_to_radians(longitude) - alpha;
128    h = ((h + PI) % (2.0 * PI)) - PI;
129    if h < -PI {
130        h += 2.0 * PI;
131    }
132
133    // Calculate topocentric coordinates
134    let s_phi = sin(degrees_to_radians(latitude));
135    let c_phi = sqrt(1.0 - s_phi * s_phi);
136    let s_delta = sin(delta);
137    let c_delta = sqrt(1.0 - s_delta * s_delta);
138    let s_h = sin(h);
139    let c_h = cos(h);
140
141    let s_epsilon0 = s_phi * s_delta + c_phi * c_delta * c_h;
142    let e_p = asin(s_epsilon0) - 4.26e-5 * sqrt(1.0 - s_epsilon0 * s_epsilon0);
143    let gamma = atan2(s_h, c_h * s_phi - s_delta * c_phi / c_delta);
144
145    // Apply refraction correction if parameters are usable
146    let delta_re = if let (Some(p), Some(t)) = (pressure, temperature) {
147        if check_refraction_params_usable(p, t) && e_p > 0.0 {
148            (0.08422 * (p / 1000.0)) / ((273.0 + t) * tan(e_p + 0.003138 / (e_p + 0.08919)))
149        } else {
150            0.0
151        }
152    } else {
153        0.0
154    };
155
156    let z = PI / 2.0 - e_p - delta_re;
157
158    let azimuth = normalize_degrees_0_to_360(radians_to_degrees(gamma + PI));
159    let zenith = radians_to_degrees(z);
160
161    SolarPosition::new(azimuth, zenith)
162}
163
164/// Calculate t parameter (days since 2000-01-01 12:00:00 TT)
165fn calc_t<Tz: TimeZone>(datetime: &DateTime<Tz>) -> f64 {
166    // Convert to UTC for proper astronomical calculations
167    let utc_datetime = datetime.with_timezone(&chrono::Utc);
168    let mut m = i32::try_from(utc_datetime.month()).expect("month should fit in i32");
169    let mut y = utc_datetime.year();
170    let d = i32::try_from(utc_datetime.day()).expect("day should fit in i32");
171    let h = f64::from(utc_datetime.hour())
172        + f64::from(utc_datetime.minute()) / 60.0
173        + f64::from(utc_datetime.second()) / 3600.0;
174
175    if m <= 2 {
176        m += 12;
177        y -= 1;
178    }
179
180    f64::from((365.25 * f64::from(y - 2000)) as i32)
181        + f64::from((30.6001 * f64::from(m + 1)) as i32)
182        - f64::from((0.01 * f64::from(y)) as i32)
183        + f64::from(d)
184        + 0.0416667 * h
185        - 21958.0
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191    use chrono::{DateTime, FixedOffset};
192
193    #[test]
194    fn test_grena3_basic_functionality() {
195        let datetime = "2023-06-21T12:00:00-07:00"
196            .parse::<DateTime<FixedOffset>>()
197            .unwrap();
198
199        let result = solar_position(datetime, 37.7749, -122.4194, 69.0);
200
201        assert!(result.is_ok());
202        let position = result.unwrap();
203        assert!(position.azimuth() >= 0.0 && position.azimuth() <= 360.0);
204        assert!(position.zenith_angle() >= 0.0 && position.zenith_angle() <= 180.0);
205    }
206
207    #[test]
208    fn test_grena3_with_refraction() {
209        let datetime = "2023-06-21T12:00:00-07:00"
210            .parse::<DateTime<FixedOffset>>()
211            .unwrap();
212
213        let result = solar_position_with_refraction(
214            datetime,
215            37.7749,
216            -122.4194,
217            69.0,
218            Some(1013.25),
219            Some(15.0),
220        );
221
222        assert!(result.is_ok());
223        let position = result.unwrap();
224        assert!(position.azimuth() >= 0.0 && position.azimuth() <= 360.0);
225        assert!(position.zenith_angle() >= 0.0 && position.zenith_angle() <= 180.0);
226    }
227
228    #[test]
229    fn test_grena3_coordinate_validation() {
230        let datetime = "2023-06-21T12:00:00-07:00"
231            .parse::<DateTime<FixedOffset>>()
232            .unwrap();
233
234        // Invalid latitude
235        assert!(solar_position(datetime, 95.0, 0.0, 0.0).is_err());
236
237        // Invalid longitude
238        assert!(solar_position(datetime, 0.0, 185.0, 0.0).is_err());
239    }
240
241    #[test]
242    fn test_calc_t() {
243        // Test with a known date
244        let datetime = "2023-06-21T12:00:00-07:00"
245            .parse::<DateTime<FixedOffset>>()
246            .unwrap();
247        let t = calc_t(&datetime);
248
249        // The Grena3 algorithm uses a specific reference point that may result in negative values
250        // This is correct behavior - just ensure the calculation is consistent
251        assert!(t.is_finite(), "t should be finite");
252
253        // Test that the calculation is stable
254        let t2 = calc_t(&datetime);
255        assert!(
256            (t - t2).abs() < f64::EPSILON,
257            "calc_t should be deterministic"
258        );
259
260        // Test that different dates give different results
261        let datetime2 = "2023-06-22T12:00:00Z"
262            .parse::<DateTime<FixedOffset>>()
263            .unwrap();
264        let t3 = calc_t(&datetime2);
265        assert!(
266            (t - t3).abs() > 0.5,
267            "Different dates should give different t values"
268        );
269    }
270}