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