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.
19#[inline]
20pub fn aoi(surface_tilt: f64, surface_azimuth: f64, solar_zenith: f64, solar_azimuth: f64) -> f64 {
21    let tilt_rad = surface_tilt.to_radians();
22    let surf_az_rad = surface_azimuth.to_radians();
23    let zen_rad = solar_zenith.to_radians();
24    let sol_az_rad = solar_azimuth.to_radians();
25
26    let cos_aoi = zen_rad.cos() * tilt_rad.cos()
27        + zen_rad.sin() * tilt_rad.sin() * (sol_az_rad - surf_az_rad).cos();
28    
29    let cos_aoi = cos_aoi.clamp(-1.0, 1.0);
30    cos_aoi.acos().to_degrees()
31}
32
33/// Calculate extraterrestrial solar irradiance for a day of year (Spencer 1971).
34#[inline]
35pub fn get_extra_radiation(dayofyear: i32) -> f64 {
36    let b = 2.0 * PI * ((dayofyear - 1) as f64) / 365.0;
37    let rover_r0_sqrd = 1.00011
38        + 0.034221 * b.cos()
39        + 0.00128 * b.sin()
40        + 0.000719 * (2.0 * b).cos()
41        + 0.000077 * (2.0 * b).sin();
42    1366.1 * rover_r0_sqrd
43}
44
45/// Isotropic diffuse model.
46#[inline]
47pub fn isotropic(surface_tilt: f64, dhi: f64) -> f64 {
48    dhi * (1.0 + surface_tilt.to_radians().cos()) / 2.0
49}
50
51/// Hay-Davies diffuse sky model.
52/// 
53/// # References
54/// Hay, J.E. and Davies, J.A., 1980, "Calculations of the solar radiation incident on an inclined surface", 
55/// in Proceedings of the First Canadian Solar Radiation Data Workshop.
56#[allow(clippy::too_many_arguments)]
57#[inline]
58pub 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 {
59    let mut a = 0.0;
60    if dni_extra > 0.0 {
61        a = dni / dni_extra;
62    }
63    let a = a.clamp(0.0, 1.0);
64    let mut cos_z = solar_zenith.to_radians().cos();
65    if cos_z < 85.0_f64.to_radians().cos() { cos_z = 85.0_f64.to_radians().cos(); }
66
67    let cos_aoi = aoi_in.to_radians().cos().max(0.0);
68    let r_b = cos_aoi / cos_z;
69
70    dhi * ((1.0 - a) * (1.0 + surface_tilt.to_radians().cos()) / 2.0 + a * r_b)
71}
72
73/// Klucher diffuse sky model.
74/// 
75/// # References
76/// Klucher, T.M., 1979, "Evaluation of models to predict insolation on tilted surfaces," 
77/// Solar Energy, 23(2), pp. 111-114.
78#[inline]
79pub fn klucher(surface_tilt: f64, _surface_azimuth: f64, dhi: f64, ghi: f64, solar_zenith: f64, _solar_azimuth: f64, aoi_in: f64) -> f64 {
80    let mut f = 0.0;
81    if ghi > 0.0 {
82        let frac = dhi / ghi;
83        f = 1.0 - frac * frac;
84    }
85    let f = f.clamp(0.0, 1.0);
86    
87    let _cos_z = solar_zenith.to_radians().cos();
88    let cos_aoi = aoi_in.to_radians().cos().max(0.0);
89    let tilt_rad = surface_tilt.to_radians();
90    
91    let term1 = 1.0 + f * (tilt_rad / 2.0).sin().powi(3);
92    let term2 = 1.0 + f * cos_aoi.powi(2) * (solar_zenith.to_radians().sin()).powi(3);
93    
94    dhi * ((1.0 + tilt_rad.cos()) / 2.0) * term1 * term2
95}
96
97/// Perez diffuse sky model.
98/// 
99/// # References
100/// Perez, R., Ineichen, P., Seals, R., Michalsky, J. and Stewart, R., 1990, 
101/// "Modeling daylight availability and irradiance components from direct and global irradiance," 
102/// Solar Energy, 44(5), pp. 271-289.
103#[allow(clippy::too_many_arguments)]
104#[inline]
105pub 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 {
106    let mut cos_z = solar_zenith.to_radians().cos();
107    if cos_z < 85.0_f64.to_radians().cos() { cos_z = 85.0_f64.to_radians().cos(); }
108    let cos_aoi = aoi_in.to_radians().cos().max(0.0); // beam parallel to surface if >90
109
110    // sky clearness epsilon
111    let _a = (dni_extra * 1e-6).max(1.0); // essentially 1.0 for bounds, simplified for delta
112    let delta = dhi * airmass / dni_extra;
113    
114    let mut epsilon = 1.0;
115    if dhi > 0.0 {
116        epsilon = ((dhi + dni) / dhi + 1.041 * solar_zenith.to_radians().powi(3)) / 
117                  (1.0 + 1.041 * solar_zenith.to_radians().powi(3));
118    }
119
120    let bin = if epsilon < 1.065 { 0 }
121    else if epsilon < 1.230 { 1 }
122    else if epsilon < 1.500 { 2 }
123    else if epsilon < 1.950 { 3 }
124    else if epsilon < 2.800 { 4 }
125    else if epsilon < 4.500 { 5 }
126    else if epsilon < 6.200 { 6 }
127    else { 7 };
128
129    let coeffs = PEREZ_COEFFICIENTS[bin];
130    let mut f1 = coeffs[0] + coeffs[1] * delta + coeffs[2] * solar_zenith.to_radians();
131    f1 = f1.max(0.0);
132    let f2 = coeffs[3] + coeffs[4] * delta + coeffs[5] * solar_zenith.to_radians();
133
134    let a_perez = cos_aoi;
135    let b_perez = cos_z;
136
137    dhi * ((1.0 - f1) * (1.0 + surface_tilt.to_radians().cos()) / 2.0 + f1 * a_perez / b_perez + f2 * surface_tilt.to_radians().sin())
138}
139
140/// Erbs decomposition model.
141/// 
142/// # References
143/// Erbs, D.G., Klein, S.A. and Duffie, J.A., 1982, 
144/// "Estimation of the diffuse radiation fraction for hourly, daily and monthly-average global radiation," 
145/// Solar Energy, 28(4), pp. 293-302.
146#[inline]
147pub fn erbs(ghi: f64, zenith: f64, _day_of_year: u32, dni_extra: f64) -> (f64, f64) {
148    if !ghi.is_finite() || !zenith.is_finite() || !dni_extra.is_finite() {
149        return (0.0, 0.0);
150    }
151    if ghi <= 0.0 || zenith >= 87.0 { return (0.0, ghi); }
152    let mut cos_z = zenith.to_radians().cos();
153    if cos_z < 85.0_f64.to_radians().cos() { cos_z = 85.0_f64.to_radians().cos(); }
154
155    let kt = ghi / (dni_extra * cos_z);
156
157    let kd = if kt <= 0.22 {
158        1.0 - 0.09 * kt
159    } else if kt <= 0.80 {
160        0.9511 - 0.1604 * kt + 4.388 * kt.powi(2) - 16.638 * kt.powi(3) + 12.336 * kt.powi(4)
161    } else {
162        0.165
163    };
164
165    let dhi = ghi * kd.clamp(0.0, 1.0);
166    let dni = (ghi - dhi) / cos_z;
167    if dni < 0.0 { return (0.0, ghi); }
168
169    (dni, dhi)
170}
171
172/// Boland (2008) decomposition model.
173/// Logistic regression model for continuous diffuse fraction estimation.
174/// 
175/// # References
176/// Boland, J., Scott, L. and Luther, M., 2008. 
177/// "Modelling the diffuse fraction of global solar radiation on a horizontal surface."
178#[inline]
179pub fn boland(ghi: f64, zenith: f64, dni_extra: f64) -> (f64, f64) {
180    if ghi <= 0.0 || zenith >= 90.0 { return (0.0, 0.0); }
181    let cos_z = zenith.to_radians().cos().max(85.0_f64.to_radians().cos());
182    
183    let kt = ghi / (dni_extra * cos_z);
184    
185    // Boland logistic equation: DF = 1 / (1 + exp(a*(kt - b)))
186    // Default coefficients: a=8.645, b=0.613 (15-minute data, Boland et al.)
187    let a_coeff = 8.645;
188    let b_coeff = 0.613;
189    let kd = 1.0 / (1.0 + (a_coeff * (kt - b_coeff)).exp());
190    let dhi = ghi * kd.clamp(0.0, 1.0);
191    let dni = ((ghi - dhi) / cos_z).max(0.0);
192    
193    (dni, dhi)
194}
195
196/// Zenith-independent clearness index (Perez eqn 1).
197///
198/// `kt' = kt / (1.031 · exp(-1.4 / (0.9 + 9.4/am)) + 0.1)`, clamped to
199/// `[0, max_clearness_index]`.
200#[inline]
201pub fn clearness_index_zenith_independent(
202    clearness_index: f64,
203    airmass: f64,
204    max_clearness_index: f64,
205) -> f64 {
206    if !clearness_index.is_finite() || !airmass.is_finite() || airmass <= 0.0 {
207        return 0.0;
208    }
209    let factor = 1.031 * (-1.4 / (0.9 + 9.4 / airmass)).exp() + 0.1;
210    (clearness_index / factor).clamp(0.0, max_clearness_index)
211}
212
213/// Precipitable water (cm) from surface dew-point temperature (Perez eqn 4).
214#[inline]
215pub fn precipitable_water_from_dew_point(temp_dew_c: f64) -> f64 {
216    (0.07 * temp_dew_c - 0.075).exp()
217}
218
219/// Determine DNI from GHI using the DIRINT modification of the DISC model.
220///
221/// Scalar version — assumes no temporal-persistence information (the
222/// `delta_kt'` bin is set to the "-1" fallback). For time-series input with
223/// adjacent samples, [`dirint_series`] propagates persistence and typically
224/// produces ~1–3 % better DNI accuracy when samples are ≤ 1 hour apart.
225///
226/// Faithful port of `pvlib.irradiance.dirint` (Perez, Ineichen, Maxwell,
227/// Seals & Zelenka, 1992). The 6×6×7×5 coefficient tensor lives in
228/// [`dirint_coeffs`](../dirint_coeffs/index.html).
229///
230/// # Parameters
231/// - `ghi`: Global horizontal irradiance [W/m²]
232/// - `solar_zenith`: True (not refraction-corrected) zenith angle [°]
233/// - `day_of_year`: Day of year (1–365/366)
234/// - `pressure_pa`: Atmospheric pressure [Pa] (use 101_325.0 for standard)
235/// - `temp_dew_c`: Optional surface dew-point temperature [°C]; `None`
236///   uses the pvlib default w = -1 fallback bin.
237///
238/// # Returns
239/// Direct normal irradiance [W/m²].
240#[inline]
241pub fn dirint(
242    ghi: f64,
243    solar_zenith: f64,
244    day_of_year: i32,
245    pressure_pa: f64,
246    temp_dew_c: Option<f64>,
247) -> f64 {
248    let disc_out = disc(ghi, solar_zenith, day_of_year, Some(pressure_pa));
249    let kt_prime =
250        clearness_index_zenith_independent(disc_out.kt, disc_out.airmass, 1.0);
251    let w = temp_dew_c.map_or(-1.0, precipitable_water_from_dew_point);
252    let m = crate::dirint_coeffs::coefficient(kt_prime, solar_zenith, -1.0, w).unwrap_or(f64::NAN);
253    if !m.is_finite() {
254        return 0.0;
255    }
256    (disc_out.dni * m).max(0.0)
257}
258
259/// Time-series DIRINT with temporal persistence (Perez eqn 2/3).
260///
261/// Faithful port of `pvlib.irradiance.dirint` for array input. For every
262/// index `i`, `delta_kt'[i]` is computed from neighbouring `kt'[i±1]`; the
263/// endpoints mirror their only neighbour (matching pvlib-python).
264///
265/// # Parameters
266/// - `ghi`, `solar_zenith`, `day_of_year` — parallel arrays, all length `n`.
267/// - `pressure_pa` — shared pressure (101_325.0 for standard) used by the
268///   internal DISC call for all samples.
269/// - `temp_dew_c` — optional per-sample dew point [°C]. Length must equal
270///   `ghi` when provided.
271/// - `use_delta_kt_prime` — when `false`, all samples use the
272///   persistence-fallback bin (equivalent to calling [`dirint`] in a loop).
273///
274/// # Panics
275///
276/// Panics if any of `solar_zenith`, `day_of_year`, or `temp_dew_c` has a
277/// different length than `ghi`.
278pub fn dirint_series(
279    ghi: &[f64],
280    solar_zenith: &[f64],
281    day_of_year: &[i32],
282    pressure_pa: f64,
283    temp_dew_c: Option<&[f64]>,
284    use_delta_kt_prime: bool,
285) -> Vec<f64> {
286    let n = ghi.len();
287    assert_eq!(solar_zenith.len(), n, "dirint_series: solar_zenith length mismatch");
288    assert_eq!(day_of_year.len(), n, "dirint_series: day_of_year length mismatch");
289    if let Some(td) = temp_dew_c {
290        assert_eq!(td.len(), n, "dirint_series: temp_dew_c length mismatch");
291    }
292    if n == 0 {
293        return Vec::new();
294    }
295
296    // Pre-compute DISC outputs and kt' for every timestep.
297    let mut dni_disc = Vec::with_capacity(n);
298    let mut kt_prime = Vec::with_capacity(n);
299    for i in 0..n {
300        let d = disc(ghi[i], solar_zenith[i], day_of_year[i], Some(pressure_pa));
301        dni_disc.push(d.dni);
302        kt_prime.push(clearness_index_zenith_independent(d.kt, d.airmass, 1.0));
303    }
304
305    // Compute delta_kt' (Perez eqn 2/3) or fall back to -1 if disabled.
306    let delta_kt_prime: Vec<f64> = if use_delta_kt_prime && n > 1 {
307        let mut out = Vec::with_capacity(n);
308        for i in 0..n {
309            // Endpoints mirror the opposite direction — see pvlib
310            // `_delta_kt_prime_dirint` (eqn 3).
311            let prev = if i == 0 { kt_prime[1] } else { kt_prime[i - 1] };
312            let next = if i + 1 >= n { kt_prime[n - 2] } else { kt_prime[i + 1] };
313            out.push(0.5 * ((kt_prime[i] - next).abs() + (kt_prime[i] - prev).abs()));
314        }
315        out
316    } else {
317        vec![-1.0; n]
318    };
319
320    // Per-sample lookup + multiply.
321    let mut dni = Vec::with_capacity(n);
322    for i in 0..n {
323        let w = temp_dew_c
324            .map(|td| td[i])
325            .filter(|t| t.is_finite())
326            .map_or(-1.0, precipitable_water_from_dew_point);
327        let m = crate::dirint_coeffs::coefficient(
328            kt_prime[i],
329            solar_zenith[i],
330            delta_kt_prime[i],
331            w,
332        )
333        .unwrap_or(f64::NAN);
334        dni.push(if m.is_finite() { (dni_disc[i] * m).max(0.0) } else { 0.0 });
335    }
336    dni
337}
338
339/// POA direct beam.
340#[inline]
341pub fn poa_direct(aoi_in: f64, dni: f64) -> f64 {
342    let aoi_rad = aoi_in.to_radians();
343    if aoi_rad.abs() > std::f64::consts::PI / 2.0 {
344        0.0
345    } else {
346        (dni * aoi_rad.cos()).max(0.0)
347    }
348}
349
350/// Reindl transposition model (anisotropic sky).
351/// 
352/// A highly cited mathematical model bridging the gap between Hay-Davies and Perez models.
353/// 
354/// # References
355/// Reindl, D.T., Beckman, W.A. and Duffie, J.A., 1990. "Evaluation of hourly tilt data models".
356#[allow(clippy::too_many_arguments)]
357#[inline]
358pub fn reindl(surface_tilt: f64, dhi: f64, ghi: f64, dni: f64, dni_extra: f64, solar_zenith: f64, aoi_in: f64) -> f64 {
359    let mut a = 0.0;
360    if dni_extra > 0.0 { a = dni / dni_extra; }
361    let a = a.clamp(0.0, 1.0);
362    
363    let cos_z = solar_zenith.to_radians().cos().max(85.0_f64.to_radians().cos());
364    let cos_aoi = aoi_in.to_radians().cos().max(0.0);
365    let r_b = cos_aoi / cos_z;
366    
367    let cos_z_reindl = solar_zenith.to_radians().cos().max(0.0);
368    let f = if ghi > 0.0 { ((dni * cos_z_reindl) / ghi).sqrt() } else { 0.0 };
369    
370    let tilt_rad = surface_tilt.to_radians();
371    let term1 = dhi * (1.0 - a) * (1.0 + tilt_rad.cos()) / 2.0 * (1.0 + f * (tilt_rad / 2.0).sin().powi(3));
372    let term2 = dhi * a * r_b;
373    
374    term1 + term2
375}
376
377/// Clearness Index (Kt).
378/// 
379/// The ratio of global horizontal irradiance to extraterrestrial horizontal irradiance.
380#[inline]
381pub fn clearness_index(ghi: f64, solar_zenith: f64, dni_extra: f64) -> f64 {
382    let cos_z = solar_zenith.to_radians().cos().max(0.01);
383    let ghi_extra = dni_extra * cos_z;
384    if ghi_extra <= 0.0 { 0.0 } else { (ghi / ghi_extra).clamp(0.0, 1.0) }
385}
386
387/// Cosine of the angle of incidence (AOI projection).
388///
389/// Calculates the dot product of the sun position unit vector and the surface
390/// normal unit vector. When the sun is behind the surface, the returned value
391/// is negative. Input all angles in degrees.
392///
393/// # References
394/// Same geometry as [`aoi`], but returns cos(AOI) without taking arccos.
395#[inline]
396pub fn aoi_projection(surface_tilt: f64, surface_azimuth: f64, solar_zenith: f64, solar_azimuth: f64) -> f64 {
397    let tilt_rad = surface_tilt.to_radians();
398    let surf_az_rad = surface_azimuth.to_radians();
399    let zen_rad = solar_zenith.to_radians();
400    let sol_az_rad = solar_azimuth.to_radians();
401
402    let projection = zen_rad.cos() * tilt_rad.cos()
403        + zen_rad.sin() * tilt_rad.sin() * (sol_az_rad - surf_az_rad).cos();
404
405    projection.clamp(-1.0, 1.0)
406}
407
408/// Beam component of plane-of-array irradiance.
409///
410/// Calculates `DNI * max(cos(AOI), 0)`.
411///
412/// # Parameters
413/// - `surface_tilt`: Panel tilt from horizontal [degrees]
414/// - `surface_azimuth`: Panel azimuth [degrees]
415/// - `solar_zenith`: Solar zenith angle [degrees]
416/// - `solar_azimuth`: Solar azimuth angle [degrees]
417/// - `dni`: Direct normal irradiance [W/m²]
418#[inline]
419pub fn beam_component(surface_tilt: f64, surface_azimuth: f64, solar_zenith: f64, solar_azimuth: f64, dni: f64) -> f64 {
420    let proj = aoi_projection(surface_tilt, surface_azimuth, solar_zenith, solar_azimuth);
421    (dni * proj).max(0.0)
422}
423
424/// Ground-reflected diffuse irradiance on a tilted surface.
425///
426/// Calculated as `GHI * albedo * (1 - cos(tilt)) / 2`.
427///
428/// # Parameters
429/// - `surface_tilt`: Panel tilt from horizontal [degrees]
430/// - `ghi`: Global horizontal irradiance [W/m²]
431/// - `albedo`: Ground surface albedo (typically 0.1–0.4) [unitless]
432///
433/// # References
434/// Loutzenhiser P.G. et al., 2007, "Empirical validation of models to compute
435/// solar irradiance on inclined surfaces for building energy simulation",
436/// Solar Energy vol. 81, pp. 254-267.
437#[inline]
438pub fn get_ground_diffuse(surface_tilt: f64, ghi: f64, albedo: f64) -> f64 {
439    ghi * albedo * (1.0 - surface_tilt.to_radians().cos()) * 0.5
440}
441
442/// Components of plane-of-array irradiance.
443#[derive(Debug, Clone, Copy)]
444pub struct PoaComponents {
445    /// Total in-plane irradiance [W/m²]
446    pub poa_global: f64,
447    /// Total in-plane beam irradiance [W/m²]
448    pub poa_direct: f64,
449    /// Total in-plane diffuse irradiance [W/m²]
450    pub poa_diffuse: f64,
451    /// In-plane diffuse irradiance from sky [W/m²]
452    pub poa_sky_diffuse: f64,
453    /// In-plane diffuse irradiance from ground [W/m²]
454    pub poa_ground_diffuse: f64,
455}
456
457/// Determine in-plane irradiance components.
458///
459/// Combines DNI with sky diffuse and ground-reflected irradiance to calculate
460/// total, direct, and diffuse irradiance components in the plane of array.
461/// Negative beam irradiation due to AOI > 90° is set to zero.
462///
463/// # Parameters
464/// - `aoi_val`: Angle of incidence [degrees]
465/// - `dni`: Direct normal irradiance [W/m²]
466/// - `poa_sky_diffuse`: Sky diffuse irradiance in the plane of array [W/m²]
467/// - `poa_ground_diffuse`: Ground-reflected irradiance in the plane of array [W/m²]
468#[inline]
469pub fn poa_components(aoi_val: f64, dni: f64, poa_sky_diffuse: f64, poa_ground_diffuse: f64) -> PoaComponents {
470    let poa_direct = (dni * aoi_val.to_radians().cos()).max(0.0);
471    let poa_diffuse = poa_sky_diffuse + poa_ground_diffuse;
472    let poa_global = poa_direct + poa_diffuse;
473
474    PoaComponents {
475        poa_global,
476        poa_direct,
477        poa_diffuse,
478        poa_sky_diffuse,
479        poa_ground_diffuse,
480    }
481}
482
483/// Result of total irradiance calculation.
484pub type TotalIrradiance = PoaComponents;
485
486/// Sky diffuse irradiance model selection.
487#[derive(Debug, Clone, Copy, PartialEq, Eq)]
488pub enum DiffuseModel {
489    Isotropic,
490    Klucher,
491    HayDavies,
492    Reindl,
493    Perez,
494}
495
496/// Determine in-plane sky diffuse irradiance using the specified model.
497///
498/// Dispatches to the appropriate diffuse sky model: isotropic, klucher,
499/// haydavies, reindl, or perez.
500///
501/// # Parameters
502/// - `surface_tilt`: Panel tilt from horizontal [degrees]
503/// - `surface_azimuth`: Panel azimuth [degrees]
504/// - `solar_zenith`: Solar zenith angle [degrees]
505/// - `solar_azimuth`: Solar azimuth angle [degrees]
506/// - `dni`: Direct normal irradiance [W/m²]
507/// - `ghi`: Global horizontal irradiance [W/m²]
508/// - `dhi`: Diffuse horizontal irradiance [W/m²]
509/// - `model`: Sky diffuse irradiance model
510/// - `dni_extra`: Extraterrestrial DNI [W/m²] (required for HayDavies, Reindl, Perez)
511/// - `airmass`: Relative airmass (required for Perez)
512#[allow(clippy::too_many_arguments)]
513#[inline]
514pub fn get_sky_diffuse(
515    surface_tilt: f64,
516    surface_azimuth: f64,
517    solar_zenith: f64,
518    solar_azimuth: f64,
519    dni: f64,
520    ghi: f64,
521    dhi: f64,
522    model: DiffuseModel,
523    dni_extra: Option<f64>,
524    airmass: Option<f64>,
525) -> f64 {
526    let aoi_val = aoi(surface_tilt, surface_azimuth, solar_zenith, solar_azimuth);
527
528    match model {
529        DiffuseModel::Isotropic => isotropic(surface_tilt, dhi),
530        DiffuseModel::Klucher => klucher(surface_tilt, surface_azimuth, dhi, ghi, solar_zenith, solar_azimuth, aoi_val),
531        DiffuseModel::HayDavies => {
532            let extra = dni_extra.unwrap_or(0.0);
533            haydavies(surface_tilt, surface_azimuth, dhi, dni, extra, solar_zenith, solar_azimuth, aoi_val)
534        }
535        DiffuseModel::Reindl => {
536            let extra = dni_extra.unwrap_or(0.0);
537            reindl(surface_tilt, dhi, ghi, dni, extra, solar_zenith, aoi_val)
538        }
539        DiffuseModel::Perez => {
540            let extra = dni_extra.unwrap_or(0.0);
541            let am = airmass.unwrap_or_else(|| atmosphere::get_relative_airmass(solar_zenith));
542            perez(surface_tilt, surface_azimuth, dhi, dni, extra, solar_zenith, solar_azimuth, am, aoi_val)
543        }
544    }
545}
546
547/// Determine total in-plane irradiance and its beam, sky diffuse, and ground
548/// reflected components using the specified sky diffuse irradiance model.
549///
550/// # Parameters
551/// - `surface_tilt`: Panel tilt from horizontal [degrees]
552/// - `surface_azimuth`: Panel azimuth [degrees]
553/// - `solar_zenith`: Solar zenith angle [degrees]
554/// - `solar_azimuth`: Solar azimuth angle [degrees]
555/// - `dni`: Direct normal irradiance [W/m²]
556/// - `ghi`: Global horizontal irradiance [W/m²]
557/// - `dhi`: Diffuse horizontal irradiance [W/m²]
558/// - `albedo`: Ground surface albedo [unitless]
559/// - `model`: Sky diffuse irradiance model
560/// - `dni_extra`: Extraterrestrial DNI [W/m²] (required for HayDavies, Reindl, Perez)
561/// - `airmass`: Relative airmass (required for Perez)
562#[allow(clippy::too_many_arguments)]
563#[inline]
564pub fn get_total_irradiance(
565    surface_tilt: f64,
566    surface_azimuth: f64,
567    solar_zenith: f64,
568    solar_azimuth: f64,
569    dni: f64,
570    ghi: f64,
571    dhi: f64,
572    albedo: f64,
573    model: DiffuseModel,
574    dni_extra: Option<f64>,
575    airmass: Option<f64>,
576) -> TotalIrradiance {
577    let aoi_val = aoi(surface_tilt, surface_azimuth, solar_zenith, solar_azimuth);
578
579    let sky_diffuse = get_sky_diffuse(
580        surface_tilt, surface_azimuth, solar_zenith, solar_azimuth,
581        dni, ghi, dhi, model, dni_extra, airmass,
582    );
583
584    let ground_diffuse = get_ground_diffuse(surface_tilt, ghi, albedo);
585
586    poa_components(aoi_val, dni, sky_diffuse, ground_diffuse)
587}
588
589/// Output of the DISC decomposition model.
590#[derive(Debug, Clone, Copy)]
591pub struct DiscOutput {
592    /// Direct normal irradiance [W/m²]
593    pub dni: f64,
594    /// Clearness index [unitless]
595    pub kt: f64,
596    /// Airmass used in the calculation [unitless]
597    pub airmass: f64,
598}
599
600/// DISC model helper: calculate Kn from clearness index and airmass.
601fn disc_kn(kt: f64, am: f64) -> (f64, f64) {
602    let am = am.min(12.0);
603
604    let (a, b, c) = if kt <= 0.6 {
605        (
606            0.512 + kt * (-1.56 + kt * (2.286 - 2.222 * kt)),
607            0.37 + 0.962 * kt,
608            -0.28 + kt * (0.932 - 2.048 * kt),
609        )
610    } else {
611        (
612            -5.743 + kt * (21.77 + kt * (-27.49 + 11.56 * kt)),
613            41.4 + kt * (-118.5 + kt * (66.05 + 31.9 * kt)),
614            -47.01 + kt * (184.2 + kt * (-222.0 + 73.81 * kt)),
615        )
616    };
617
618    let delta_kn = a + b * (c * am).exp();
619    let knc = 0.866 + am * (-0.122 + am * (0.0121 + am * (-0.000653 + 1.4e-05 * am)));
620    let kn = knc - delta_kn;
621
622    (kn, am)
623}
624
625/// Estimate Direct Normal Irradiance from Global Horizontal Irradiance
626/// using the DISC model.
627///
628/// The DISC algorithm converts GHI to DNI through empirical relationships
629/// between the global and direct clearness indices.
630///
631/// # Parameters
632/// - `ghi`: Global horizontal irradiance [W/m²]
633/// - `solar_zenith`: True (not refraction-corrected) solar zenith angle [degrees]
634/// - `day_of_year`: Day of year (1–365)
635/// - `pressure`: Site pressure [Pa]. Use `None` for relative airmass only.
636///
637/// # References
638/// Maxwell, E. L., 1987, "A Quasi-Physical Model for Converting Hourly
639/// Global Horizontal to Direct Normal Insolation", Technical Report
640/// No. SERI/TR-215-3087, Golden, CO: Solar Energy Research Institute.
641#[inline]
642pub fn disc(ghi: f64, solar_zenith: f64, day_of_year: i32, pressure: Option<f64>) -> DiscOutput {
643    let max_zenith = 87.0;
644    let min_cos_zenith = 0.065;
645
646    // DISC uses solar constant = 1370 with Spencer 1971 full Fourier series
647    let b = 2.0 * PI * ((day_of_year - 1) as f64) / 365.0;
648    let rover = 1.00011 + 0.034221 * b.cos() + 0.00128 * b.sin()
649        + 0.000719 * (2.0 * b).cos() + 0.000077 * (2.0 * b).sin();
650    let i0 = 1370.0 * rover;
651
652    // Clearness index
653    let cos_z = solar_zenith.to_radians().cos().max(min_cos_zenith);
654    let ghi_extra = i0 * cos_z;
655    let kt = if ghi_extra > 0.0 { (ghi / ghi_extra).clamp(0.0, 1.0) } else { 0.0 };
656
657    // Airmass — DISC was calibrated against Kasten 1966, not Kasten-Young 1989
658    // Kasten 1966: AM = 1 / (cos(z) + 0.15 * (93.885 - z)^(-1.253))
659    let mut am = {
660        let z = solar_zenith;
661        let cos_z = z.to_radians().cos();
662        let c = 93.885 - z;
663        if c <= 0.0 {
664            f64::NAN
665        } else {
666            1.0 / (cos_z + 0.15 * c.powf(-1.253))
667        }
668    };
669    if let Some(p) = pressure {
670        am = atmosphere::get_absolute_airmass(am, p);
671    }
672
673    // Guard against NaN airmass (e.g. numerical edge at the horizon)
674    // so that `disc_kn`'s exp cannot produce NaN DNI below the zenith cutoff.
675    if !am.is_finite() {
676        return DiscOutput { dni: 0.0, kt, airmass: f64::NAN };
677    }
678
679    let (kn, am) = disc_kn(kt, am);
680    let mut dni = kn * i0;
681
682    if solar_zenith > max_zenith || ghi < 0.0 || dni < 0.0 || !dni.is_finite() {
683        dni = 0.0;
684    }
685
686    DiscOutput { dni, kt, airmass: am }
687}
688
689/// Output of the Erbs-Driesse decomposition model.
690#[derive(Debug, Clone, Copy)]
691pub struct ErbsDriesseOutput {
692    /// Direct normal irradiance [W/m²]
693    pub dni: f64,
694    /// Diffuse horizontal irradiance [W/m²]
695    pub dhi: f64,
696    /// Clearness index [unitless]
697    pub kt: f64,
698}
699
700/// Estimate DNI and DHI from GHI using the continuous Erbs-Driesse model.
701///
702/// The Erbs-Driesse model is a reformulation of the original Erbs model
703/// that provides continuity of the function and its first derivative at
704/// the two transition points.
705///
706/// # Parameters
707/// - `ghi`: Global horizontal irradiance [W/m²]
708/// - `solar_zenith`: True (not refraction-corrected) zenith angle [degrees]
709/// - `day_of_year`: Day of year (1–365)
710///
711/// # References
712/// Driesse, A., Jensen, A., Perez, R., 2024. A Continuous form of the
713/// Perez diffuse sky model for forward and reverse transposition.
714/// Solar Energy vol. 267. doi:10.1016/j.solener.2023.112093
715#[inline]
716pub fn erbs_driesse(ghi: f64, solar_zenith: f64, day_of_year: i32) -> ErbsDriesseOutput {
717    let max_zenith = 87.0;
718    let min_cos_zenith = 0.065;
719
720    let ghi = ghi.max(0.0);
721
722    let dni_extra = get_extra_radiation(day_of_year);
723
724    // Clearness index
725    let cos_z = solar_zenith.to_radians().cos().max(min_cos_zenith);
726    let ghi_extra = dni_extra * cos_z;
727    let kt = if ghi_extra > 0.0 { (ghi / ghi_extra).clamp(0.0, 1.0) } else { 0.0 };
728
729    // Central polynomial coefficients
730    let p = [12.26911439571261, -16.4705084246973, 4.246926715218317,
731             -0.11390583806313881, 0.946296633571001];
732
733    // Diffuse fraction
734    let df = if kt <= 0.216 {
735        1.0 - 0.09 * kt
736    } else if kt <= 0.792 {
737        // np.polyval evaluates p[0]*x^4 + p[1]*x^3 + ...
738        p[0] * kt.powi(4) + p[1] * kt.powi(3) + p[2] * kt.powi(2) + p[3] * kt + p[4]
739    } else {
740        0.165
741    };
742
743    let dhi = df * ghi;
744    let mut dni = (ghi - dhi) / solar_zenith.to_radians().cos();
745
746    let bad = solar_zenith > max_zenith || ghi < 0.0 || dni < 0.0;
747    let dhi = if bad { ghi } else { dhi };
748    if bad {
749        dni = 0.0;
750    }
751
752    ErbsDriesseOutput { dni, dhi, kt }
753}
754
755/// King diffuse sky model.
756///
757/// Determines the diffuse irradiance from the sky on a tilted surface using
758/// the King model. Ground-reflected irradiance is not included.
759///
760/// # Parameters
761/// - `surface_tilt`: Panel tilt from horizontal [degrees]
762/// - `dhi`: Diffuse horizontal irradiance [W/m²]
763/// - `ghi`: Global horizontal irradiance [W/m²]
764/// - `solar_zenith`: Apparent (refraction-corrected) solar zenith angle [degrees]
765#[inline]
766pub fn king(surface_tilt: f64, dhi: f64, ghi: f64, solar_zenith: f64) -> f64 {
767    let cos_tilt = surface_tilt.to_radians().cos();
768    let sky_diffuse = dhi * (1.0 + cos_tilt) / 2.0
769        + ghi * (0.012 * solar_zenith - 0.04) * (1.0 - cos_tilt) / 2.0;
770    sky_diffuse.max(0.0)
771}
772
773/// DIRINDEX model for estimating DNI from GHI using clearsky information.
774///
775/// The DIRINDEX model modifies the DIRINT model by incorporating information
776/// from a clear sky model. It computes:
777/// `DNI = DNI_clear * DIRINT(GHI) / DIRINT(GHI_clear)`
778///
779/// # Parameters
780/// - `ghi`: Global horizontal irradiance [W/m²]
781/// - `ghi_clearsky`: Clear-sky global horizontal irradiance [W/m²]
782/// - `dni_clearsky`: Clear-sky direct normal irradiance [W/m²]
783/// - `zenith`: True (not refraction-corrected) zenith angle [degrees]
784/// - `day_of_year`: Day of year (1–365)
785/// - `pressure`: Site pressure [Pa]. Use `None` for standard pressure (101325 Pa).
786///
787/// # References
788/// Perez, R., Ineichen, P., Moore, K., Kmiecik, M., Chain, C., George, R.,
789/// & Vignola, F. (2002). A new operational model for satellite-derived
790/// irradiances: description and validation. Solar Energy, 73(5), 307-317.
791#[inline]
792pub fn dirindex(
793    ghi: f64,
794    ghi_clearsky: f64,
795    dni_clearsky: f64,
796    zenith: f64,
797    day_of_year: i32,
798    pressure: Option<f64>,
799) -> f64 {
800    let p = pressure.unwrap_or(101325.0);
801
802    let dni_dirint = dirint(ghi, zenith, day_of_year, p, None);
803    let dni_dirint_clear = dirint(ghi_clearsky, zenith, day_of_year, p, None);
804
805    if dni_dirint_clear <= 0.0 {
806        return 0.0;
807    }
808
809    let dni = dni_clearsky * dni_dirint / dni_dirint_clear;
810    dni.max(0.0)
811}
812
813// ---------------------------------------------------------------------------
814// Perez-Driesse transposition model
815// ---------------------------------------------------------------------------
816
817/// Knot vector for the Perez-Driesse quadratic B-splines.
818const PD_KNOTS: [f64; 13] = [
819    0.000, 0.000, 0.000,
820    0.061, 0.187, 0.333, 0.487, 0.643, 0.778, 0.839,
821    1.000, 1.000, 1.000,
822];
823
824/// Coefficient table for the Perez-Driesse splines.
825/// Original layout: 13 rows x 6 columns (f11,f12,f13, f21,f22,f23).
826/// After transpose+reshape to (2,3,13), index as COEFS[i-1][j-1].
827const PD_COEFS: [[[f64; 13]; 3]; 2] = [
828    // i=1 (F1 coefficients)
829    [
830        // j=1: f11
831        [-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],
832        // j=2: f12
833        [ 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],
834        // j=3: f13
835        [-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],
836    ],
837    // i=2 (F2 coefficients)
838    [
839        // j=1: f21
840        [-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],
841        // j=2: f22
842        [ 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],
843        // j=3: f23
844        [-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],
845    ],
846];
847
848/// Evaluate a quadratic B-spline defined by the Perez-Driesse knots and coefficients.
849///
850/// This is equivalent to `scipy.interpolate.splev(x, (knots, coefs, 2))`.
851fn pd_splev(x: f64, coefs: &[f64; 13]) -> f64 {
852    let t = &PD_KNOTS;
853    let k = 2_usize; // quadratic
854    let n = t.len() - k - 1; // 10 basis functions
855
856    // Clamp x to knot domain [t[k], t[n]]
857    let x = x.clamp(t[k], t[n]);
858
859    // De Boor's algorithm for evaluating B-spline at x
860    // Find knot span: largest i such that t[i] <= x < t[i+1], with i in [k, n-1]
861    let mut span = k;
862    for i in k..n {
863        if t[i + 1] > x {
864            span = i;
865            break;
866        }
867        span = i;
868    }
869
870    // Initialize: d[j] = coefs[span - k + j] for j = 0..=k
871    let mut d = [0.0_f64; 3]; // k+1 = 3
872    for (j, d_j) in d.iter_mut().enumerate().take(k + 1) {
873        let idx = span - k + j;
874        if idx < 13 {
875            *d_j = coefs[idx];
876        }
877    }
878
879    // Triangular computation
880    for r in 1..=k {
881        for j in (r..=k).rev() {
882            let left = span + j - k;
883            let right = span + 1 + j - r;
884            let denom = t[right] - t[left];
885            if denom.abs() < 1e-15 {
886                d[j] = 0.0;
887            } else {
888                let alpha = (x - t[left]) / denom;
889                d[j] = (1.0 - alpha) * d[j - 1] + alpha * d[j];
890            }
891        }
892    }
893
894    d[k]
895}
896
897/// Compute the delta parameter (sky brightness) for Perez-Driesse.
898fn pd_calc_delta(dhi: f64, dni_extra: f64, solar_zenith: f64, airmass: Option<f64>) -> f64 {
899    let am = match airmass {
900        Some(a) => {
901            if solar_zenith >= 90.0 {
902                // Use max airmass at horizon
903                atmosphere::get_relative_airmass(89.999)
904            } else {
905                a
906            }
907        }
908        None => {
909            if solar_zenith >= 90.0 {
910                atmosphere::get_relative_airmass(89.999)
911            } else {
912                atmosphere::get_relative_airmass(solar_zenith)
913            }
914        }
915    };
916
917    let am = if am.is_nan() { atmosphere::get_relative_airmass(89.999) } else { am };
918
919    if dni_extra <= 0.0 || am <= 0.0 {
920        return 0.0;
921    }
922
923    dhi / (dni_extra / am)
924}
925
926/// Compute the zeta parameter (sky clearness) for Perez-Driesse.
927fn pd_calc_zeta(dhi: f64, dni: f64, zenith: f64) -> f64 {
928    if dhi <= 0.0 && dni <= 0.0 {
929        return 0.0;
930    }
931
932    let sum = dhi + dni;
933    let mut zeta = if sum > 0.0 { dni / sum } else { 0.0 };
934
935    if dhi == 0.0 {
936        zeta = 0.0;
937    }
938
939    // Apply kappa correction (analogous to eq. 7)
940    let kappa = 1.041;
941    let kterm = kappa * zenith.to_radians().powi(3);
942    let denom = 1.0 - kterm * (zeta - 1.0);
943    if denom.abs() > 1e-15 {
944        zeta /= denom;
945    }
946
947    zeta
948}
949
950/// Evaluate the Perez-Driesse spline function f(i,j,zeta).
951fn pd_f(i: usize, j: usize, zeta: f64) -> f64 {
952    pd_splev(zeta, &PD_COEFS[i - 1][j - 1])
953}
954
955/// Continuous Perez-Driesse diffuse sky model.
956///
957/// The Perez-Driesse model is a reformulation of the 1990 Perez model
958/// that provides continuity of the function and of its first derivatives
959/// by replacing the look-up table of coefficients with quadratic splines.
960///
961/// # Parameters
962/// - `surface_tilt`: Panel tilt from horizontal [degrees]
963/// - `surface_azimuth`: Panel azimuth [degrees]
964/// - `dhi`: Diffuse horizontal irradiance [W/m²]
965/// - `dni`: Direct normal irradiance [W/m²]
966/// - `dni_extra`: Extraterrestrial normal irradiance [W/m²]
967/// - `solar_zenith`: Apparent (refraction-corrected) zenith angle [degrees]
968/// - `solar_azimuth`: Solar azimuth angle [degrees]
969/// - `airmass`: Relative (not pressure-corrected) airmass [unitless].
970///   If `None`, calculated internally using Kasten-Young 1989.
971///
972/// # References
973/// Driesse, A., Jensen, A., Perez, R., 2024. A Continuous form of the
974/// Perez diffuse sky model for forward and reverse transposition.
975/// Solar Energy vol. 267. doi:10.1016/j.solener.2023.112093
976#[allow(clippy::too_many_arguments)]
977#[inline]
978pub fn perez_driesse(
979    surface_tilt: f64,
980    surface_azimuth: f64,
981    dhi: f64,
982    dni: f64,
983    dni_extra: f64,
984    solar_zenith: f64,
985    solar_azimuth: f64,
986    airmass: Option<f64>,
987) -> f64 {
988    let delta = pd_calc_delta(dhi, dni_extra, solar_zenith, airmass);
989    let zeta = pd_calc_zeta(dhi, dni, solar_zenith);
990
991    let z = solar_zenith.to_radians();
992
993    let f1 = pd_f(1, 1, zeta) + pd_f(1, 2, zeta) * delta + pd_f(1, 3, zeta) * z;
994    let f2 = pd_f(2, 1, zeta) + pd_f(2, 2, zeta) * delta + pd_f(2, 3, zeta) * z;
995
996    // Clip F1 to [0, 0.9] as recommended
997    let f1 = f1.clamp(0.0, 0.9);
998
999    // A = max(cos(AOI), 0)
1000    let a = aoi_projection(surface_tilt, surface_azimuth, solar_zenith, solar_azimuth).max(0.0);
1001
1002    // B = max(cos(zenith), cos(85))
1003    let b = solar_zenith.to_radians().cos().max(85.0_f64.to_radians().cos());
1004
1005    let term1 = 0.5 * (1.0 - f1) * (1.0 + surface_tilt.to_radians().cos());
1006    let term2 = f1 * a / b;
1007    let term3 = f2 * surface_tilt.to_radians().sin();
1008
1009    (dhi * (term1 + term2 + term3)).max(0.0)
1010}
1011