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