Source code for bsrn.modeling.separation

"""
Irradiance separation models (Erbs, Engerer2, etc.).
Estimates diffuse fraction and DHI/BNI from GHI.
"""

import numpy as np
import pandas as pd
from bsrn.physics import geometry
from bsrn.constants import ENGERER2_PARAMS, YANG4_PARAMS
from bsrn.utils.calculations import calc_kt


def _get_solar_and_kt(times, ghi, lat, lon, elev=0, min_mu0=0.065,
                      max_clearness_index=1.0):
    """
    Get ghi_extra, zenith, mu0, kt, and night mask for separation.

    Uses pvlib-style clearness index: ghi_extra = bni_extra * max(mu0, min_mu0),
    kt from calc_kt, clamped and set to NaN at night.

    Parameters
    ----------
    times : pd.DatetimeIndex
        Timestamps (same length as ghi).
    ghi : array-like
        Global horizontal irradiance. [W/m^2]
    lat : float
        Latitude. [degrees]
    lon : float
        Longitude. [degrees]
    elev : float, default 0
        Site elevation. [m] Used for topocentric solar position (matches pvlib when same elev).
    min_mu0 : float, default 0.065
        Minimum cosine of solar zenith for ghi_extra and kt (equiv. ~86.3 deg).
    max_clearness_index : float, default 1.0
        Upper clamp for kt.

    Returns
    -------
    ghi, ghi_extra, zenith, mu0, kt, night : tuple
        Solar and clearness index components.

    Raises
    ------
    ValueError
        If ``times`` is not a :class:`~pandas.DatetimeIndex` or ``ghi`` length mismatches.
    """

    # Check if times is a pd.DatetimeIndex.
    if not isinstance(times, pd.DatetimeIndex):
        raise ValueError("times must be a pd.DatetimeIndex.")

    ghi = np.asarray(ghi, dtype=float)
    if len(ghi) != len(times):
        raise ValueError("ghi must have the same length as times.")

    # Get the solar position.
    solpos = geometry.get_solar_position(times, lat, lon, elev)
    zenith = solpos["zenith"].values
    mu0 = np.maximum(np.cos(np.radians(zenith)), 0.0)

    # Get the extraterrestrial BNI.
    bni_extra = np.asarray(geometry.get_bni_extra(times), dtype=float)
    ghi_extra = bni_extra * np.maximum(mu0, min_mu0)

    # Get the night mask.
    night = zenith >= 90

    # Get the clearness index.
    # Keep calc_kt output as-is; nighttime separation (k, dhi, bni) is set to 0 in each model.
    kt = calc_kt(ghi, zenith, bni_extra, min_mu0=min_mu0,
                  max_clearness_index=max_clearness_index)
    return ghi, ghi_extra, zenith, mu0, kt, night

def _k_to_dhi_bni(ghi, k, zenith, max_zenith=87.0, force_nan=None):
    """
    Convert diffuse fraction $k$ to DHI and BNI with pvlib-style edge handling.

    DHI = $k \\cdot G_h$, BNI = $(G_h - \\text{DHI}) / \\mu_0$. Where
    zenith > max_zenith, GHI < 0, BNI < 0, or $\\mu_0 \\le 0$, sets BNI = 0 and
    DHI = GHI to preserve closure. Optionally force DHI/BNI to NaN where
    force_nan is True (e.g. under-sampled days in BRL).

    Parameters
    ----------
    ghi : array-like
        Global horizontal irradiance ($G_h$). [W/m^2]
    k : array-like
        Diffuse fraction ($k$). [unitless]
    zenith : array-like
        Solar zenith angle ($Z$). [degrees]
    max_zenith : float, default 87.0
        Maximum zenith for valid BNI; beyond this BNI = 0, DHI = GHI. [degrees]
    force_nan : array-like or None, default None
        If provided, DHI and BNI are set to NaN where force_nan is True.

    Returns
    -------
    dhi, bni : tuple of np.ndarray
        Diffuse horizontal and beam normal irradiance. [W/m^2]
    """

    ghi = np.asarray(ghi, dtype=float)
    k = np.asarray(k, dtype=float)
    zenith = np.asarray(zenith, dtype=float)
    mu0 = np.maximum(np.cos(np.radians(zenith)), 0.0)
    dhi = ghi * k
    with np.errstate(divide="ignore", invalid="ignore"):
        bni = (ghi - dhi) / mu0
    bad_values = (zenith > max_zenith) | (ghi < 0) | (bni < 0) | (mu0 <= 0)
    bni = np.where(bad_values, 0.0, bni)
    dhi = np.where(bad_values, ghi, dhi)
    if force_nan is not None:
        force_nan = np.asarray(force_nan, dtype=bool)
        dhi = np.where(force_nan, np.nan, dhi)
        bni = np.where(force_nan, np.nan, bni)
    return dhi, bni
    
def _apparent_solar_time(times, lon):
    """
    Calculate apparent solar time (AST, hours) from timestamps and longitude.

    Uses the equation of time formulation from Ridley et al. (2010), consistent
    with the BRL, Engerer2 and Yang4 models.

    Parameters
    ----------
    times : pd.DatetimeIndex
        Timestamps for calculation.
    lon : float
        Longitude. [degrees]

    Returns
    -------
    ast : np.ndarray
        Apparent solar time in hours. [h]
    """

    doy = times.dayofyear.values
    decimal_hour = (
        times.hour.values
        + times.minute.values / 60.0
        + times.second.values / 3600.0
    )
    beta_eot = (360.0 / 365.242) * (doy - 1)
    eot = (
        0.258 * np.cos(np.radians(beta_eot))
        - 7.416 * np.sin(np.radians(beta_eot))
        - 3.648 * np.cos(np.radians(2 * beta_eot))
        - 9.228 * np.sin(np.radians(2 * beta_eot))
    )
    lsn = 12 - lon / 15.0 - eot / 60.0
    hour_angle = (decimal_hour - lsn) * 15.0
    hour_angle = np.where(hour_angle >= 180, hour_angle - 360, hour_angle)
    hour_angle = np.where(hour_angle <= -180, hour_angle + 360, hour_angle)
    ast = hour_angle / 15.0 + 12.0
    ast = np.where(ast < 0, np.abs(ast), ast)
    return ast

def _brl_daily_clearness_index(times, ghi, ghi_extra, night):
    """
    Daily clearness index K_t = sum(ghi over day) / sum(ghi_extra over day).

    A daily K_t is only computed when more than half of the daytime hourly
    values (ghi_extra > 0) have non-NaN GHI; otherwise K_t for that day is NaN.

    Parameters
    ----------
    times : DatetimeIndex
        Timestamps for calculation.
    ghi : numeric or Series
        Global horizontal irradiance. [W/m^2]
    ghi_extra : numeric or Series
        Extraterrestrial horizontal irradiance. [W/m^2]
    night : array-like
        Night mask at the native resolution (True for night).

    Returns
    -------
    Kt : np.ndarray
        Daily clearness index ($K_t$). [unitless]
    """

    idx = pd.DatetimeIndex(times)
    ghi_ser = pd.Series(ghi, index=idx)
    ghi_extra_ser = pd.Series(ghi_extra, index=idx)
    night_ser = pd.Series(night, index=idx)

    # Resample to hourly means (GHI, GHI_extra) and any-night mask
    hourly_ghi = ghi_ser.resample("1h").mean()
    hourly_ghi_extra = ghi_extra_ser.resample("1h").mean()
    hourly_night = night_ser.resample("1h").max().astype(bool)

    # Define daytime as hours with at least one non-night sample
    is_daytime = ~hourly_night
    has_data = is_daytime & hourly_ghi.notna()

    # Per-day sums and counts restricted to daytime
    dates = hourly_ghi.index.date
    daily_ghi = hourly_ghi.where(is_daytime).groupby(dates).sum()
    daily_ghi_extra = hourly_ghi_extra.where(is_daytime).groupby(dates).sum()
    daily_count_daytime = is_daytime.groupby(dates).sum()
    daily_count_valid = has_data.groupby(dates).sum()

    # Only compute K_t when > half of daytime hours have valid data
    enough_data = daily_count_valid > (daily_count_daytime / 2.0)
    with np.errstate(divide="ignore", invalid="ignore"):
        Kt_day = daily_ghi / daily_ghi_extra
    Kt_day = Kt_day.where(enough_data, np.nan)

    date_to_Kt = dict(zip(Kt_day.index, Kt_day.values))
    dates = np.array([t.date() for t in times])
    Kt = np.array([date_to_Kt.get(d, np.nan) for d in dates])
    return Kt

def _brl_psi(kt, night, dates):
    """
    Piecewise linear interpolation for BRL ψ parameter.

    ψ = (k_{t-1}+k_{t+1})/2 for sunrise < t < sunset; at sunrise ψ=k_{t+1}, at sunset ψ=k_{t-1}.

    Parameters
    ----------
    kt : array-like
        Clearness index. [unitless]
    night : array-like
        Night mask (True for night).
    dates : array-like
        Dates corresponding to timestamps.

    Returns
    -------
    psi : np.ndarray
        BRL ψ parameter. [unitless]
    """

    n = len(kt)
    psi = np.full(n, np.nan, dtype=float)
    kt_arr = np.asarray(kt, dtype=float)
    kt_pad = np.concatenate([[np.nan], kt_arr, [np.nan]])
    daytime = ~night
    # Per-day first and last daytime indices (sunrise / sunset)
    sunrise_idx = {}
    sunset_idx = {}
    for i in range(n):
        if not daytime[i]:
            continue
        d = dates[i]
        if d not in sunrise_idx:
            sunrise_idx[d] = i
        sunset_idx[d] = i
    for i in range(n):
        if not daytime[i]:
            continue
        d = dates[i]
        k_prev = kt_pad[i]
        k_next = kt_pad[i + 2]
        if i == sunrise_idx.get(d, -1):
            psi[i] = k_next if np.isfinite(k_next) else np.nan
        elif i == sunset_idx.get(d, -2):
            psi[i] = k_prev if np.isfinite(k_prev) else np.nan
        else:
            both = np.array([k_prev, k_next])
            psi[i] = np.nanmean(both) if np.any(np.isfinite(both)) else np.nan
    return psi


def _engerer2_k_at_resolution(df, lat, lon, period_minutes, ghi_col="ghi",
                              ghi_clear_col="ghi_clear"):
    """
    Compute Engerer2 diffuse fraction at a given temporal resolution by resampling.

    Requires a clear-sky GHI column in df (caller must provide it).

    Resamples the input to `period_minutes` using right-closed bins (e.g. (10:00, 11:00]
    for the hour labeled 11:00), runs Engerer2 with the corresponding coefficient set,
    and maps the resulting k back to the original index using first-future (backward fill):
    each 1-min time t in (10:00, 11:00] gets the hourly k at 11:00. Use this when you
    need true period-averaged Engerer2 k (e.g. k_60min for Yang4).

    Parameters
    ----------
    df : pd.DataFrame
        Input data with DatetimeIndex and a clear-sky GHI column.
    lat : float
        Latitude. [degrees]
    lon : float
        Longitude. [degrees]
    period_minutes : int
        Resampling resolution. [minutes]
    ghi_col : str, default "ghi"
        Column name for GHI. [W/m^2]
    ghi_clear_col : str, default "ghi_clear"
        Column name for clear-sky GHI. [W/m^2] Must be present in df.

    Returns
    -------
    k : np.ndarray
        Diffuse fraction k aligned to `df.index`. [unitless]

    Raises
    ------
    ValueError
        If ``ghi_clear_col`` is missing, ``period_minutes`` is unsupported, or
        ``df.index`` is not a :class:`~pandas.DatetimeIndex`.
    """
    if ghi_clear_col not in df.columns:
        raise ValueError(
            f"DataFrame must contain clear-sky column '{ghi_clear_col}'. "
            "Provide ghi_clear (e.g. :func:`bsrn.modeling.clear_sky.add_clearsky_columns`) before calling."
        )

    resample_rule = {
        1: "1min",
        5: "5min",
        10: "10min",
        15: "15min",
        30: "30min",
        60: "1h",
        1440: "1D",
    }
    if period_minutes not in resample_rule:
        raise ValueError(
            "period_minutes must be one of 1, 5, 10, 15, 30, 60, 1440."
        )
    if not isinstance(df.index, pd.DatetimeIndex):
        raise ValueError("DataFrame must have a DatetimeIndex.")

    rule = resample_rule[period_minutes]
    cols = [ghi_col, ghi_clear_col]
    # Right-closed bins: (t-1h, t] so the value at 11:00 is the mean over (10:00, 11:00]
    resample_kw = {"rule": rule, "closed": "right", "label": "right"}
    counts = df[cols].resample(**resample_kw).count()
    bin_size = df.resample(**resample_kw).size()
    # Keep only those periods where more than half the data points are present for ghi_col
    enough_data = counts[ghi_col] > (bin_size / 2)
    # Compute mean over each right-closed period, set to NaN where not enough data
    df_rs = df[cols].resample(**resample_kw).mean()
    df_rs.loc[~enough_data] = np.nan
    df_rs = df_rs.dropna(subset=[ghi_col])

    # Use mid-interval timestamps for solar position so zenith/AST are representative of the hour,
    # avoiding systematic bias (e.g. end-of-hour sun making k trend monotonically through the day).
    period_td = pd.Timedelta(minutes=period_minutes)
    times_mid = df_rs.index - period_td / 2

    result = engerer2_separation(
        times_mid, df_rs[ghi_col].values, lat, lon,
        ghi_clear=df_rs[ghi_clear_col].values,
        averaging_period=period_minutes
    )
    # Align result back to hour-end index for assignment (result index is mid-interval).
    result = result.set_index(df_rs.index)

    # First-future assignment: for each 1-min t, use the hourly k at the end of the hour containing t
    # e.g. 1-min in (10:00, 11:00] get the k at 11:00 (mean over (10:00, 11:00])
    k_series = result["k"].reindex(df.index, method="bfill")
    return np.asarray(k_series, dtype=float)

[docs] def erbs_separation(times, ghi, lat, lon, elev=0, min_mu0=0.065, max_zenith=87.0): """ Erbs irradiance separation [1]_: diffuse fraction $k$ from clearness index $k_t$, then DHI and BNI. Inputs are time, ghi, and location (lat, lon, elev); zenith and clearness index are computed inside. Piecewise formula (Erbs et al.): - $k_t \\leq 0.22$: $k = 1.0 - 0.09 k_t$ - $0.22 < k_t \\leq 0.80$: $k = 0.9511 - 0.1604 k_t + 4.388 k_t^2 - 16.638 k_t^3 + 12.336 k_t^4$ - $k_t > 0.80$: $k = 0.165$ Parameters ---------- times : pd.DatetimeIndex Timestamps. ghi : array-like Global horizontal irradiance. [W/m^2] lat : float Latitude. [degrees] lon : float Longitude. [degrees] elev : float, default 0 Site elevation. [m] Use same value as for zenith when comparing to pvlib. min_mu0 : float, default 0.065 Minimum $\\mu_0$ when computing $k_t$. max_zenith : float, default 87.0 Maximum zenith for valid BNI; beyond this BNI is set to 0. [degrees] Returns ------- out : pd.DataFrame DataFrame with index=times and columns ``k``, ``dhi``, ``bni`` (modeled). Raises ------ ValueError Propagated from :func:`_get_solar_and_kt` if ``times`` or ``ghi`` are invalid. References ---------- .. [1] Erbs, D. G., Klein, S. A., & Duffie, J. A. (1982). Estimation of the diffuse radiation fraction for hourly, daily and monthly-average global radiation. Solar Energy, 28(4), 293-302. """ ghi, ghi_extra, zenith, mu0, kt, night = _get_solar_and_kt( times, ghi, lat, lon, elev=elev, min_mu0=min_mu0, max_clearness_index=1.0 ) # Calculate diffuse fraction # For Kt <= 0.22, set the diffuse fraction k = 1.0 - 0.09 * kt # For Kt > 0.22 and Kt <= 0.8, set the diffuse fraction k = np.where( (kt > 0.22) & (kt <= 0.8), 0.9511 - 0.1604 * kt + 4.388 * kt ** 2 - 16.638 * kt ** 3 + 12.336 * kt ** 4, k ) # For Kt > 0.8, set the diffuse fraction k = np.where(kt > 0.8, 0.165, k) # This ensures the diffuse fraction is physically valid; values <0 or >1 are not physical. k = np.clip(k, 0.0, 1.0) # Calculate DHI and BNI dhi, bni = _k_to_dhi_bni(ghi, k, zenith, max_zenith=max_zenith) # Nighttime separation handled by _k_to_dhi_bni and zenith limits. return pd.DataFrame({"k": k, "dhi": dhi, "bni": bni}, index=times)
[docs] def brl_separation(times, ghi, lat, lon, min_mu0=0.065, max_zenith=87.0): """ BRL irradiance separation [1]_: diffuse fraction $k$ from logistic function of $k_t$, AST, $\\alpha$, $K_t$, $\\psi$. $k = 1 / (1 + \\exp(-5.38 + 6.63 k_t + 0.006\\,\\text{AST} - 0.007\\,\\alpha + 1.75 K_t + 1.31 \\psi))$. $\\psi$ at sunrise = $k_{t+1}$, at sunset = $k_{t-1}$, else $(k_{t-1}+k_{t+1})/2$. $K_t$ = daily clearness index. Parameters ---------- times : pd.DatetimeIndex Timestamps. ghi : array-like Global horizontal irradiance. [W/m^2] lat : float Latitude. [degrees] lon : float Longitude. [degrees] min_mu0 : float, default 0.065 Minimum $\\mu_0$ when computing $k_t$. max_zenith : float, default 87.0 Maximum zenith for valid BNI; beyond this BNI is set to 0. [degrees] Returns ------- out : pd.DataFrame DataFrame with index=times and columns ``k``, ``dhi``, ``bni`` (modeled). Raises ------ ValueError Propagated from :func:`_get_solar_and_kt` if ``times`` or ``ghi`` are invalid. References ---------- .. [1] Ridley, B., Boland, J., & Lauret, P. (2010). Modelling of diffuse solar fraction with multiple predictors. Renewable Energy, 35(2), 478-483. """ ghi, ghi_extra, zenith, mu0, kt, night = _get_solar_and_kt( times, ghi, lat, lon, min_mu0=min_mu0, max_clearness_index=1.0 ) # Daily clearness index Kt (Eq. 7), only when enough daytime data Kt = _brl_daily_clearness_index(times, ghi, ghi_extra, night) good_day = np.isfinite(Kt) # Apparent solar time AST (hours) ast = _apparent_solar_time(times, lon) # Solar altitude alpha (degrees) = 90 - zenith alpha = 90.0 - zenith # psi: piecewise from kt at adjacent timesteps dates = np.array([t.date() for t in times]) psi = _brl_psi(kt, night, dates) # For days without a valid Kt, psi is not defined psi = np.where(good_day, psi, np.nan) # k = 1 / (1 + exp(...)); hourly kt and daily Kt exponent = ( -5.38 + 6.63 * kt + 0.006 * ast - 0.007 * alpha + 1.75 * Kt + 1.31 * psi ) with np.errstate(invalid="ignore", over="ignore"): k = 1.0 / (1.0 + np.exp(exponent)) # This ensures the diffuse fraction is physically valid; values <0 or >1 are not physical. k = np.clip(k, 0.0, 1.0) # Nighttime or days without valid Kt: k is NaN k = np.where(night | ~good_day, np.nan, k) # Calculate DHI and BNI dhi, bni = _k_to_dhi_bni(ghi, k, zenith, max_zenith=max_zenith, force_nan=~good_day) # Nighttime separation handled by _k_to_dhi_bni and zenith limits. return pd.DataFrame({"k": k, "dhi": dhi, "bni": bni}, index=times)
[docs] def engerer2_separation(times, ghi, lat, lon, ghi_clear, averaging_period=1): """ Engerer2 irradiance separation: estimate diffuse fraction ($k$), DHI and BNI from GHI. Caller must provide clear-sky GHI (e.g. from a clear-sky model or add_clearsky_columns). Parameters ---------- times : pd.DatetimeIndex Timestamps. ghi : array-like Global horizontal irradiance. [W/m^2] lat : float Latitude. [degrees] lon : float Longitude. [degrees] ghi_clear : array-like Clear-sky GHI. [W/m^2] Same length as times. Required. averaging_period : int, default 1 Coefficient set for resolution. [minutes] 1, 5, 10, 15, 30, 60, or 1440. Returns ------- out : pd.DataFrame DataFrame with index=times and columns ``k``, ``dhi``, ``bni`` (modeled). Raises ------ ValueError If ``averaging_period`` is not in the supported set, ``times`` is not a :class:`~pandas.DatetimeIndex`, or ``ghi`` / ``ghi_clear`` lengths mismatch. References ---------- .. [1] Bright, J. M., & Engerer, N. A. (2019). Engerer2: Global re-parameterisation, update, and validation of an irradiance separation model at different temporal resolutions. Journal of Renewable and Sustainable Energy, 11(3), 033701. .. [2] Engerer, N. A. (2015). Minute resolution estimates of the diffuse fraction of global irradiance for southeastern Australia. Solar Energy, 116, 215-237. """ if averaging_period not in ENGERER2_PARAMS: raise ValueError( "averaging_period must be one of 1, 5, 10, 15, 30, 60, 1440 (minutes)." ) if not isinstance(times, pd.DatetimeIndex): raise ValueError("times must be a pd.DatetimeIndex.") ghi = np.asarray(ghi, dtype=float) if len(ghi) != len(times): raise ValueError("ghi must have the same length as times.") ghi_clear = np.asarray(ghi_clear, dtype=float) if len(ghi_clear) != len(times): raise ValueError("ghi_clear must have the same length as times.") ghi, ghi_extra, zenith, mu0, kt, night = _get_solar_and_kt( times, ghi, lat, lon, min_mu0=0.065, max_clearness_index=1.0 ) ast = _apparent_solar_time(times, lon) ghi_extra_safe = np.where(ghi_extra > 0, ghi_extra, np.nan) with np.errstate(divide="ignore", invalid="ignore"): ktc = ghi_clear / ghi_extra_safe dktc = ktc - kt cloud_enh = np.where(ghi - ghi_clear > 0.015, ghi - ghi_clear, 0.0) # np.where evaluates both branches; divide(..., where=) skips ghi<=0 (no divide warning). k_de = np.zeros_like(ghi, dtype=float) np.divide(cloud_enh, ghi, out=k_de, where=ghi > 0) ktc = np.where(night, np.nan, ktc) dktc = np.where(night, np.nan, dktc) ast = np.where(night, np.nan, ast) k_de = np.where(night, np.nan, k_de) # Engerer2 logistic formula c, b0, b1, b2, b3, b4, b5 = ENGERER2_PARAMS[averaging_period] with np.errstate(invalid="ignore"): k = c + (1 - c) / (1 + np.exp( b0 + b1 * kt + b2 * ast + b3 * zenith + b4 * dktc )) + b5 * k_de k = np.clip(k, 0.0, 1.0) dhi, bni = _k_to_dhi_bni(ghi, k, zenith, max_zenith=87.0) # Nighttime separation handled by _k_to_dhi_bni and zenith limits. return pd.DataFrame({"k": k, "dhi": dhi, "bni": bni}, index=times)
[docs] def yang4_separation(times, ghi, lat, lon, ghi_clear): """ Yang4 irradiance separation: diffuse fraction k_d from k_t, AST, Z, Δk_tc, k_de, and Engerer2 60-min k. k_d^YANG4 = C + (1-C)/(1 + exp(β0 + β1*k_t + β2*AST + β3*Z + β4*Δk_tc + β6*k_d,60min^ENGERER2)) + β5*k_de. Uses YANG2 coefficient set (TABLE III) from 1-min SURFRAD data. Caller must provide clear-sky GHI (e.g. from a clear-sky model or add_clearsky_columns). Parameters ---------- times : pd.DatetimeIndex Timestamps. ghi : array-like Global horizontal irradiance. [W/m^2] lat : float Latitude. [degrees] lon : float Longitude. [degrees] ghi_clear : array-like Clear-sky GHI. [W/m^2] Same length as times. Required. Returns ------- out : pd.DataFrame DataFrame with index=times and columns ``k``, ``dhi``, ``bni`` (modeled). Raises ------ ValueError If ``times`` is not a :class:`~pandas.DatetimeIndex`, lengths mismatch, or :func:`_engerer2_k_at_resolution` rejects inputs (e.g. missing ``ghi_clear``). References ---------- .. [1] Yang, D. (2021). Temporal-resolution cascade model for separation of 1-min beam and diffuse irradiance. Journal of Renewable and Sustainable Energy, 13(5), 053703. .. [2] Yang, D., & Boland, J. (2019). Satellite-augmented diffuse solar radiation separation models. Journal of Renewable and Sustainable Energy, 11(2), 023704. """ if not isinstance(times, pd.DatetimeIndex): raise ValueError("times must be a pd.DatetimeIndex.") ghi = np.asarray(ghi, dtype=float) if len(ghi) != len(times): raise ValueError("ghi must have the same length as times.") ghi_clear = np.asarray(ghi_clear, dtype=float) if len(ghi_clear) != len(times): raise ValueError("ghi_clear must have the same length as times.") # Build minimal df for _engerer2_k_at_resolution (resampling) df_min = pd.DataFrame({"ghi": ghi, "ghi_clear": ghi_clear}, index=times) k_engerer2_60 = _engerer2_k_at_resolution( df_min, lat, lon, 60, ghi_col="ghi", ghi_clear_col="ghi_clear" ) ghi, ghi_extra, zenith, mu0, kt, night = _get_solar_and_kt( times, ghi, lat, lon, min_mu0=0.065, max_clearness_index=1.0 ) ast = _apparent_solar_time(times, lon) ghi_extra_safe = np.where(ghi_extra > 0, ghi_extra, np.nan) with np.errstate(divide="ignore", invalid="ignore"): kt = ghi / ghi_extra_safe ktc = ghi_clear / ghi_extra_safe dktc = ktc - kt cloud_enh = np.where(ghi - ghi_clear > 0.015, ghi - ghi_clear, 0.0) k_de = np.zeros_like(ghi, dtype=float) np.divide(cloud_enh, ghi, out=k_de, where=ghi > 0) dktc = np.where(night, np.nan, dktc) ast = np.where(night, np.nan, ast) k_de = np.where(night, np.nan, k_de) k_engerer2_60 = np.where(night, np.nan, k_engerer2_60) # Yang4 logistic formula C, b0, b1, b2, b3, b4, b5, b6 = YANG4_PARAMS exponent = b0 + b1 * kt + b2 * ast + b3 * zenith + b4 * dktc + b6 * k_engerer2_60 with np.errstate(invalid="ignore", over="ignore"): k = C + (1 - C) / (1 + np.exp(exponent)) + b5 * k_de k = np.clip(k, 0.0, 1.0) dhi, bni = _k_to_dhi_bni(ghi, k, zenith, max_zenith=87.0) # Nighttime separation handled by _k_to_dhi_bni and zenith limits. return pd.DataFrame({"k": k, "dhi": dhi, "bni": bni}, index=times)