Skip to main content

pvlib/
solarposition.rs

1use chrono::{DateTime, Utc};
2use chrono_tz::Tz;
3use spa::{solar_position, SpaError, StdFloatOps};
4use crate::location::Location;
5
6use std::f64::consts::PI;
7
8/// Output of the solar position calculation.
9#[derive(Debug, Clone, PartialEq)]
10pub struct SolarPosition {
11    /// True zenith angle in degrees
12    pub zenith: f64,
13    /// True azimuth angle in degrees (North = 0, East = 90)
14    pub azimuth: f64,
15    /// True elevation angle in degrees
16    pub elevation: f64,
17}
18
19/// Calculate the solar position for a given location and time.
20/// This uses the NREL SPA (Solar Position Algorithm).
21#[inline]
22pub fn get_solarposition(location: &Location, time: DateTime<Tz>) -> Result<SolarPosition, SpaError> {
23    let utc_time: DateTime<Utc> = time.with_timezone(&Utc);
24
25    let result = solar_position::<StdFloatOps>(utc_time, location.latitude, location.longitude)?;
26
27    Ok(SolarPosition {
28        zenith: result.zenith_angle,
29        azimuth: result.azimuth,
30        elevation: 90.0 - result.zenith_angle,
31    })
32}
33
34// --- Helper ---
35
36/// Calculates the day angle for the Earth's orbit around the Sun.
37/// For the Spencer method, offset=1.
38fn calculate_simple_day_angle(day_of_year: f64, offset: f64) -> f64 {
39    (2.0 * PI / 365.0) * (day_of_year - offset)
40}
41
42// --- Equation of Time ---
43
44/// Equation of time in minutes using Spencer (1971) / Iqbal (1983) Fourier series.
45///
46/// Returns the difference between solar time and mean solar time in minutes.
47#[inline]
48pub fn equation_of_time_spencer71(day_of_year: f64) -> f64 {
49    let day_angle = calculate_simple_day_angle(day_of_year, 1.0);
50    (1440.0 / (2.0 * PI))
51        * (0.0000075
52            + 0.001868 * day_angle.cos()
53            - 0.032077 * day_angle.sin()
54            - 0.014615 * (2.0 * day_angle).cos()
55            - 0.040849 * (2.0 * day_angle).sin())
56}
57
58/// Equation of time in minutes from PVCDROM / PV Education.
59///
60/// Returns the difference between solar time and mean solar time in minutes.
61#[inline]
62pub fn equation_of_time_pvcdrom(day_of_year: f64) -> f64 {
63    let bday = calculate_simple_day_angle(day_of_year, 1.0) - (2.0 * PI / 365.0) * 80.0;
64    9.87 * (2.0 * bday).sin() - 7.53 * bday.cos() - 1.5 * bday.sin()
65}
66
67// --- Declination ---
68
69/// Solar declination in radians using Spencer (1971) Fourier series.
70///
71/// Returns the angular position of the sun at solar noon relative to
72/// the plane of the equator, approximately +/-23.45 degrees.
73#[inline]
74pub fn declination_spencer71(day_of_year: f64) -> f64 {
75    let day_angle = calculate_simple_day_angle(day_of_year, 1.0);
76    0.006918
77        - 0.399912 * day_angle.cos()
78        + 0.070257 * day_angle.sin()
79        - 0.006758 * (2.0 * day_angle).cos()
80        + 0.000907 * (2.0 * day_angle).sin()
81        - 0.002697 * (3.0 * day_angle).cos()
82        + 0.00148 * (3.0 * day_angle).sin()
83}
84
85/// Solar declination in radians using Cooper (1969).
86///
87/// delta = 23.45 * sin(2*pi*(284+doy)/365) converted to radians.
88#[inline]
89pub fn declination_cooper69(day_of_year: f64) -> f64 {
90    let day_angle = calculate_simple_day_angle(day_of_year, 1.0);
91    (23.45_f64).to_radians() * (day_angle + (2.0 * PI / 365.0) * 285.0).sin()
92}
93
94// --- Hour Angle ---
95
96/// Hour angle in degrees.
97///
98/// `hours_from_midnight_utc` is fractional hours since midnight UTC.
99/// `longitude` is in degrees. `equation_of_time` is in minutes.
100///
101/// HA = 15 * (hours - 12) + longitude + eot/4
102#[inline]
103pub fn hour_angle(hours_from_midnight_utc: f64, longitude: f64, equation_of_time: f64) -> f64 {
104    15.0 * (hours_from_midnight_utc - 12.0) + longitude + equation_of_time / 4.0
105}
106
107// --- Zenith and Azimuth (analytical, all inputs in radians) ---
108
109/// Solar zenith angle in radians from analytical spherical trigonometry.
110///
111/// All inputs are in radians.
112#[inline]
113pub fn solar_zenith_analytical(latitude: f64, hour_angle: f64, declination: f64) -> f64 {
114    let cos_zenith = declination.cos() * latitude.cos() * hour_angle.cos()
115        + declination.sin() * latitude.sin();
116    // clamp to [-1, 1] before acos to avoid NaN from floating point errors
117    cos_zenith.clamp(-1.0, 1.0).acos()
118}
119
120/// Solar azimuth angle in radians from analytical spherical trigonometry.
121///
122/// All inputs are in radians. Returns azimuth measured from north (0) clockwise.
123#[inline]
124pub fn solar_azimuth_analytical(
125    latitude: f64,
126    hour_angle: f64,
127    declination: f64,
128    zenith: f64,
129) -> f64 {
130    let numer = zenith.cos() * latitude.sin() - declination.sin();
131    let denom = zenith.sin() * latitude.cos();
132
133    let mut cos_azi = if denom.abs() < 1e-8 {
134        1.0
135    } else {
136        numer / denom
137    };
138
139    // clamp to [-1, 1]
140    if (cos_azi - 1.0).abs() < 1e-8 {
141        cos_azi = 1.0;
142    }
143    if (cos_azi + 1.0).abs() < 1e-8 {
144        cos_azi = -1.0;
145    }
146    cos_azi = cos_azi.clamp(-1.0, 1.0);
147
148    let sign_ha = if hour_angle > 0.0 {
149        1.0
150    } else if hour_angle < 0.0 {
151        -1.0
152    } else {
153        0.0
154    };
155
156    sign_ha * cos_azi.acos() + PI
157}
158
159// --- Sunrise, Sunset, Transit ---
160
161/// Result of geometric sunrise/sunset/transit calculation.
162#[derive(Debug, Clone, PartialEq)]
163pub struct SunRiseSetTransit {
164    /// Sunrise as fractional hours from midnight (local time)
165    pub sunrise: f64,
166    /// Sunset as fractional hours from midnight (local time)
167    pub sunset: f64,
168    /// Solar transit (noon) as fractional hours from midnight (local time)
169    pub transit: f64,
170}
171
172/// Geometric calculation of sunrise, sunset, and transit times.
173///
174/// * `latitude` - degrees, positive north
175/// * `longitude` - degrees, positive east
176/// * `declination` - radians
177/// * `equation_of_time` - minutes
178/// * `utc_offset_hours` - timezone offset from UTC in hours (e.g. -5 for EST)
179///
180/// Returns sunrise, sunset, and transit as fractional hours from local midnight.
181/// Returns `None` if the sun never rises or never sets (polar day/night).
182#[inline]
183pub fn sun_rise_set_transit_geometric(
184    latitude: f64,
185    longitude: f64,
186    declination: f64,
187    equation_of_time: f64,
188    utc_offset_hours: f64,
189) -> Option<SunRiseSetTransit> {
190    let latitude_rad = latitude.to_radians();
191    let tan_product = -declination.tan() * latitude_rad.tan();
192
193    // If |tan_product| > 1, the sun never rises or never sets
194    if tan_product.abs() > 1.0 {
195        return None;
196    }
197
198    let sunset_angle = tan_product.acos().to_degrees(); // degrees
199    let sunrise_angle = -sunset_angle;
200
201    // Convert hour angles to hours from local midnight
202    let sunrise_hour =
203        (sunrise_angle - longitude - equation_of_time / 4.0) / 15.0 + 12.0 + utc_offset_hours;
204    let sunset_hour =
205        (sunset_angle - longitude - equation_of_time / 4.0) / 15.0 + 12.0 + utc_offset_hours;
206    let transit_hour =
207        (0.0 - longitude - equation_of_time / 4.0) / 15.0 + 12.0 + utc_offset_hours;
208
209    Some(SunRiseSetTransit {
210        sunrise: sunrise_hour,
211        sunset: sunset_hour,
212        transit: transit_hour,
213    })
214}
215
216// --- Earth-Sun Distance ---
217
218/// Earth-sun distance in AU using Spencer (1971).
219///
220/// Uses the Fourier series for the reciprocal squared of the earth-sun distance.
221#[inline]
222pub fn nrel_earthsun_distance(day_of_year: f64) -> f64 {
223    let day_angle = calculate_simple_day_angle(day_of_year, 1.0);
224    let r_over_r0_squared = 1.000110
225        + 0.034221 * day_angle.cos()
226        + 0.001280 * day_angle.sin()
227        + 0.000719 * (2.0 * day_angle).cos()
228        + 0.000077 * (2.0 * day_angle).sin();
229    // This gives 1/r^2 in AU, so distance = 1/sqrt(value)
230    1.0 / r_over_r0_squared.sqrt()
231}