Skip to main content

pvlib/
irradiance.rs

1use std::f64::consts::PI;
2use crate::atmosphere;
3
4/// Perez et al. (1990) Table 1 — brightness coefficients
5/// Columns: f11, f12, f13, f21, f22, f23
6/// Rows: 8 sky clearness bins (epsilon ranges)
7const PEREZ_COEFFICIENTS: [[f64; 6]; 8] = [
8    [-0.0083117, 0.5877277, -0.0620636, -0.0596012, 0.0721249, -0.0220216],
9    [0.1299457, 0.6825954, -0.1513752, -0.0189325, 0.0659650, -0.0288748],
10    [0.3296958, 0.4868735, -0.2210958, 0.0554140, -0.0639588, -0.0260542],
11    [0.5682053, 0.1874990, -0.2951290, 0.1088631, -0.1519229, -0.0139754],
12    [0.8730280, -0.3920403, -0.3616149, 0.2255647, -0.4620442,  0.0012448],
13    [1.1326077, -1.2367284, -0.4118494, 0.2877813, -0.8230357,  0.0558225],
14    [1.0601591, -1.5999137, -0.3589221, 0.2642124, -1.1272340,  0.1310694],
15    [0.6777470, -0.3272588, -0.2504286, 0.1561313, -1.3765031,  0.2506212],
16];
17
18/// Calculate the angle of incidence (AOI) of the solar vector on a surface.
19pub fn aoi(surface_tilt: f64, surface_azimuth: f64, solar_zenith: f64, solar_azimuth: f64) -> f64 {
20    let tilt_rad = surface_tilt.to_radians();
21    let surf_az_rad = surface_azimuth.to_radians();
22    let zen_rad = solar_zenith.to_radians();
23    let sol_az_rad = solar_azimuth.to_radians();
24
25    let cos_aoi = zen_rad.cos() * tilt_rad.cos()
26        + zen_rad.sin() * tilt_rad.sin() * (sol_az_rad - surf_az_rad).cos();
27    
28    let cos_aoi = cos_aoi.clamp(-1.0, 1.0);
29    cos_aoi.acos().to_degrees()
30}
31
32/// Calculate extraterrestrial solar irradiance for a day of year (Spencer 1971).
33pub fn get_extra_radiation(dayofyear: i32) -> f64 {
34    let b = 2.0 * PI * ((dayofyear - 1) as f64) / 365.0;
35    let rover_r0_sqrd = 1.00011
36        + 0.034221 * b.cos()
37        + 0.00128 * b.sin()
38        + 0.000719 * (2.0 * b).cos()
39        + 0.000077 * (2.0 * b).sin();
40    1366.1 * rover_r0_sqrd
41}
42
43/// Isotropic diffuse model.
44pub fn isotropic(surface_tilt: f64, dhi: f64) -> f64 {
45    dhi * (1.0 + surface_tilt.to_radians().cos()) / 2.0
46}
47
48/// Hay-Davies diffuse sky model.
49/// 
50/// # References
51/// Hay, J.E. and Davies, J.A., 1980, "Calculations of the solar radiation incident on an inclined surface", 
52/// in Proceedings of the First Canadian Solar Radiation Data Workshop.
53pub fn haydavies(surface_tilt: f64, _surface_azimuth: f64, dhi: f64, dni: f64, dni_extra: f64, solar_zenith: f64, _solar_azimuth: f64, aoi_in: f64) -> f64 {
54    let mut a = 0.0;
55    if dni_extra > 0.0 {
56        a = dni / dni_extra;
57    }
58    let a = a.clamp(0.0, 1.0);
59    let mut cos_z = solar_zenith.to_radians().cos();
60    if cos_z < 0.0436 { cos_z = 0.0436; }
61    
62    let cos_aoi = aoi_in.to_radians().cos().max(0.0);
63    let r_b = cos_aoi / cos_z;
64    
65    dhi * ((1.0 - a) * (1.0 + surface_tilt.to_radians().cos()) / 2.0 + a * r_b)
66}
67
68/// Klucher diffuse sky model.
69/// 
70/// # References
71/// Klucher, T.M., 1979, "Evaluation of models to predict insolation on tilted surfaces," 
72/// Solar Energy, 23(2), pp. 111-114.
73pub fn klucher(surface_tilt: f64, _surface_azimuth: f64, dhi: f64, ghi: f64, solar_zenith: f64, _solar_azimuth: f64, aoi_in: f64) -> f64 {
74    let mut f = 0.0;
75    if ghi > 0.0 {
76        let frac = dhi / ghi;
77        f = 1.0 - frac * frac;
78    }
79    let f = f.clamp(0.0, 1.0);
80    
81    let _cos_z = solar_zenith.to_radians().cos();
82    let cos_aoi = aoi_in.to_radians().cos().max(0.0);
83    let tilt_rad = surface_tilt.to_radians();
84    
85    let term1 = 1.0 + f * (tilt_rad / 2.0).sin().powi(3);
86    let term2 = 1.0 + f * cos_aoi.powi(2) * (solar_zenith.to_radians().sin()).powi(3);
87    
88    dhi * ((1.0 + tilt_rad.cos()) / 2.0) * term1 * term2
89}
90
91/// Perez diffuse sky model.
92/// 
93/// # References
94/// Perez, R., Ineichen, P., Seals, R., Michalsky, J. and Stewart, R., 1990, 
95/// "Modeling daylight availability and irradiance components from direct and global irradiance," 
96/// Solar Energy, 44(5), pp. 271-289.
97pub fn perez(surface_tilt: f64, _surface_azimuth: f64, dhi: f64, dni: f64, dni_extra: f64, solar_zenith: f64, _solar_azimuth: f64, airmass: f64, aoi_in: f64) -> f64 {
98    let mut cos_z = solar_zenith.to_radians().cos();
99    if cos_z < 0.0436 { cos_z = 0.0436; } // cap to ~87.5 deg
100    let cos_aoi = aoi_in.to_radians().cos().max(0.0); // beam parallel to surface if >90
101
102    // sky clearness epsilon
103    let _a = (dni_extra * 1e-6).max(1.0); // essentially 1.0 for bounds, simplified for delta
104    let delta = dhi * airmass / dni_extra;
105    
106    let mut epsilon = 1.0;
107    if dhi > 0.0 {
108        epsilon = ((dhi + dni) / dhi + 1.041 * solar_zenith.to_radians().powi(3)) / 
109                  (1.0 + 1.041 * solar_zenith.to_radians().powi(3));
110    }
111
112    let bin = if epsilon < 1.065 { 0 }
113    else if epsilon < 1.230 { 1 }
114    else if epsilon < 1.500 { 2 }
115    else if epsilon < 1.950 { 3 }
116    else if epsilon < 2.800 { 4 }
117    else if epsilon < 4.500 { 5 }
118    else if epsilon < 6.200 { 6 }
119    else { 7 };
120
121    let coeffs = PEREZ_COEFFICIENTS[bin];
122    let mut f1 = coeffs[0] + coeffs[1] * delta + coeffs[2] * solar_zenith.to_radians();
123    f1 = f1.max(0.0);
124    let f2 = coeffs[3] + coeffs[4] * delta + coeffs[5] * solar_zenith.to_radians();
125
126    let a_perez = cos_aoi;
127    let b_perez = cos_z;
128
129    dhi * ((1.0 - f1) * (1.0 + surface_tilt.to_radians().cos()) / 2.0 + f1 * a_perez / b_perez + f2 * surface_tilt.to_radians().sin())
130}
131
132/// Erbs decomposition model.
133/// 
134/// # References
135/// Erbs, D.G., Klein, S.A. and Duffie, J.A., 1982, 
136/// "Estimation of the diffuse radiation fraction for hourly, daily and monthly-average global radiation," 
137/// Solar Energy, 28(4), pp. 293-302.
138pub fn erbs(ghi: f64, zenith: f64, _day_of_year: u32, dni_extra: f64) -> (f64, f64) {
139    if ghi <= 0.0 || zenith >= 90.0 { return (0.0, 0.0); }
140    let mut cos_z = zenith.to_radians().cos();
141    if cos_z < 0.0436 { cos_z = 0.0436; }
142    
143    let kt = ghi / (dni_extra * cos_z);
144    
145    let kd = if kt <= 0.22 {
146        1.0 - 0.09 * kt
147    } else if kt <= 0.80 {
148        0.9511 - 0.1604 * kt + 4.388 * kt.powi(2) - 16.638 * kt.powi(3) + 12.336 * kt.powi(4)
149    } else {
150        0.165
151    };
152    
153    let dhi = ghi * kd.clamp(0.0, 1.0);
154    let dni = ((ghi - dhi) / cos_z).max(0.0);
155    
156    (dni, dhi)
157}
158
159/// Boland (2008) decomposition model.
160/// Logistic regression model for continuous diffuse fraction estimation.
161/// 
162/// # References
163/// Boland, J., Scott, L. and Luther, M., 2008. 
164/// "Modelling the diffuse fraction of global solar radiation on a horizontal surface."
165pub fn boland(ghi: f64, zenith: f64, dni_extra: f64) -> (f64, f64) {
166    if ghi <= 0.0 || zenith >= 90.0 { return (0.0, 0.0); }
167    let cos_z = zenith.to_radians().cos().max(0.0436);
168    
169    let kt = ghi / (dni_extra * cos_z);
170    
171    // Boland logistic equation
172    let kd = 1.0 / (1.0 + (-5.0033 + 8.6025 * kt).exp());
173    let dhi = ghi * kd.clamp(0.0, 1.0);
174    let dni = ((ghi - dhi) / cos_z).max(0.0);
175    
176    (dni, dhi)
177}
178
179/// DIRINT (Perez 1992) decomposition model.
180/// 
181/// Note: This is a highly simplified representation of DIRINT for estimating DNI 
182/// from GHI without full climatic parameter timeseries tracking.
183/// 
184/// # References
185/// Perez, R., Ineichen, P., Maxwell, E., Seals, R. and Zelenka, A., 1992. 
186/// "Dynamic global-to-direct irradiance conversion models."
187pub fn dirint(ghi: f64, zenith: f64, _dew_point: f64, _pressure: f64, dni_extra: f64) -> (f64, f64) {
188    // In a full time-series context, DIRINT uses persistence bins. 
189    // Here we approximate it by defaulting to a slightly more aggressive Erbs.
190    if ghi <= 0.0 || zenith >= 90.0 { return (0.0, 0.0); }
191    let cos_z = zenith.to_radians().cos().max(0.0436);
192    
193    let kt = ghi / (dni_extra * cos_z);
194    
195    // Approximate diffuse fraction
196    let kd = if kt <= 0.2 {
197        0.99
198    } else if kt <= 0.8 {
199        0.95 - 0.9 * (kt - 0.2)
200    } else {
201        0.15
202    };
203    
204    let dhi = ghi * kd.clamp(0.0, 1.0);
205    let dni = ((ghi - dhi) / cos_z).max(0.0);
206    (dni, dhi)
207}
208
209/// POA direct beam.
210pub fn poa_direct(aoi_in: f64, dni: f64) -> f64 {
211    let aoi_rad = aoi_in.to_radians();
212    if aoi_rad > std::f64::consts::PI / 2.0 {
213        0.0
214    } else {
215        dni * aoi_rad.cos()
216    }
217}
218
219/// Reindl transposition model (anisotropic sky).
220/// 
221/// A highly cited mathematical model bridging the gap between Hay-Davies and Perez models.
222/// 
223/// # References
224/// Reindl, D.T., Beckman, W.A. and Duffie, J.A., 1990. "Evaluation of hourly tilt data models".
225#[allow(clippy::too_many_arguments)]
226pub fn reindl(surface_tilt: f64, dhi: f64, ghi: f64, dni: f64, dni_extra: f64, solar_zenith: f64, aoi_in: f64) -> f64 {
227    let mut a = 0.0;
228    if dni_extra > 0.0 { a = dni / dni_extra; }
229    let a = a.clamp(0.0, 1.0);
230    
231    let cos_z = solar_zenith.to_radians().cos().max(0.0436);
232    let cos_aoi = aoi_in.to_radians().cos().max(0.0);
233    let r_b = cos_aoi / cos_z;
234    
235    let f = if ghi > 0.0 { (dni / ghi).powi(2) } else { 0.0 };
236    
237    let tilt_rad = surface_tilt.to_radians();
238    let term1 = dhi * (1.0 - a) * (1.0 + tilt_rad.cos()) / 2.0 * (1.0 + f * (tilt_rad / 2.0).sin().powi(3));
239    let term2 = dhi * a * r_b;
240    
241    term1 + term2
242}
243
244/// Clearness Index (Kt).
245/// 
246/// The ratio of global horizontal irradiance to extraterrestrial horizontal irradiance.
247pub fn clearness_index(ghi: f64, solar_zenith: f64, dni_extra: f64) -> f64 {
248    let cos_z = solar_zenith.to_radians().cos().max(0.01);
249    let ghi_extra = dni_extra * cos_z;
250    if ghi_extra <= 0.0 { 0.0 } else { (ghi / ghi_extra).clamp(0.0, 1.0) }
251}
252
253/// Zenith-independent clearness index (Kt*).
254///
255/// # References
256/// Perez, R. et al., 1990. "Making full use of the clearness index for parameterizing hourly insolation conditions."
257pub fn clearness_index_zenith_independent(clearness_idx: f64, _solar_zenith: f64, airmass_absolute: f64) -> f64 {
258    let am = airmass_absolute.max(1.0);
259    // Approximation of the geometric zenith independence formula
260    let denominator = 1.031 * (-1.4 / (0.9 + 9.4 / am)).exp() + 0.1;
261    (clearness_idx / denominator).max(0.0)
262}
263
264/// Cosine of the angle of incidence (AOI projection).
265///
266/// Calculates the dot product of the sun position unit vector and the surface
267/// normal unit vector. When the sun is behind the surface, the returned value
268/// is negative. Input all angles in degrees.
269///
270/// # References
271/// Same geometry as [`aoi`], but returns cos(AOI) without taking arccos.
272pub fn aoi_projection(surface_tilt: f64, surface_azimuth: f64, solar_zenith: f64, solar_azimuth: f64) -> f64 {
273    let tilt_rad = surface_tilt.to_radians();
274    let surf_az_rad = surface_azimuth.to_radians();
275    let zen_rad = solar_zenith.to_radians();
276    let sol_az_rad = solar_azimuth.to_radians();
277
278    let projection = zen_rad.cos() * tilt_rad.cos()
279        + zen_rad.sin() * tilt_rad.sin() * (sol_az_rad - surf_az_rad).cos();
280
281    projection.clamp(-1.0, 1.0)
282}
283
284/// Beam component of plane-of-array irradiance.
285///
286/// Calculates `DNI * max(cos(AOI), 0)`.
287///
288/// # Parameters
289/// - `surface_tilt`: Panel tilt from horizontal [degrees]
290/// - `surface_azimuth`: Panel azimuth [degrees]
291/// - `solar_zenith`: Solar zenith angle [degrees]
292/// - `solar_azimuth`: Solar azimuth angle [degrees]
293/// - `dni`: Direct normal irradiance [W/m²]
294pub fn beam_component(surface_tilt: f64, surface_azimuth: f64, solar_zenith: f64, solar_azimuth: f64, dni: f64) -> f64 {
295    let proj = aoi_projection(surface_tilt, surface_azimuth, solar_zenith, solar_azimuth);
296    (dni * proj).max(0.0)
297}
298
299/// Ground-reflected diffuse irradiance on a tilted surface.
300///
301/// Calculated as `GHI * albedo * (1 - cos(tilt)) / 2`.
302///
303/// # Parameters
304/// - `surface_tilt`: Panel tilt from horizontal [degrees]
305/// - `ghi`: Global horizontal irradiance [W/m²]
306/// - `albedo`: Ground surface albedo (typically 0.1–0.4) [unitless]
307///
308/// # References
309/// Loutzenhiser P.G. et al., 2007, "Empirical validation of models to compute
310/// solar irradiance on inclined surfaces for building energy simulation",
311/// Solar Energy vol. 81, pp. 254-267.
312pub fn get_ground_diffuse(surface_tilt: f64, ghi: f64, albedo: f64) -> f64 {
313    ghi * albedo * (1.0 - surface_tilt.to_radians().cos()) * 0.5
314}
315
316/// Components of plane-of-array irradiance.
317#[derive(Debug, Clone, Copy)]
318pub struct PoaComponents {
319    /// Total in-plane irradiance [W/m²]
320    pub poa_global: f64,
321    /// Total in-plane beam irradiance [W/m²]
322    pub poa_direct: f64,
323    /// Total in-plane diffuse irradiance [W/m²]
324    pub poa_diffuse: f64,
325    /// In-plane diffuse irradiance from sky [W/m²]
326    pub poa_sky_diffuse: f64,
327    /// In-plane diffuse irradiance from ground [W/m²]
328    pub poa_ground_diffuse: f64,
329}
330
331/// Determine in-plane irradiance components.
332///
333/// Combines DNI with sky diffuse and ground-reflected irradiance to calculate
334/// total, direct, and diffuse irradiance components in the plane of array.
335/// Negative beam irradiation due to AOI > 90° is set to zero.
336///
337/// # Parameters
338/// - `aoi_val`: Angle of incidence [degrees]
339/// - `dni`: Direct normal irradiance [W/m²]
340/// - `poa_sky_diffuse`: Sky diffuse irradiance in the plane of array [W/m²]
341/// - `poa_ground_diffuse`: Ground-reflected irradiance in the plane of array [W/m²]
342pub fn poa_components(aoi_val: f64, dni: f64, poa_sky_diffuse: f64, poa_ground_diffuse: f64) -> PoaComponents {
343    let poa_direct = (dni * aoi_val.to_radians().cos()).max(0.0);
344    let poa_diffuse = poa_sky_diffuse + poa_ground_diffuse;
345    let poa_global = poa_direct + poa_diffuse;
346
347    PoaComponents {
348        poa_global,
349        poa_direct,
350        poa_diffuse,
351        poa_sky_diffuse,
352        poa_ground_diffuse,
353    }
354}
355
356/// Result of total irradiance calculation.
357pub type TotalIrradiance = PoaComponents;
358
359/// Sky diffuse irradiance model selection.
360#[derive(Debug, Clone, Copy, PartialEq, Eq)]
361pub enum DiffuseModel {
362    Isotropic,
363    Klucher,
364    HayDavies,
365    Reindl,
366    Perez,
367}
368
369/// Determine in-plane sky diffuse irradiance using the specified model.
370///
371/// Dispatches to the appropriate diffuse sky model: isotropic, klucher,
372/// haydavies, reindl, or perez.
373///
374/// # Parameters
375/// - `surface_tilt`: Panel tilt from horizontal [degrees]
376/// - `surface_azimuth`: Panel azimuth [degrees]
377/// - `solar_zenith`: Solar zenith angle [degrees]
378/// - `solar_azimuth`: Solar azimuth angle [degrees]
379/// - `dni`: Direct normal irradiance [W/m²]
380/// - `ghi`: Global horizontal irradiance [W/m²]
381/// - `dhi`: Diffuse horizontal irradiance [W/m²]
382/// - `model`: Sky diffuse irradiance model
383/// - `dni_extra`: Extraterrestrial DNI [W/m²] (required for HayDavies, Reindl, Perez)
384/// - `airmass`: Relative airmass (required for Perez)
385#[allow(clippy::too_many_arguments)]
386pub fn get_sky_diffuse(
387    surface_tilt: f64,
388    surface_azimuth: f64,
389    solar_zenith: f64,
390    solar_azimuth: f64,
391    dni: f64,
392    ghi: f64,
393    dhi: f64,
394    model: DiffuseModel,
395    dni_extra: Option<f64>,
396    airmass: Option<f64>,
397) -> f64 {
398    let aoi_val = aoi(surface_tilt, surface_azimuth, solar_zenith, solar_azimuth);
399
400    match model {
401        DiffuseModel::Isotropic => isotropic(surface_tilt, dhi),
402        DiffuseModel::Klucher => klucher(surface_tilt, surface_azimuth, dhi, ghi, solar_zenith, solar_azimuth, aoi_val),
403        DiffuseModel::HayDavies => {
404            let extra = dni_extra.unwrap_or(0.0);
405            haydavies(surface_tilt, surface_azimuth, dhi, dni, extra, solar_zenith, solar_azimuth, aoi_val)
406        }
407        DiffuseModel::Reindl => {
408            let extra = dni_extra.unwrap_or(0.0);
409            reindl(surface_tilt, dhi, ghi, dni, extra, solar_zenith, aoi_val)
410        }
411        DiffuseModel::Perez => {
412            let extra = dni_extra.unwrap_or(0.0);
413            let am = airmass.unwrap_or_else(|| atmosphere::get_relative_airmass(solar_zenith));
414            perez(surface_tilt, surface_azimuth, dhi, dni, extra, solar_zenith, solar_azimuth, am, aoi_val)
415        }
416    }
417}
418
419/// Determine total in-plane irradiance and its beam, sky diffuse, and ground
420/// reflected components using the specified sky diffuse irradiance model.
421///
422/// # Parameters
423/// - `surface_tilt`: Panel tilt from horizontal [degrees]
424/// - `surface_azimuth`: Panel azimuth [degrees]
425/// - `solar_zenith`: Solar zenith angle [degrees]
426/// - `solar_azimuth`: Solar azimuth angle [degrees]
427/// - `dni`: Direct normal irradiance [W/m²]
428/// - `ghi`: Global horizontal irradiance [W/m²]
429/// - `dhi`: Diffuse horizontal irradiance [W/m²]
430/// - `albedo`: Ground surface albedo [unitless]
431/// - `model`: Sky diffuse irradiance model
432/// - `dni_extra`: Extraterrestrial DNI [W/m²] (required for HayDavies, Reindl, Perez)
433/// - `airmass`: Relative airmass (required for Perez)
434#[allow(clippy::too_many_arguments)]
435pub fn get_total_irradiance(
436    surface_tilt: f64,
437    surface_azimuth: f64,
438    solar_zenith: f64,
439    solar_azimuth: f64,
440    dni: f64,
441    ghi: f64,
442    dhi: f64,
443    albedo: f64,
444    model: DiffuseModel,
445    dni_extra: Option<f64>,
446    airmass: Option<f64>,
447) -> TotalIrradiance {
448    let aoi_val = aoi(surface_tilt, surface_azimuth, solar_zenith, solar_azimuth);
449
450    let sky_diffuse = get_sky_diffuse(
451        surface_tilt, surface_azimuth, solar_zenith, solar_azimuth,
452        dni, ghi, dhi, model, dni_extra, airmass,
453    );
454
455    let ground_diffuse = get_ground_diffuse(surface_tilt, ghi, albedo);
456
457    poa_components(aoi_val, dni, sky_diffuse, ground_diffuse)
458}
459
460/// Output of the DISC decomposition model.
461#[derive(Debug, Clone, Copy)]
462pub struct DiscOutput {
463    /// Direct normal irradiance [W/m²]
464    pub dni: f64,
465    /// Clearness index [unitless]
466    pub kt: f64,
467    /// Airmass used in the calculation [unitless]
468    pub airmass: f64,
469}
470
471/// DISC model helper: calculate Kn from clearness index and airmass.
472fn disc_kn(kt: f64, am: f64) -> (f64, f64) {
473    let am = am.min(12.0);
474
475    let (a, b, c) = if kt <= 0.6 {
476        (
477            0.512 + kt * (-1.56 + kt * (2.286 - 2.222 * kt)),
478            0.37 + 0.962 * kt,
479            -0.28 + kt * (0.932 - 2.048 * kt),
480        )
481    } else {
482        (
483            -5.743 + kt * (21.77 + kt * (-27.49 + 11.56 * kt)),
484            41.4 + kt * (-118.5 + kt * (66.05 + 31.9 * kt)),
485            -47.01 + kt * (184.2 + kt * (-222.0 + 73.81 * kt)),
486        )
487    };
488
489    let delta_kn = a + b * (c * am).exp();
490    let knc = 0.866 + am * (-0.122 + am * (0.0121 + am * (-0.000653 + 1.4e-05 * am)));
491    let kn = knc - delta_kn;
492
493    (kn, am)
494}
495
496/// Estimate Direct Normal Irradiance from Global Horizontal Irradiance
497/// using the DISC model.
498///
499/// The DISC algorithm converts GHI to DNI through empirical relationships
500/// between the global and direct clearness indices.
501///
502/// # Parameters
503/// - `ghi`: Global horizontal irradiance [W/m²]
504/// - `solar_zenith`: True (not refraction-corrected) solar zenith angle [degrees]
505/// - `day_of_year`: Day of year (1–365)
506/// - `pressure`: Site pressure [Pa]. Use `None` for relative airmass only.
507///
508/// # References
509/// Maxwell, E. L., 1987, "A Quasi-Physical Model for Converting Hourly
510/// Global Horizontal to Direct Normal Insolation", Technical Report
511/// No. SERI/TR-215-3087, Golden, CO: Solar Energy Research Institute.
512pub fn disc(ghi: f64, solar_zenith: f64, day_of_year: i32, pressure: Option<f64>) -> DiscOutput {
513    let max_zenith = 87.0;
514    let min_cos_zenith = 0.065;
515
516    // DISC uses solar constant = 1370 with Spencer method
517    let b = 2.0 * PI * ((day_of_year - 3) as f64) / 365.0;
518    let i0 = 1370.0 * (1.0 + 0.033412 * b.cos());
519
520    // Clearness index
521    let cos_z = solar_zenith.to_radians().cos().max(min_cos_zenith);
522    let ghi_extra = i0 * cos_z;
523    let kt = if ghi_extra > 0.0 { (ghi / ghi_extra).clamp(0.0, 1.0) } else { 0.0 };
524
525    // Airmass — DISC was calibrated against Kasten 1966, not Kasten-Young 1989
526    // Kasten 1966: AM = 1 / (cos(z) + 0.15 * (93.885 - z)^(-1.253))
527    let mut am = {
528        let z = solar_zenith;
529        let cos_z = z.to_radians().cos();
530        let c = 93.885 - z;
531        if c <= 0.0 {
532            f64::NAN
533        } else {
534            1.0 / (cos_z + 0.15 * c.powf(-1.253))
535        }
536    };
537    if let Some(p) = pressure {
538        am = atmosphere::get_absolute_airmass(am, p);
539    }
540
541    let (kn, am) = disc_kn(kt, am);
542    let mut dni = kn * i0;
543
544    if solar_zenith > max_zenith || ghi < 0.0 || dni < 0.0 {
545        dni = 0.0;
546    }
547
548    DiscOutput { dni, kt, airmass: am }
549}
550
551/// Output of the Erbs-Driesse decomposition model.
552#[derive(Debug, Clone, Copy)]
553pub struct ErbsDriesseOutput {
554    /// Direct normal irradiance [W/m²]
555    pub dni: f64,
556    /// Diffuse horizontal irradiance [W/m²]
557    pub dhi: f64,
558    /// Clearness index [unitless]
559    pub kt: f64,
560}
561
562/// Estimate DNI and DHI from GHI using the continuous Erbs-Driesse model.
563///
564/// The Erbs-Driesse model is a reformulation of the original Erbs model
565/// that provides continuity of the function and its first derivative at
566/// the two transition points.
567///
568/// # Parameters
569/// - `ghi`: Global horizontal irradiance [W/m²]
570/// - `solar_zenith`: True (not refraction-corrected) zenith angle [degrees]
571/// - `day_of_year`: Day of year (1–365)
572///
573/// # References
574/// Driesse, A., Jensen, A., Perez, R., 2024. A Continuous form of the
575/// Perez diffuse sky model for forward and reverse transposition.
576/// Solar Energy vol. 267. doi:10.1016/j.solener.2023.112093
577pub fn erbs_driesse(ghi: f64, solar_zenith: f64, day_of_year: i32) -> ErbsDriesseOutput {
578    let max_zenith = 87.0;
579    let min_cos_zenith = 0.065;
580
581    let ghi = ghi.max(0.0);
582
583    let dni_extra = get_extra_radiation(day_of_year);
584
585    // Clearness index
586    let cos_z = solar_zenith.to_radians().cos().max(min_cos_zenith);
587    let ghi_extra = dni_extra * cos_z;
588    let kt = if ghi_extra > 0.0 { (ghi / ghi_extra).clamp(0.0, 1.0) } else { 0.0 };
589
590    // Central polynomial coefficients
591    let p = [12.26911439571261, -16.4705084246973, 4.24692671521831700,
592             -0.11390583806313881, 0.946296633571001];
593
594    // Diffuse fraction
595    let df = if kt <= 0.216 {
596        1.0 - 0.09 * kt
597    } else if kt <= 0.792 {
598        // np.polyval evaluates p[0]*x^4 + p[1]*x^3 + ...
599        p[0] * kt.powi(4) + p[1] * kt.powi(3) + p[2] * kt.powi(2) + p[3] * kt + p[4]
600    } else {
601        0.165
602    };
603
604    let dhi = df * ghi;
605    let mut dni = (ghi - dhi) / solar_zenith.to_radians().cos();
606
607    let bad = solar_zenith > max_zenith || ghi < 0.0 || dni < 0.0;
608    let dhi = if bad { ghi } else { dhi };
609    if bad {
610        dni = 0.0;
611    }
612
613    ErbsDriesseOutput { dni, dhi, kt }
614}
615
616/// King diffuse sky model.
617///
618/// Determines the diffuse irradiance from the sky on a tilted surface using
619/// the King model. Ground-reflected irradiance is not included.
620///
621/// # Parameters
622/// - `surface_tilt`: Panel tilt from horizontal [degrees]
623/// - `dhi`: Diffuse horizontal irradiance [W/m²]
624/// - `ghi`: Global horizontal irradiance [W/m²]
625/// - `solar_zenith`: Apparent (refraction-corrected) solar zenith angle [degrees]
626pub fn king(surface_tilt: f64, dhi: f64, ghi: f64, solar_zenith: f64) -> f64 {
627    let cos_tilt = surface_tilt.to_radians().cos();
628    let sky_diffuse = dhi * (1.0 + cos_tilt) / 2.0
629        + ghi * (0.012 * solar_zenith - 0.04) * (1.0 - cos_tilt) / 2.0;
630    sky_diffuse.max(0.0)
631}
632
633/// DIRINDEX model for estimating DNI from GHI using clearsky information.
634///
635/// The DIRINDEX model modifies the DIRINT model by incorporating information
636/// from a clear sky model. It computes:
637/// `DNI = DNI_clear * DIRINT(GHI) / DIRINT(GHI_clear)`
638///
639/// # Parameters
640/// - `ghi`: Global horizontal irradiance [W/m²]
641/// - `ghi_clearsky`: Clear-sky global horizontal irradiance [W/m²]
642/// - `dni_clearsky`: Clear-sky direct normal irradiance [W/m²]
643/// - `zenith`: True (not refraction-corrected) zenith angle [degrees]
644/// - `day_of_year`: Day of year (1–365)
645/// - `pressure`: Site pressure [Pa]. Use `None` for standard pressure (101325 Pa).
646///
647/// # References
648/// Perez, R., Ineichen, P., Moore, K., Kmiecik, M., Chain, C., George, R.,
649/// & Vignola, F. (2002). A new operational model for satellite-derived
650/// irradiances: description and validation. Solar Energy, 73(5), 307-317.
651pub fn dirindex(
652    ghi: f64,
653    ghi_clearsky: f64,
654    dni_clearsky: f64,
655    zenith: f64,
656    day_of_year: i32,
657    pressure: Option<f64>,
658) -> f64 {
659    let dni_extra = get_extra_radiation(day_of_year);
660    let p = pressure.unwrap_or(101325.0);
661
662    let (dni_dirint, _) = dirint(ghi, zenith, 0.0, p, dni_extra);
663    let (dni_dirint_clear, _) = dirint(ghi_clearsky, zenith, 0.0, p, dni_extra);
664
665    if dni_dirint_clear <= 0.0 {
666        return 0.0;
667    }
668
669    let dni = dni_clearsky * dni_dirint / dni_dirint_clear;
670    dni.max(0.0)
671}
672
673// ---------------------------------------------------------------------------
674// Perez-Driesse transposition model
675// ---------------------------------------------------------------------------
676
677/// Knot vector for the Perez-Driesse quadratic B-splines.
678const PD_KNOTS: [f64; 13] = [
679    0.000, 0.000, 0.000,
680    0.061, 0.187, 0.333, 0.487, 0.643, 0.778, 0.839,
681    1.000, 1.000, 1.000,
682];
683
684/// Coefficient table for the Perez-Driesse splines.
685/// Original layout: 13 rows x 6 columns (f11,f12,f13, f21,f22,f23).
686/// After transpose+reshape to (2,3,13), index as COEFS[i-1][j-1].
687const PD_COEFS: [[[f64; 13]; 3]; 2] = [
688    // i=1 (F1 coefficients)
689    [
690        // j=1: f11
691        [-0.053, -0.008,  0.131,  0.328,  0.557,  0.861,  1.212,  1.099,  0.544,  0.544,  0.000,  0.000,  0.000],
692        // j=2: f12
693        [ 0.529,  0.588,  0.770,  0.471,  0.241, -0.323, -1.239, -1.847,  0.157,  0.157,  0.000,  0.000,  0.000],
694        // j=3: f13
695        [-0.028, -0.062, -0.167, -0.216, -0.300, -0.355, -0.444, -0.365, -0.213, -0.213,  0.000,  0.000,  0.000],
696    ],
697    // i=2 (F2 coefficients)
698    [
699        // j=1: f21
700        [-0.071, -0.060, -0.026,  0.069,  0.086,  0.240,  0.305,  0.275,  0.118,  0.118,  0.000,  0.000,  0.000],
701        // j=2: f22
702        [ 0.061,  0.072,  0.106, -0.105, -0.085, -0.467, -0.797, -1.132, -1.455, -1.455,  0.000,  0.000,  0.000],
703        // j=3: f23
704        [-0.019, -0.022, -0.032, -0.028, -0.012, -0.008,  0.047,  0.124,  0.292,  0.292,  0.000,  0.000,  0.000],
705    ],
706];
707
708/// Evaluate a quadratic B-spline defined by the Perez-Driesse knots and coefficients.
709///
710/// This is equivalent to `scipy.interpolate.splev(x, (knots, coefs, 2))`.
711fn pd_splev(x: f64, coefs: &[f64; 13]) -> f64 {
712    let t = &PD_KNOTS;
713    let k = 2_usize; // quadratic
714    let n = t.len() - k - 1; // 10 basis functions
715
716    // Clamp x to knot domain [t[k], t[n]]
717    let x = x.clamp(t[k], t[n]);
718
719    // De Boor's algorithm for evaluating B-spline at x
720    // Find knot span: largest i such that t[i] <= x < t[i+1], with i in [k, n-1]
721    let mut span = k;
722    for i in k..n {
723        if t[i + 1] > x {
724            span = i;
725            break;
726        }
727        span = i;
728    }
729
730    // Initialize: d[j] = coefs[span - k + j] for j = 0..=k
731    let mut d = [0.0_f64; 3]; // k+1 = 3
732    for j in 0..=k {
733        let idx = span - k + j;
734        if idx < 13 {
735            d[j] = coefs[idx];
736        }
737    }
738
739    // Triangular computation
740    for r in 1..=k {
741        for j in (r..=k).rev() {
742            let left = span + j - k;
743            let right = span + 1 + j - r;
744            let denom = t[right] - t[left];
745            if denom.abs() < 1e-15 {
746                d[j] = 0.0;
747            } else {
748                let alpha = (x - t[left]) / denom;
749                d[j] = (1.0 - alpha) * d[j - 1] + alpha * d[j];
750            }
751        }
752    }
753
754    d[k]
755}
756
757/// Compute the delta parameter (sky brightness) for Perez-Driesse.
758fn pd_calc_delta(dhi: f64, dni_extra: f64, solar_zenith: f64, airmass: Option<f64>) -> f64 {
759    let am = match airmass {
760        Some(a) => {
761            if solar_zenith >= 90.0 {
762                // Use max airmass at horizon
763                atmosphere::get_relative_airmass(89.999)
764            } else {
765                a
766            }
767        }
768        None => {
769            if solar_zenith >= 90.0 {
770                atmosphere::get_relative_airmass(89.999)
771            } else {
772                atmosphere::get_relative_airmass(solar_zenith)
773            }
774        }
775    };
776
777    let am = if am.is_nan() { atmosphere::get_relative_airmass(89.999) } else { am };
778
779    if dni_extra <= 0.0 || am <= 0.0 {
780        return 0.0;
781    }
782
783    dhi / (dni_extra / am)
784}
785
786/// Compute the zeta parameter (sky clearness) for Perez-Driesse.
787fn pd_calc_zeta(dhi: f64, dni: f64, zenith: f64) -> f64 {
788    if dhi <= 0.0 && dni <= 0.0 {
789        return 0.0;
790    }
791
792    let sum = dhi + dni;
793    let mut zeta = if sum > 0.0 { dni / sum } else { 0.0 };
794
795    if dhi == 0.0 {
796        zeta = 0.0;
797    }
798
799    // Apply kappa correction (analogous to eq. 7)
800    let kappa = 1.041;
801    let kterm = kappa * zenith.to_radians().powi(3);
802    let denom = 1.0 - kterm * (zeta - 1.0);
803    if denom.abs() > 1e-15 {
804        zeta /= denom;
805    }
806
807    zeta
808}
809
810/// Evaluate the Perez-Driesse spline function f(i,j,zeta).
811fn pd_f(i: usize, j: usize, zeta: f64) -> f64 {
812    pd_splev(zeta, &PD_COEFS[i - 1][j - 1])
813}
814
815/// Continuous Perez-Driesse diffuse sky model.
816///
817/// The Perez-Driesse model is a reformulation of the 1990 Perez model
818/// that provides continuity of the function and of its first derivatives
819/// by replacing the look-up table of coefficients with quadratic splines.
820///
821/// # Parameters
822/// - `surface_tilt`: Panel tilt from horizontal [degrees]
823/// - `surface_azimuth`: Panel azimuth [degrees]
824/// - `dhi`: Diffuse horizontal irradiance [W/m²]
825/// - `dni`: Direct normal irradiance [W/m²]
826/// - `dni_extra`: Extraterrestrial normal irradiance [W/m²]
827/// - `solar_zenith`: Apparent (refraction-corrected) zenith angle [degrees]
828/// - `solar_azimuth`: Solar azimuth angle [degrees]
829/// - `airmass`: Relative (not pressure-corrected) airmass [unitless].
830///   If `None`, calculated internally using Kasten-Young 1989.
831///
832/// # References
833/// Driesse, A., Jensen, A., Perez, R., 2024. A Continuous form of the
834/// Perez diffuse sky model for forward and reverse transposition.
835/// Solar Energy vol. 267. doi:10.1016/j.solener.2023.112093
836#[allow(clippy::too_many_arguments)]
837pub fn perez_driesse(
838    surface_tilt: f64,
839    surface_azimuth: f64,
840    dhi: f64,
841    dni: f64,
842    dni_extra: f64,
843    solar_zenith: f64,
844    solar_azimuth: f64,
845    airmass: Option<f64>,
846) -> f64 {
847    let delta = pd_calc_delta(dhi, dni_extra, solar_zenith, airmass);
848    let zeta = pd_calc_zeta(dhi, dni, solar_zenith);
849
850    let z = solar_zenith.to_radians();
851
852    let f1 = pd_f(1, 1, zeta) + pd_f(1, 2, zeta) * delta + pd_f(1, 3, zeta) * z;
853    let f2 = pd_f(2, 1, zeta) + pd_f(2, 2, zeta) * delta + pd_f(2, 3, zeta) * z;
854
855    // Clip F1 to [0, 0.9] as recommended
856    let f1 = f1.clamp(0.0, 0.9);
857
858    // A = max(cos(AOI), 0)
859    let a = aoi_projection(surface_tilt, surface_azimuth, solar_zenith, solar_azimuth).max(0.0);
860
861    // B = max(cos(zenith), cos(85))
862    let b = solar_zenith.to_radians().cos().max(85.0_f64.to_radians().cos());
863
864    let term1 = 0.5 * (1.0 - f1) * (1.0 + surface_tilt.to_radians().cos());
865    let term2 = f1 * a / b;
866    let term3 = f2 * surface_tilt.to_radians().sin();
867
868    (dhi * (term1 + term2 + term3)).max(0.0)
869}
870