Skip to main content

pvlib/
clearsky.rs

1/// Output for clear sky models that compute all 3 irradiance components.
2#[derive(Debug, Clone, PartialEq)]
3pub struct ClearSkyIrradiance {
4    pub ghi: f64,
5    pub dni: f64,
6    pub dhi: f64,
7}
8
9/// Output for the Bird clear sky model, which also includes direct horizontal irradiance.
10#[derive(Debug, Clone, PartialEq)]
11pub struct BirdResult {
12    pub ghi: f64,
13    pub dni: f64,
14    pub dhi: f64,
15    pub direct_horizontal: f64,
16}
17
18/// The Haurwitz clear sky model.
19/// Computes clear sky GHI (Global Horizontal Irradiance) 
20/// from apparent zenith angle.
21/// 
22/// # Arguments
23/// * `zenith` - Apparent zenith angle in degrees.
24/// 
25/// # Returns
26/// GHI in W/m^2. Returns 0.0 for zenith >= 90.
27#[inline]
28pub fn haurwitz(zenith: f64) -> f64 {
29    if zenith >= 90.0 {
30        return 0.0;
31    }
32    let cos_z = zenith.to_radians().cos();
33    if cos_z <= 0.0 {
34        return 0.0;
35    }
36    
37    1098.0 * cos_z * (-0.059 / cos_z).exp()
38}
39
40/// The Ineichen and Perez (2002) clear sky model.
41/// 
42/// # References
43/// Ineichen, P. and Perez, R., 2002. "A new airmass independent formulation for the Linke turbidity coefficient."
44/// 
45/// # Arguments
46/// * `zenith` - Apparent zenith angle in degrees.
47/// * `airmass_absolute` - Absolute air mass.
48/// * `linke_turbidity` - Linke turbidity factor (typically 2 to 6, default ~3).
49/// * `altitude` - Site altitude in meters.
50/// * `dni_extra` - Extraterrestrial normal irradiance in W/m^2.
51///
52/// # Returns
53/// A `ClearSkyIrradiance` struct containing GHI, DNI, and DHI in W/m^2.
54#[inline]
55pub fn ineichen(zenith: f64, airmass_absolute: f64, linke_turbidity: f64, altitude: f64, dni_extra: f64) -> ClearSkyIrradiance {
56    if zenith >= 90.0 || airmass_absolute <= 0.0 {
57        return ClearSkyIrradiance { ghi: 0.0, dni: 0.0, dhi: 0.0 };
58    }
59
60    let am = airmass_absolute;
61    let tl = linke_turbidity;
62    let h = altitude;
63
64    let fh1 = (-h / 8000.0).exp();
65    let fh2 = (-h / 1250.0).exp();
66
67    let cos_z = zenith.to_radians().cos().max(0.01);
68
69    let i0 = dni_extra;
70
71    let cg1 = 5.09e-05 * h + 0.868;
72    let cg2 = 3.92e-05 * h + 0.0387;
73
74    // GHI
75    let ghi = cg1 * i0 * cos_z * (-cg2 * am * (fh1 + fh2 * (tl - 1.0))).exp().max(0.0);
76
77    // DNI
78    let b = 0.664 + 0.163 / fh1;
79    let bnci = i0 * (b * (-0.09 * am * (tl - 1.0)).exp()).max(0.0);
80    let denom = cos_z;
81    let ratio = if denom > 0.0 {
82        ((1.0 - (0.1 - 0.2 * (-tl).exp()) / (0.1 + 0.882 / fh1)) / denom).clamp(0.0, 1e20)
83    } else {
84        0.0
85    };
86    let bnci_2 = ghi * ratio;
87    let dni = bnci.min(bnci_2);
88
89    // DHI
90    let dhi = ghi - dni * cos_z;
91
92    ClearSkyIrradiance { ghi: ghi.max(0.0), dni: dni.max(0.0), dhi: dhi.max(0.0) }
93}
94
95/// The Simplified Solis clear sky model.
96/// 
97/// A broadly used broadband model leveraging aerosol depth and precipitable water.
98/// 
99/// # References
100/// Ineichen, P., 2008. "A broadband simplified version of the Solis clear sky model."
101/// 
102/// # Returns
103/// A `ClearSkyIrradiance` struct containing GHI, DNI, and DHI in W/m^2.
104/// Simplified Solis clear sky model.
105///
106/// Note: this function takes `apparent_elevation` (degrees above horizon),
107/// NOT zenith angle, matching the pvlib-python API.
108///
109/// # Arguments
110/// * `apparent_elevation` - Apparent solar elevation in degrees above the horizon.
111/// * `aod700` - Aerosol optical depth at 700 nm (default 0.1).
112/// * `precipitable_water` - Precipitable water in cm (default 1.0, minimum 0.2).
113/// * `pressure` - Atmospheric pressure in Pascals (default 101325).
114///
115/// # Returns
116/// A `ClearSkyIrradiance` struct containing GHI, DNI, and DHI in W/m^2.
117#[inline]
118pub fn simplified_solis(apparent_elevation: f64, aod700: f64, precipitable_water: f64, pressure: f64) -> ClearSkyIrradiance {
119    if apparent_elevation <= 0.0 {
120        return ClearSkyIrradiance { ghi: 0.0, dni: 0.0, dhi: 0.0 };
121    }
122
123    let dni_extra = 1364.0;
124    let p = pressure;
125    let p0 = 101325.0_f64;
126    let w = precipitable_water.max(0.2);
127    let aod = aod700;
128    let ln_w = w.ln();
129    let ln_p = (p / p0).ln();
130
131    // i0p: enhanced extraterrestrial irradiance
132    let io0 = 1.08 * w.powf(0.0051);
133    let i01 = 0.97 * w.powf(0.032);
134    let i02 = 0.12 * w.powf(0.56);
135    let i0p = dni_extra * (i02 * aod * aod + i01 * aod + io0 + 0.071 * ln_p);
136
137    // taub, b coefficients (DNI)
138    let tb1 = 1.82 + 0.056 * ln_w + 0.0071 * ln_w * ln_w;
139    let tb0 = 0.33 + 0.045 * ln_w + 0.0096 * ln_w * ln_w;
140    let tbp = 0.0089 * w + 0.13;
141    let taub = tb1 * aod + tb0 + tbp * ln_p;
142
143    let b1 = 0.00925 * aod * aod + 0.0148 * aod - 0.0172;
144    let b0 = -0.7565 * aod * aod + 0.5057 * aod + 0.4557;
145    let b = b1 * ln_w + b0;
146
147    // taug, g coefficients (GHI)
148    let tg1 = 1.24 + 0.047 * ln_w + 0.0061 * ln_w * ln_w;
149    let tg0 = 0.27 + 0.043 * ln_w + 0.0090 * ln_w * ln_w;
150    let tgp = 0.0079 * w + 0.1;
151    let taug = tg1 * aod + tg0 + tgp * ln_p;
152
153    let g = -0.0147 * ln_w - 0.3079 * aod * aod + 0.2846 * aod + 0.3798;
154
155    // taud, d coefficients (DHI)
156    let (td4, td3, td2, td1, td0, tdp) = if aod < 0.05 {
157        (
158            86.0 * w - 13800.0,
159            -3.11 * w + 79.4,
160            -0.23 * w + 74.8,
161            0.092 * w - 8.86,
162            0.0042 * w + 3.12,
163            -0.83 * (1.0 + aod).powf(-17.2),
164        )
165    } else {
166        (
167            -0.21 * w + 11.6,
168            0.27 * w - 20.7,
169            -0.134 * w + 15.5,
170            0.0554 * w - 5.71,
171            0.0057 * w + 2.94,
172            -0.71 * (1.0 + aod).powf(-15.0),
173        )
174    };
175    let taud = td4 * aod.powi(4) + td3 * aod.powi(3) + td2 * aod.powi(2) + td1 * aod + td0 + tdp * ln_p;
176
177    let dp = 1.0 / (18.0 + 152.0 * aod);
178    let d = -0.337 * aod * aod + 0.63 * aod + 0.116 + dp * ln_p;
179
180    // Compute irradiances
181    let sin_elev = apparent_elevation.to_radians().sin().max(1e-30);
182
183    let dni = i0p * (-taub / sin_elev.powf(b)).exp();
184    let ghi = i0p * (-taug / sin_elev.powf(g)).exp() * sin_elev;
185    let dhi = i0p * (-taud / sin_elev.powf(d)).exp();
186
187    ClearSkyIrradiance {
188        ghi: ghi.max(0.0),
189        dni: dni.max(0.0),
190        dhi: dhi.max(0.0),
191    }
192}
193
194// ---------------------------------------------------------------------------
195// Reno–Hansen clear-sky detection (ported from pvlib-python)
196// ---------------------------------------------------------------------------
197
198/// Thresholds for [`detect_clearsky`] (Reno–Hansen 2016).
199///
200/// Default values match pvlib-python defaults for 10-minute windows of
201/// 1-minute GHI data. Use [`ClearSkyThresholds::from_sample_interval`] to
202/// get the Jordan–Hansen (2023) values interpolated for a specific
203/// sample interval.
204#[derive(Debug, Clone, Copy, PartialEq)]
205pub struct ClearSkyThresholds {
206    /// Centered sliding-window length, in minutes.
207    pub window_length_minutes: f64,
208    /// Max |mean(meas) - α·mean(clear)| per window [W/m²].
209    pub mean_diff: f64,
210    /// Max |max(meas) - α·max(clear)| per window [W/m²].
211    pub max_diff: f64,
212    /// Lower bound on `line_length(meas) - line_length(α·clear)`.
213    pub lower_line_length: f64,
214    /// Upper bound on `line_length(meas) - line_length(α·clear)`.
215    pub upper_line_length: f64,
216    /// Upper bound on the normalised std-dev of slopes (Hz⁻¹).
217    pub var_diff: f64,
218    /// Upper bound on the max |Δ(meas − α·clear)| per window.
219    pub slope_dev: f64,
220    /// Maximum iterations for the α rescaling fixed-point.
221    pub max_iterations: usize,
222}
223
224impl Default for ClearSkyThresholds {
225    fn default() -> Self {
226        // Reno-Hansen 2016 defaults for 10-min window / 1-min data.
227        Self {
228            window_length_minutes: 10.0,
229            mean_diff: 75.0,
230            max_diff: 75.0,
231            lower_line_length: -5.0,
232            upper_line_length: 10.0,
233            var_diff: 0.005,
234            slope_dev: 8.0,
235            max_iterations: 20,
236        }
237    }
238}
239
240impl ClearSkyThresholds {
241    /// Interpolate thresholds for a given sample interval (Jordan &
242    /// Hansen 2023, Table 1). Valid for `sample_interval_minutes ∈ [1, 30]`.
243    pub fn from_sample_interval(sample_interval_minutes: f64) -> Self {
244        let si = sample_interval_minutes.clamp(1.0, 30.0);
245        let interp = |xs: &[f64], ys: &[f64]| -> f64 {
246            // linear interpolation over 4 breakpoints [1, 5, 15, 30]
247            debug_assert_eq!(xs.len(), ys.len());
248            if si <= xs[0] {
249                return ys[0];
250            }
251            for i in 0..xs.len() - 1 {
252                if si <= xs[i + 1] {
253                    let t = (si - xs[i]) / (xs[i + 1] - xs[i]);
254                    return ys[i] + t * (ys[i + 1] - ys[i]);
255                }
256            }
257            *ys.last().unwrap()
258        };
259        let breakpoints = [1.0, 5.0, 15.0, 30.0];
260        Self {
261            window_length_minutes: interp(&breakpoints, &[50.0, 60.0, 90.0, 120.0]),
262            mean_diff: 75.0,
263            max_diff: interp(&breakpoints, &[60.0, 65.0, 75.0, 90.0]),
264            lower_line_length: -45.0,
265            upper_line_length: 80.0,
266            var_diff: interp(&breakpoints, &[0.005, 0.01, 0.032, 0.07]),
267            slope_dev: interp(&breakpoints, &[50.0, 60.0, 75.0, 96.0]),
268            max_iterations: 20,
269        }
270    }
271}
272
273/// Full result from [`detect_clearsky_detail`]: per-sample clear flag plus
274/// the fitted α rescaling factor.
275#[derive(Debug, Clone)]
276pub struct ClearSkyDetectionResult {
277    pub clear_samples: Vec<bool>,
278    pub alpha: f64,
279    pub iterations: usize,
280}
281
282/// Detect clear-sky periods using the **Reno–Hansen (2016) windowed
283/// 5-criterion algorithm**.
284///
285/// Faithful port of `pvlib.clearsky.detect_clearsky`. Samples that fall
286/// inside any centered window passing the five criteria (mean diff, max
287/// diff, line-length diff, slope-n-std, slope-dev) are flagged as clear.
288/// The algorithm iterates an α rescaling of the clear-sky series so
289/// that the detected clear samples best match the clear-sky model.
290///
291/// # Parameters
292/// - `measured`, `clearsky` — parallel time-series samples of measured
293///   GHI and expected clearsky GHI [W/m²]. Must have equal length and be
294///   equally spaced in time at `sample_interval_minutes`.
295/// - `sample_interval_minutes` — spacing between consecutive samples
296///   (e.g. 1.0 for 1-minute data, 60.0 for hourly).
297/// - `thresholds` — detection thresholds; [`ClearSkyThresholds::default`]
298///   matches pvlib-python defaults. For non-default sample intervals,
299///   use [`ClearSkyThresholds::from_sample_interval`] to get
300///   Jordan–Hansen 2023 values.
301///
302/// # Panics
303///
304/// Panics if `measured.len() != clearsky.len()` or if the window would
305/// contain fewer than 3 samples.
306///
307/// # References
308/// - Reno, M.J. and Hansen, C.W., 2016. "Identification of periods of
309///   clear sky irradiance in time series of GHI measurements."
310///   Renewable Energy, v90, p. 520-531.
311/// - Jordan, D.C. and Hansen, C., 2023. "Clear-sky detection for PV
312///   degradation analysis using multiple regression." Renewable Energy,
313///   v209, p. 393-400.
314pub fn detect_clearsky(
315    measured: &[f64],
316    clearsky: &[f64],
317    sample_interval_minutes: f64,
318    thresholds: ClearSkyThresholds,
319) -> Vec<bool> {
320    detect_clearsky_detail(measured, clearsky, sample_interval_minutes, thresholds).clear_samples
321}
322
323/// Same as [`detect_clearsky`] but also returns the fitted α and the
324/// iteration count. Useful for diagnostics or for chaining into a second
325/// pass with the fitted scaling.
326pub fn detect_clearsky_detail(
327    measured: &[f64],
328    clearsky: &[f64],
329    sample_interval_minutes: f64,
330    thresholds: ClearSkyThresholds,
331) -> ClearSkyDetectionResult {
332    let n = measured.len();
333    assert_eq!(clearsky.len(), n, "detect_clearsky: length mismatch");
334
335    let samples_per_window =
336        (thresholds.window_length_minutes / sample_interval_minutes).round() as usize;
337    assert!(
338        samples_per_window >= 3,
339        "detect_clearsky: samples_per_window ({samples_per_window}) must be ≥ 3; \
340         increase window_length_minutes or reduce sample_interval_minutes"
341    );
342
343    if n < samples_per_window {
344        return ClearSkyDetectionResult {
345            clear_samples: vec![false; n],
346            alpha: 1.0,
347            iterations: 0,
348        };
349    }
350
351    let num_windows = n + 1 - samples_per_window;
352
353    // Per-window statistics for the measured series: mean, max, slope-n-std,
354    // line length, and (for slope-dev) the raw sample-to-sample forward diffs.
355    let meas_mean = window_mean(measured, samples_per_window, num_windows);
356    let meas_max = window_max(measured, samples_per_window, num_windows);
357    let meas_slope_nstd = window_slope_nstd(
358        measured,
359        sample_interval_minutes,
360        samples_per_window,
361        num_windows,
362        &meas_mean,
363    );
364    let meas_line_length = window_line_length(
365        measured,
366        sample_interval_minutes,
367        samples_per_window,
368        num_windows,
369    );
370
371    // Clearsky window stats (α applied inside the loop).
372    let clear_mean = window_mean(clearsky, samples_per_window, num_windows);
373    let clear_max = window_max(clearsky, samples_per_window, num_windows);
374
375    let mut alpha: f64 = 1.0;
376    let mut clear_windows_flags = vec![false; num_windows];
377    let mut iterations = 0;
378
379    for it in 0..thresholds.max_iterations {
380        iterations = it + 1;
381
382        // Scaled clearsky stats; line length of α·clear equals α·line-length
383        // only in the limit of negligible sample_interval — recompute fully.
384        let scaled_clear: Vec<f64> = clearsky.iter().map(|v| alpha * v).collect();
385        let clear_line_length = window_line_length(
386            &scaled_clear,
387            sample_interval_minutes,
388            samples_per_window,
389            num_windows,
390        );
391        let residual: Vec<f64> = measured
392            .iter()
393            .zip(&scaled_clear)
394            .map(|(m, c)| m - c)
395            .collect();
396        let slope_max_diff = window_max_abs_diff(
397            &residual,
398            samples_per_window,
399            num_windows,
400        );
401
402        for w in 0..num_windows {
403            let line_diff = meas_line_length[w] - clear_line_length[w];
404            let c1 = (meas_mean[w] - alpha * clear_mean[w]).abs() < thresholds.mean_diff;
405            let c2 = (meas_max[w] - alpha * clear_max[w]).abs() < thresholds.max_diff;
406            let c3 = line_diff > thresholds.lower_line_length
407                && line_diff < thresholds.upper_line_length;
408            let c4 = meas_slope_nstd[w] < thresholds.var_diff;
409            let c5 = slope_max_diff[w] < thresholds.slope_dev;
410            let c6 = clear_mean[w] != 0.0 && !clear_mean[w].is_nan();
411            clear_windows_flags[w] = c1 && c2 && c3 && c4 && c5 && c6;
412        }
413
414        // Propagate window flags to samples — any sample inside a clear
415        // window is clear.
416        let mut sample_clear = vec![false; n];
417        for w in 0..num_windows {
418            if clear_windows_flags[w] {
419                for j in 0..samples_per_window {
420                    sample_clear[w + j] = true;
421                }
422            }
423        }
424
425        // Refit α over the identified clear samples.
426        let mut num = 0.0;
427        let mut den = 0.0;
428        for i in 0..n {
429            if sample_clear[i] && clearsky[i].is_finite() && measured[i].is_finite() {
430                num += measured[i] * clearsky[i];
431                den += clearsky[i] * clearsky[i];
432            }
433        }
434        let previous_alpha = alpha;
435        if den.is_finite() && den > 0.0 {
436            alpha = num / den;
437        }
438        if (alpha * 10_000.0).round() == (previous_alpha * 10_000.0).round() {
439            break;
440        }
441    }
442
443    // Final sample-clear propagation after convergence.
444    let mut clear_samples = vec![false; n];
445    for w in 0..num_windows {
446        if clear_windows_flags[w] {
447            for j in 0..samples_per_window {
448                clear_samples[w + j] = true;
449            }
450        }
451    }
452
453    ClearSkyDetectionResult {
454        clear_samples,
455        alpha,
456        iterations,
457    }
458}
459
460/// Window mean over a sliding window of `w` consecutive samples. Returns
461/// `num_windows` values (one per starting position).
462fn window_mean(data: &[f64], w: usize, num_windows: usize) -> Vec<f64> {
463    // Prefix-sum for O(n) mean; NaN samples contribute NaN to the mean.
464    let mut out = Vec::with_capacity(num_windows);
465    for start in 0..num_windows {
466        let mut s = 0.0_f64;
467        for j in 0..w {
468            s += data[start + j];
469        }
470        out.push(s / w as f64);
471    }
472    out
473}
474
475fn window_max(data: &[f64], w: usize, num_windows: usize) -> Vec<f64> {
476    let mut out = Vec::with_capacity(num_windows);
477    for start in 0..num_windows {
478        let mut m = f64::NEG_INFINITY;
479        for j in 0..w {
480            let v = data[start + j];
481            if v > m {
482                m = v;
483            }
484        }
485        out.push(m);
486    }
487    out
488}
489
490/// Σ √(Δ² + dt²) over each window (i.e. arc length on a regular-interval
491/// time series). `w` samples per window → `w - 1` differences per window.
492fn window_line_length(
493    data: &[f64],
494    sample_interval: f64,
495    w: usize,
496    num_windows: usize,
497) -> Vec<f64> {
498    let mut out = Vec::with_capacity(num_windows);
499    let dt2 = sample_interval * sample_interval;
500    for start in 0..num_windows {
501        let mut s = 0.0_f64;
502        for j in 0..w - 1 {
503            let d = data[start + j + 1] - data[start + j];
504            s += (d * d + dt2).sqrt();
505        }
506        out.push(s);
507    }
508    out
509}
510
511/// Standard deviation (sample, ddof=1) of `diff(data)/sample_interval`
512/// within each window, normalised by the window mean of `data`
513/// (matching pvlib-python's `_slope_nstd_windowed`).
514fn window_slope_nstd(
515    data: &[f64],
516    sample_interval: f64,
517    w: usize,
518    num_windows: usize,
519    window_means: &[f64],
520) -> Vec<f64> {
521    let mut out = Vec::with_capacity(num_windows);
522    let n_slopes = w - 1; // per window
523    let denom_ddof = (n_slopes - 1) as f64; // sample std
524    for start in 0..num_windows {
525        let mut sum_s = 0.0_f64;
526        let mut sum_sq = 0.0_f64;
527        for j in 0..n_slopes {
528            let slope = (data[start + j + 1] - data[start + j]) / sample_interval;
529            sum_s += slope;
530            sum_sq += slope * slope;
531        }
532        let mean_s = sum_s / n_slopes as f64;
533        let var = (sum_sq - n_slopes as f64 * mean_s * mean_s) / denom_ddof;
534        let std = if var > 0.0 { var.sqrt() } else { 0.0 };
535        let wm = window_means[start];
536        out.push(if wm.abs() > 0.0 { std / wm } else { f64::NAN });
537    }
538    out
539}
540
541/// Max |diff(data)| within each window (= `w - 1` successive-sample deltas).
542fn window_max_abs_diff(data: &[f64], w: usize, num_windows: usize) -> Vec<f64> {
543    let mut out = Vec::with_capacity(num_windows);
544    for start in 0..num_windows {
545        let mut m = 0.0_f64;
546        for j in 0..w - 1 {
547            let d = (data[start + j + 1] - data[start + j]).abs();
548            if d > m {
549                m = d;
550            }
551        }
552        out.push(m);
553    }
554    out
555}
556
557/// Bird Simple Clear Sky Broadband Solar Radiation Model.
558///
559/// Based on NREL implementation by Daryl R. Myers.
560///
561/// # Arguments
562/// * `zenith` - Solar zenith angle in degrees.
563/// * `airmass_relative` - Relative airmass (not pressure-corrected).
564/// * `aod380` - Aerosol optical depth at 380 nm.
565/// * `aod500` - Aerosol optical depth at 500 nm.
566/// * `precipitable_water` - Precipitable water in cm.
567/// * `ozone` - Atmospheric ozone in cm (default 0.3).
568/// * `pressure` - Ambient pressure in Pa (default 101325).
569/// * `dni_extra` - Extraterrestrial irradiance in W/m^2 (default 1364).
570/// * `asymmetry` - Aerosol asymmetry factor (default 0.85).
571/// * `albedo` - Ground surface albedo (default 0.2).
572///
573/// # Returns
574/// A `BirdResult` containing GHI, DNI, DHI, and direct horizontal irradiance in W/m^2.
575///
576/// # References
577/// R. E. Bird and R. L. Hulstrom, "A Simplified Clear Sky model for
578/// Direct and Diffuse Insolation on Horizontal Surfaces", SERI/TR-642-761, 1981.
579#[allow(clippy::too_many_arguments)]
580#[inline]
581pub fn bird(
582    zenith: f64,
583    airmass_relative: f64,
584    aod380: f64,
585    aod500: f64,
586    precipitable_water: f64,
587    ozone: f64,
588    pressure: f64,
589    dni_extra: f64,
590    asymmetry: f64,
591    albedo: f64,
592) -> BirdResult {
593    if zenith >= 90.0 || airmass_relative <= 0.0 {
594        return BirdResult { ghi: 0.0, dni: 0.0, dhi: 0.0, direct_horizontal: 0.0 };
595    }
596
597    let etr = dni_extra;
598    let ze_rad = zenith.to_radians();
599    let airmass = airmass_relative;
600
601    // Pressure-corrected airmass
602    let am_press = airmass * (pressure / 101325.0);
603
604    // Rayleigh scattering transmittance
605    let t_rayleigh = (-0.0903 * am_press.powf(0.84)
606        * (1.0 + am_press - am_press.powf(1.01)))
607    .exp();
608
609    // Ozone transmittance
610    let am_o3 = ozone * airmass;
611    let t_ozone = 1.0
612        - 0.1611 * am_o3 * (1.0 + 139.48 * am_o3).powf(-0.3034)
613        - 0.002715 * am_o3 / (1.0 + 0.044 * am_o3 + 0.0003 * am_o3 * am_o3);
614
615    // Uniform mixed gas transmittance
616    let t_gases = (-0.0127 * am_press.powf(0.26)).exp();
617
618    // Water vapor transmittance
619    let am_h2o = airmass * precipitable_water;
620    let t_water = 1.0
621        - 2.4959 * am_h2o
622            / ((1.0 + 79.034 * am_h2o).powf(0.6828) + 6.385 * am_h2o);
623
624    // Broadband aerosol optical depth (Bird-Hulstrom 1980)
625    let bird_hulstrom = 0.27583 * aod380 + 0.35 * aod500;
626
627    // Aerosol transmittance
628    let t_aerosol = (-(bird_hulstrom.powf(0.873))
629        * (1.0 + bird_hulstrom - bird_hulstrom.powf(0.7088))
630        * airmass.powf(0.9108))
631    .exp();
632
633    // Aerosol absorptance
634    let taa = 1.0 - 0.1 * (1.0 - airmass + airmass.powf(1.06)) * (1.0 - t_aerosol);
635
636    // Sky reflectivity
637    let rs = 0.0685 + (1.0 - asymmetry) * (1.0 - t_aerosol / taa);
638
639    // Direct normal irradiance
640    let id = 0.9662 * etr * t_aerosol * t_water * t_gases * t_ozone * t_rayleigh;
641
642    // Direct horizontal
643    let cos_z = ze_rad.cos().max(0.0);
644    let id_nh = id * cos_z;
645
646    // Diffuse (scattering) component on horizontal
647    let ias = etr
648        * cos_z
649        * 0.79
650        * t_ozone
651        * t_gases
652        * t_water
653        * taa
654        * (0.5 * (1.0 - t_rayleigh) + asymmetry * (1.0 - t_aerosol / taa))
655        / (1.0 - airmass + airmass.powf(1.02));
656
657    // Global horizontal with ground reflection
658    let ghi = (id_nh + ias) / (1.0 - albedo * rs);
659    let dhi = ghi - id_nh;
660
661    BirdResult {
662        ghi: ghi.max(0.0),
663        dni: id.max(0.0),
664        dhi: dhi.max(0.0),
665        direct_horizontal: id_nh.max(0.0),
666    }
667}
668
669/// Bird model with default parameters for ozone, pressure, dni_extra, asymmetry, and albedo.
670///
671/// Convenience wrapper using: ozone=0.3, pressure=101325, dni_extra=1364, asymmetry=0.85, albedo=0.2.
672#[inline]
673pub fn bird_default(
674    zenith: f64,
675    airmass_relative: f64,
676    aod380: f64,
677    aod500: f64,
678    precipitable_water: f64,
679) -> BirdResult {
680    bird(
681        zenith,
682        airmass_relative,
683        aod380,
684        aod500,
685        precipitable_water,
686        0.3,
687        101325.0,
688        1364.0,
689        0.85,
690        0.2,
691    )
692}
693
694/// Simplified lookup for Linke turbidity based on latitude, longitude, and month.
695///
696/// This is a simplified approximation based on climate zones. The full version
697/// requires the LinkeTurbidities.h5 climatological dataset from SoDa/Meteonorm.
698///
699/// # Arguments
700/// * `latitude` - Latitude in degrees (-90 to 90).
701/// * `longitude` - Longitude in degrees (-180 to 180). Used to refine desert estimates.
702/// * `month` - Month of year (1-12).
703///
704/// # Returns
705/// Approximate Linke turbidity factor (typically 2.0 to 7.0).
706#[inline]
707pub fn lookup_linke_turbidity(latitude: f64, longitude: f64, month: u32) -> f64 {
708    let abs_lat = latitude.abs();
709
710    // Base turbidity by climate zone
711    let base = if abs_lat > 60.0 {
712        // Polar / subarctic: very clean atmosphere
713        2.0
714    } else if abs_lat > 45.0 {
715        // Temperate mid-latitude
716        3.0
717    } else if abs_lat > 23.5 {
718        // Subtropical: check for desert regions
719        // Major desert belts (Sahara, Arabian, Gobi, etc.) have lower turbidity
720        // due to low humidity but higher aerosol; net effect varies
721        let is_desert_belt = (abs_lat > 23.5 && abs_lat < 35.0)
722            && ((longitude > -20.0 && longitude < 60.0)   // Sahara + Arabian
723                || (longitude > 70.0 && longitude < 110.0) // Gobi / Thar
724                || (longitude > -120.0 && longitude < -100.0)); // SW US deserts
725        if is_desert_belt {
726            3.5
727        } else {
728            3.2
729        }
730    } else {
731        // Tropical: high humidity drives high turbidity
732        4.0
733    };
734
735    // Seasonal adjustment: summer months have higher turbidity
736    let is_northern = latitude >= 0.0;
737    let is_summer = if is_northern {
738        (5..=9).contains(&month)
739    } else {
740        month <= 3 || month >= 11
741    };
742
743    if is_summer {
744        base + 0.5
745    } else {
746        base
747    }
748}