"""
clear-sky radiation models.
Provides theoretical reference for QC checks and separation modeling.
"""
import numpy as np
import pandas as pd
from bsrn.physics import geometry
from bsrn.constants import (
BSRN_STATIONS,
LINKE_TURBIDITY,
)
from bsrn.io.mcclear import fetch_mcclear
from bsrn.io.merra2 import fetch_rest2
def get_relative_airmass(zenith, model='kastenyoung1989'):
"""
Calculate relative (not pressure-adjusted) airmass at sea level.
Parameters
----------
zenith : numeric
Solar zenith angle ($Z$). [degrees]
model : string, default 'kastenyoung1989'
Available models include:
* 'kasten1966' - See [1]_
* 'kastenyoung1989' (default) - See [2]_
Returns
-------
airmass_relative : numeric
Relative airmass at sea level. Returns NaN values for any
zenith angle greater than 90 degrees. [unitless]
Raises
------
ValueError
If *model* is not ``'kastenyoung1989'`` or ``'kasten1966'``.
References
----------
.. [1] Kasten, F. (1965). A New Table and Approximation Formula for the
Relative Optical Air Mass (Technical Report 136). Hanover, NH:
CRREL (U.S. Army).
.. [2] Kasten, F., & Young, A. T. (1989). Revised optical air mass
tables and approximation formula. Applied Optics, 28(22), 4735-4738.
"""
# Set zenith values greater than 90 to nans
zenith = np.where(zenith > 90, np.nan, zenith)
model = model.lower()
if 'kastenyoung1989' == model:
zenith_rad = np.radians(zenith)
am = (1.0 / (np.cos(zenith_rad) +
0.50572*((6.07995 + (90 - zenith)) ** - 1.6364)))
elif 'kasten1966' == model:
zenith_rad = np.radians(zenith)
am = 1.0 / (np.cos(zenith_rad) + 0.15*((93.885 - zenith) ** - 1.253))
else:
raise ValueError(f'{model} is not a valid model for relative airmass.')
if isinstance(zenith, pd.Series):
am = pd.Series(am, index=zenith.index)
return am
def get_absolute_airmass(airmass_relative, pressure=101325.0):
"""
Calculates absolute (pressure-corrected) airmass.
Parameters
----------
airmass_relative : numeric
Relative optical air mass. [unitless]
pressure : numeric, default 101325.0
Surface pressure. [Pa]
Returns
-------
airmass_absolute : numeric
Absolute optical air mass. [unitless]
"""
return airmass_relative * pressure / 101325.
[docs]
def ineichen_model(apparent_zenith, airmass_absolute, lt, elev, bni_extra):
"""
Implementation of Ineichen clear-sky model [1]_ matching the formulation from pvlib.
Parameters
----------
apparent_zenith : numeric
Apparent (refraction-corrected) solar zenith angle ($Z$). [degrees]
airmass_absolute : numeric
Absolute (pressure-corrected) air mass ($AM_a$). [unitless]
lt : numeric
Linke turbidity factor ($T_L$). [unitless]
elev : float
Elevation. [m]
bni_extra : numeric
Extraterrestrial beam normal irradiance ($E_{0n}$). [W/m^2]
Returns
-------
ghi_clear : numeric
Clear-sky global horizontal irradiance ($G_{hc}$). [W/m^2]
bni_clear : numeric
Clear-sky beam normal irradiance ($B_{nc}$). [W/m^2]
dhi_clear : numeric
Clear-sky diffuse horizontal irradiance ($D_{hc}$). [W/m^2]
References
----------
.. [1] Ineichen, P., & Perez, R. (2002). A new airmass independent
formulation for the Linke turbidity coefficient. Solar Energy, 73(3), 151-157.
"""
mu0 = np.maximum(np.cos(np.radians(apparent_zenith)), 0)
# Altitude-dependent coefficients
fh1 = np.exp(-elev / 8000.0)
fh2 = np.exp(-elev / 1250.0)
cg1 = 5.09e-05 * elev + 0.868
cg2 = 3.92e-05 * elev + 0.0387
# GHI calculation
# ghi_clear matches pvlib 'ghi' logic and NaN handling
ghi_exp = np.exp(-cg2 * airmass_absolute * (fh1 + fh2 * (lt - 1)))
ghi_clear = cg1 * bni_extra * mu0 * lt / lt * np.fmax(ghi_exp, 0)
# BNI calculation
# First component: direct beam attenuation
b = 0.664 + 0.163 / fh1
bni_clear_1 = b * np.exp(-0.09 * airmass_absolute * (lt - 1))
bni_clear_1 = bni_extra * np.fmax(bni_clear_1, 0)
# Second component: empirical correction
with np.errstate(divide='ignore', invalid='ignore'):
bni_multiplier = ((1.0 - (0.1 - 0.2 * np.exp(-lt)) / (0.1 + 0.882 / fh1)) /
mu0)
bni_clear_2 = ghi_clear * np.fmin(np.fmax(bni_multiplier, 0), 1e20)
bni_clear = np.minimum(bni_clear_1, bni_clear_2)
# DHI by subtraction
dhi_clear = ghi_clear - bni_clear * mu0
return ghi_clear, bni_clear, dhi_clear
[docs]
def rest2_model(index, zenith, rest2_inputs, no2=0.0002):
"""
REST2 clear-sky model [1]_ translated from the provided R implementation.
Parameters
----------
index : pd.DatetimeIndex
Time index for the calculation.
zenith : array-like
Solar zenith angle ($Z$). [degrees]
rest2_inputs : pd.DataFrame
Input dataframe from `fetch_rest2`, with columns:
`PS`, `ALBEDO`, `ALPHA`, `BETA`, `TO3`, `TQV`.
no2 : float, default 0.0002
Total column nitrogen dioxide amount.
Returns
-------
ghi_clear : np.ndarray
Clear-sky global horizontal irradiance ($G_{hc}$). [W/m^2]
bni_clear : np.ndarray
Clear-sky beam normal irradiance ($B_{nc}$). [W/m^2]
dhi_clear : np.ndarray
Clear-sky diffuse horizontal irradiance ($D_{hc}$). [W/m^2]
Raises
------
ValueError
If ``rest2_inputs`` is not a :class:`~pandas.DataFrame`, row counts differ,
or required columns are missing.
References
----------
.. [1] Gueymard, C. A. (2008). REST2: High-performance solar radiation model
for cloudless-sky irradiance, illuminance, and photosynthetically active
radiation: Validation with a benchmark dataset. Solar Energy, 82(3), 272-285.
"""
idx = pd.DatetimeIndex(index)
if not isinstance(rest2_inputs, pd.DataFrame):
raise ValueError("rest2_inputs must be a DataFrame.")
if len(idx) != len(rest2_inputs):
raise ValueError("index and rest2_inputs must have equal length.")
required = {"PS", "ALBEDO", "ALPHA", "BETA", "TO3", "TQV"}
missing = required - set(rest2_inputs.columns)
if missing:
raise ValueError(f"REST2 inputs missing required columns: {sorted(missing)}")
# Read REST2 inputs directly from reanalysis preprocessing.
ps = np.asarray(rest2_inputs["PS"], dtype=float)
albedo_val = np.asarray(rest2_inputs["ALBEDO"], dtype=float)
alpha = np.asarray(rest2_inputs["ALPHA"], dtype=float)
beta = np.asarray(rest2_inputs["BETA"], dtype=float)
ozone = np.asarray(rest2_inputs["TO3"], dtype=float)
wv = np.asarray(rest2_inputs["TQV"], dtype=float)
# 300 < p < 1100 hPa; 0 < uo < 0.6; 0 < un < 0.03; 0 < w < 10 cm; 0 < alpha < 2.5; 0 < beta < 1.1; 0 < albedo < 1
ps = np.clip(ps, 300.0, 1100.0)
ozone = np.clip(ozone, 0.0, 0.6)
wv = np.clip(wv, 0.0, 10.0)
alpha = np.clip(alpha, 0.0, 2.5)
beta = np.clip(beta, 0.0, 1.1)
albedo_val = np.clip(albedo_val, 0.0, 1.0)
no2 = np.clip(no2, 0.0, 0.03)
zenith = np.asarray(zenith, dtype=float)
daytime = (zenith < 90.0) & np.isfinite(zenith)
if not np.any(daytime):
zeros = np.zeros(len(idx), dtype=float)
return zeros, zeros, zeros
# Clip and replace night/NaN to avoid domain errors (will be masked later)
zenith = np.where(daytime, np.clip(zenith, 0.0, 89.999), 89.999)
zenith_rad = np.radians(zenith)
mu0 = np.cos(zenith_rad)
# extraterrestrial BNI
bni_extra = geometry.get_bni_extra(idx).to_numpy(dtype=float)
# Air mass formulas per REST2 reference (complex dtype for edge cases).
complex_temp = np.array(zenith, dtype=np.complex128)
# air mass for aerosols extinction
ama = np.abs(np.power(mu0 + 0.16851 * np.power(complex_temp, 0.18198) / np.power(95.318 - complex_temp, 1.9542), -1))
# air mass for water vapor absorption
amw = np.abs(np.power(mu0 + 0.10648 * np.power(complex_temp, 0.11423) / np.power(93.781 - complex_temp, 1.9203), -1))
# air mass for ozone absorption
amo = np.abs(np.power(mu0 + 1.0651 * np.power(complex_temp, 0.6379) / np.power(101.8 - complex_temp, 2.2694), -1))
# air mass for Rayleigh scattering and uniformly mixed gases absorption
amR = np.abs(np.power(mu0 + 0.48353 * np.power(complex_temp, 0.095846) / np.power(96.741 - complex_temp, 1.754), -1))
amRe = np.abs((ps / 1013.25) * np.power(mu0 + 0.48353 * np.power(complex_temp, 0.095846) / np.power(96.741 - complex_temp, 1.754), -1))
# Band 1
# transmittance for Rayleigh scattering
TR1 = (1 + 1.8169 * amRe - 0.033454 * amRe ** 2) / (1 + 2.063 * amRe + 0.31978 * amRe ** 2)
# transmittance for uniformly mixed gases absorption
Tg1 = (1 + 0.95885 * amRe + 0.012871 * amRe ** 2) / (1 + 0.96321 * amRe + 0.015455 * amRe ** 2)
# transmittance for Ozone absorption
uo = ozone
f1 = uo * (10.979 - 8.5421 * uo) / (1 + 2.0115 * uo + 40.189 * uo ** 2)
f2 = uo * (-0.027589 - 0.005138 * uo) / (1 - 2.4857 * uo + 13.942 * uo ** 2)
f3 = uo * (10.995 - 5.5001 * uo) / (1 + 1.6784 * uo + 42.406 * uo ** 2)
To1 = (1 + f1 * amo + f2 * amo ** 2) / (1 + f3 * amo)
# transmittance for Nitrogen dioxide absorption
un = np.full_like(zenith, float(no2))
g1 = (0.17499 + 41.654 * un - 2146.4 * np.power(un, 2)) / (1 + 22295. * np.power(un, 2))
g2 = un * (-1.2134 + 59.324 * un) / (1 + 8847.8 * np.power(un, 2))
g3 = (0.17499 + 61.658 * un + 9196.4 * np.power(un, 2)) / (1 + 74109. * np.power(un, 2))
Tn1_middle = (1 + g1 * amw + g2 * np.power(amw, 2)) / (1 + g3 * amw)
Tn1_middle = np.minimum(Tn1_middle, 1.0)
Tn1 = Tn1_middle
Tn1166_middle = (1 + g1 * 1.66 + g2 * np.power(1.66, 2)) / (1 + g3 * 1.66)
Tn1166_middle = np.minimum(Tn1166_middle, 1.0)
Tn1166 = Tn1166_middle
# transmittance for Water Vapor absorption
h1 = wv * (0.065445 + 0.00029901 * wv) / (1 + 1.2728 * wv)
h2 = wv * (0.065687 + 0.0013218 * wv) / (1 + 1.2008 * wv)
Tw1 = (1 + h1 * amw) / (1 + h2 * amw)
# at air mass=1.66
Tw1166 = (1 + h1 * 1.66) / (1 + h2 * 1.66)
# coefficients of angstrom_alpha
AB1 = beta
alph1 = alpha
d0 = 0.57664 - 0.024743 * alph1
d1 = (0.093942 - 0.2269 * alph1 + 0.12848 * alph1 ** 2) / (1 + 0.6418 * alph1)
d2 = (-0.093819 + 0.36668 * alph1 - 0.12775 * alph1 ** 2) / (1 - 0.11651 * alph1)
d3 = alph1 * (0.15232 - 0.087214 * alph1 + 0.012664 * alph1 ** 2) / (
1 - 0.90454 * alph1 + 0.26167 * alph1 ** 2
)
# below Eq.7a
ua1 = np.log(1 + ama * AB1)
lam1 = (d0 + d1 * ua1 + d2 * ua1 ** 2) / (1 + d3 * ua1 ** 2)
# Aerosol transmittance Eq.6,7
ta1 = np.abs(AB1 * np.power(lam1, -1 * alph1))
TA1 = np.exp(-ama * ta1)
# Aerosol scattering transmittance
# w1=0.92 recommended by author
TAS1 = np.exp(-ama * 0.92 * ta1)
# forward scattering fractions for Rayleigh extinction Eq.10,11
BR1 = 0.5 * (0.89013 - 0.0049558 * amR + 0.000045721 * amR ** 2)
# the aerosol forward scatterance factor
Ba = 1 - np.exp(-0.6931 - 1.8326 * mu0)
# Aerosol scattering correction factor Appendix
g0 = (3.715 + 0.368 * ama + 0.036294 * np.power(ama, 2)) / (1 + 0.0009391 * np.power(ama, 2))
g1 = (-0.164 - 0.72567 * ama + 0.20701 * np.power(ama, 2)) / (1 + 0.0019012 * np.power(ama, 2))
g2 = (-0.052288 + 0.31902 * ama + 0.17871 * np.power(ama, 2)) / (1 + 0.0069592 * np.power(ama, 2))
F1 = (g0 + g1 * ta1) / (1 + g2 * ta1)
# sky albedo Appendix
rs1 = (0.13363 + 0.00077358 * alph1 + AB1 * (0.37567 + 0.22946 * alph1) / (1 - 0.10832 * alph1)) / (
1 + AB1 * (0.84057 + 0.68683 * alph1) / (1 - 0.08158 * alph1)
)
# ground albedo
rg = albedo_val
# Band 2
# transmittance for Rayleigh scattering
TR2 = (1 - 0.010394 * amRe) / (1 - 0.00011042 * amRe ** 2)
# transmittance for uniformly mixed gases absorption
Tg2 = (1 + 0.27284 * amRe - 0.00063699 * amRe ** 2) / (1 + 0.30306 * amRe)
# transmittance for Ozone absorption
# Ozone (none)
To2 = 1.0
# transmittance for Nitrogen dioxide absorption
# Nitrogen (none)
Tn2 = 1.0
# at air mass=1.66
Tn2166 = 1.0
# transmittance for water vapor absorption
c1 = wv * (19.566 - 1.6506 * wv + 1.0672 * wv ** 2) / (1 + 5.4248 * wv + 1.6005 * wv ** 2)
c2 = wv * (0.50158 - 0.14732 * wv + 0.047584 * wv ** 2) / (1 + 1.1811 * wv + 1.0699 * wv ** 2)
c3 = wv * (21.286 - 0.39232 * wv + 1.2692 * wv ** 2) / (1 + 4.8318 * wv + 1.412 * wv ** 2)
c4 = wv * (0.70992 - 0.23155 * wv + 0.096514 * wv ** 2) / (1 + 0.44907 * wv + 0.75425 * wv ** 2)
Tw2 = (1 + c1 * amw + c2 * amw ** 2) / (1 + c3 * amw + c4 * amw ** 2)
# at air mass=1.66
Tw2166 = (1 + c1 * 1.66 + c2 * 1.66 ** 2) / (1 + c3 * 1.66 + c4 * 1.66 ** 2)
# coefficients of angstrom_alpha
AB2 = beta
alph2 = alpha
e0 = (1.183 - 0.022989 * alph2 + 0.020829 * alph2 ** 2) / (1 + 0.11133 * alph2)
e1 = (-0.50003 - 0.18329 * alph2 + 0.23835 * alph2 ** 2) / (1 + 1.6756 * alph2)
e2 = (-0.50001 + 1.1414 * alph2 + 0.0083589 * alph2 ** 2) / (1 + 11.168 * alph2)
e3 = (-0.70003 - 0.73587 * alph2 + 0.51509 * alph2 ** 2) / (1 + 4.7665 * alph2)
# below Eq.7a
ua2 = np.log(1 + ama * AB2)
lam2 = (e0 + e1 * ua2 + e2 * ua2 ** 2) / (1 + e3 * ua2)
# Aerosol transmittance below Eq.7a
lam2_temp = np.array(lam2, dtype=np.complex128)
ta2 = np.abs(AB2 * np.power(lam2_temp, -1 * alph2))
TA2 = np.exp(-1 * ama * ta2)
# Aerosol scattering transmittance
# w2=0.84 recommended by author
TAS2 = np.exp(-1 * ama * 0.84 * ta2)
# forward scattering fractions for Rayleigh extinction
# multi scatter negligible in Band 2
BR2 = 0.5
# the aerosol forward scatterance factor
Ba = 1 - np.exp(-0.6931 - 1.8326 * mu0)
# Aerosol scattering correction
h0 = (3.4352 + 0.65267 * ama + 0.00034328 * np.power(ama, 2)) / (1 + 0.034388 * np.power(ama, 1.5))
h1 = (1.231 - 1.63853 * ama + 0.20667 * np.power(ama, 2)) / (1 + 0.1451 * np.power(ama, 1.5))
h2 = (0.8889 - 0.55063 * ama + 0.50152 * np.power(ama, 2)) / (1 + 0.14865 * np.power(ama, 1.5))
F2 = (h0 + h1 * ta2) / (1 + h2 * ta2)
# sky albedo Appendix
rs2 = (0.010191 + 0.00085547 * alph2 + AB2 * (0.14618 + 0.062758 * alph2) / (1 - 0.19402 * alph2)) / (
1 + AB2 * (0.58101 + 0.17426 * alph2) / (1 - 0.17586 * alph2)
)
# ground albedo
rg = albedo_val
# irradiance BAND1
E0n1 = bni_extra * 0.46512
# direct beam irradiance
Ebn1 = E0n1 * TR1 * Tg1 * To1 * Tn1 * Tw1 * TA1
# the incident diffuse irradiance on a perfectly absorbing ground
Edp1 = E0n1 * mu0 * To1 * Tg1 * Tn1166 * Tw1166 * (
BR1 * (1 - TR1) * np.power(TA1, 0.25) + Ba * F1 * TR1 * (1 - np.power(TAS1, 0.25)))
# multiple reflections between the ground and the atmosphere
Edd1 = rg * rs1 * (Ebn1 * mu0 + Edp1) / (1 - rg * rs1)
# irradiance BAND2
E0n2 = bni_extra * 0.51951
# direct beam irradiance
Ebn2 = E0n2 * TR2 * Tg2 * To2 * Tn2 * Tw2 * TA2
# the incident diffuse irradiance on a perfectly absorbing ground
Edp2 = E0n2 * mu0 * To2 * Tg2 * Tn2166 * Tw2166 * (
BR2 * (1 - TR2) * np.power(TA2, 0.25) + Ba * F2 * TR2 * (1 - np.power(TAS2, 0.25)))
# multiple reflections between the ground and the atmosphere
Edd2 = rg * rs2 * (Ebn2 * mu0 + Edp2) / (1 - rg * rs2)
# TOTALS BAND1+BAND2
# direct normal irradiance
Ebnrest2 = Ebn1 + Ebn2
# diffuse horizontal irradiance
Edhrest2 = Edp1 + Edd1 + Edp2 + Edd2
# global horizontal irradiance
Eghrest2 = Ebnrest2 * mu0 + Edhrest2
# Convert to Python convention output names
bni_clear = Ebnrest2
dhi_clear = Edhrest2
ghi_clear = Eghrest2
# Nighttime filter and non-negative QC (matching provided Python 'lower=0' logic)
bni_clear = np.where(daytime, np.where(bni_clear < 0.0, np.nan, bni_clear), 0.0)
dhi_clear = np.where(daytime, np.where(dhi_clear < 0.0, np.nan, dhi_clear), 0.0)
ghi_clear = np.where(daytime, np.where(ghi_clear < 0.0, np.nan, ghi_clear), 0.0)
# Return in Python convention order: ghi, bni, dhi
return ghi_clear, bni_clear, dhi_clear
[docs]
def threlkeld_jordan_model(zenith, day_of_year):
"""
Threlkeld-Jordan clear-sky GHI model [1]_.
The published Engerer2 reference uses this for the ktc predictor.
Parameters
----------
zenith : array-like
Solar zenith angle. [degrees]
day_of_year : array-like
Day of year. [1–366]
Returns
-------
ghi_clear : np.ndarray
Clear-sky GHI ($G_{hc}$). [W/m^2]
References
----------
.. [1] Threlkeld, J. L., & Jordan, R. C. (1958). Direct solar radiation
availability on clear days. ASHRAE Trans, 64(1), 45-105.
"""
mu0 = np.maximum(np.cos(np.radians(zenith)), 0)
doy = np.asarray(day_of_year, dtype=float)
# Avoid division by zero when sun is below horizon
# Use small floor for stability
mu0_safe = np.fmax(mu0, 1e-10)
a_tj = 1160 + 75 * np.sin(np.radians(360 * (doy - 275) / 365))
k_tj = 0.174 + 0.035 * np.sin(np.radians(360 * (doy - 100) / 365))
c_tj = 0.095 + 0.04 * np.sin(np.radians(360 * (doy - 100) / 365))
dni_clear = a_tj * np.exp(-k_tj / mu0_safe)
ghi_clear = dni_clear * mu0 + c_tj * dni_clear
# Mask night
ghi_clear = np.where(mu0 > 0, ghi_clear, 0.0)
return ghi_clear
def calculate_vapor_pressure(temp, rh):
"""
Calculates actual vapor pressure ($e_a$) in hPa using the Magnus-Tetens formula.
Parameters
----------
temp : numeric
Air temperature ($T$). [°C]
rh : numeric
Relative humidity ($RH$). [%]
Returns
-------
ea : numeric
Actual vapor pressure ($e_a$). [hPa]
References
----------
.. [1] Murray, F. W. (1966). On the computation of saturation vapor
pressure (Technical Report P3423). Santa Monica, CA: RAND Corp.
"""
# 1. Calculate saturation vapor pressure (es)
# 6.112 is the saturation pressure at 0°C in hPa
es = 6.112 * np.exp((17.67 * temp) / (temp + 243.5))
# 2. Calculate actual vapor pressure (ea)
ea = es * (rh / 100.0)
return ea
[docs]
def brutsaert_model(temp_c, rh):
"""
Calculates clear-sky downward longwave radiation ($L_{dc}$) using Brutsaert (1975) [1]_.
Parameters
----------
temp_c : numeric
Air temperature. [°C]
rh : numeric
Relative humidity. [%]
Returns
-------
lwd_clear : numeric
Clear-sky downward longwave radiation ($L_{dc}$). [W/m^2]
References
----------
.. [1] Brutsaert, W. (1975). On a derivable formula for long-wave
radiation from clear skies. Water Resources Research, 11(5), 742-744.
"""
# Constants
sigma = 5.670373e-8 # Stefan-Boltzmann constant (W/m^2/K^4)
temp_k = temp_c + 273.15 # Convert to Kelvin
# Get vapor pressure (ea) in hPa
ea = calculate_vapor_pressure(temp_c, rh)
# Brutsaert (2005) updated emissivity formula
# epsilon = 1.323 * (ea / Ta)^(1/7) for ea in hPa (millibars)
emissivity = 1.323 * (ea / temp_k)**(1/7)
# Calculate final downward radiation (L_down)
lwd_clear = emissivity * sigma * (temp_k**4)
return lwd_clear
[docs]
def add_clearsky_columns(df, station_code=None, lat=None, lon=None, elev=None,
model="ineichen", mcclear_email=None):
"""
Adds clear-sky radiation columns to a DataFrame based on its DatetimeIndex.
Location can be given by BSRN station code or by explicit lat/lon/elev
(e.g. for non-BSRN stations), consistent with the QC wrapper metadata pattern.
Parameters
----------
df : pd.DataFrame
Input data with pd.DatetimeIndex.
station_code : str, optional
BSRN station abbreviation. [e.g. 'QIQ'] Used if lat/lon/elev not provided.
lat : float, optional
Latitude. [degrees] Required for non-BSRN stations if station_code omitted.
lon : float, optional
Longitude. [degrees] Required for non-BSRN stations if station_code omitted.
elev : float, optional
Elevation. [m] Required for non-BSRN stations if station_code omitted.
model : str, default 'ineichen'
Clear-sky model to use. ['ineichen', 'mcclear', 'rest2', or 'tj']
mcclear_email : str, optional
SoDa account email used when downloading McClear automatically
for the 'mcclear' model.
Returns
-------
df : pd.DataFrame
DataFrame with added _clear columns.
Raises
------
ValueError
If ``df.index`` is not a :class:`~pandas.DatetimeIndex`.
ValueError
If solar geometry columns are missing and the DataFrame frequency is >5 min,
causing the automatic `add_solpos_columns` fallback to fail.
ValueError
If neither a valid station_code nor complete (lat, lon, elev) is provided.
"""
if not isinstance(df.index, pd.DatetimeIndex):
raise ValueError("DataFrame index must be a pandas DatetimeIndex.")
# Resolve metadata: explicit lat/lon/elev or BSRN lookup (same pattern as QC wrapper)
if lat is not None and lon is not None and elev is not None:
pass # use provided coordinates
elif station_code is not None and station_code in BSRN_STATIONS:
meta = BSRN_STATIONS[station_code]
lat, lon, elev = meta["lat"], meta["lon"], meta["elev"]
elif station_code is not None:
raise ValueError(
f"Station '{station_code}' not found in BSRN registry. "
"For non-BSRN stations, provide 'lat', 'lon', and 'elev' explicitly."
)
else:
raise ValueError(
"Insufficient metadata. Provide a valid BSRN 'station_code' or "
"explicit 'lat', 'lon', and 'elev'."
)
# Ensure solar geometry columns exist
required_cols = {"zenith", "apparent_zenith", "bni_extra"}
if not required_cols.issubset(df.columns):
if len(df.index) > 1:
median_dt = pd.Series(df.index).diff().median()
if pd.notna(median_dt) and median_dt > pd.Timedelta(minutes=5):
raise ValueError(
f"Geometrical error: Generating clear-sky models on low-frequency data (timestep ≈ {median_dt}) "
"introduces severe inaccuracies. Always calculate clear-sky at high resolution (e.g., 1-minute)."
)
solpos = geometry.get_solar_position(df.index, lat=lat, lon=lon, elev=elev)
zenith = solpos["zenith"].round(3)
apparent_zenith = solpos["apparent_zenith"].round(3)
bni_extra = geometry.get_bni_extra(df.index).round(3)
else:
zenith = df["zenith"]
apparent_zenith = df["apparent_zenith"]
bni_extra = df["bni_extra"]
if model.lower() == "ineichen":
# Handle monthly Linke turbidity values
month_names = ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
default_lt = {m: 3.0 for m in month_names}
lt_mapping = LINKE_TURBIDITY.get(station_code, default_lt)
# Map months to lt
months = df.index.month - 1
lt_series = np.array([lt_mapping[month_names[m]] for m in months])
# Airmass calculations
am_rel = get_relative_airmass(zenith)
# Use pvlib-equivalent elevation-to-pressure conversion in Pa
pressure = geometry.get_pressure_from_elevation(elev)
am_abs = get_absolute_airmass(am_rel, pressure)
# Calculate clear-sky components
ghi_clear, bni_clear, dhi_clear = ineichen_model(apparent_zenith, am_abs, lt_series, elev, bni_extra)
elif model.lower() == "mcclear":
if mcclear_email is None:
raise ValueError(
"mcclear_email is required when using model='mcclear'."
)
mcclear_data = fetch_mcclear(
df.index,
latitude=lat,
longitude=lon,
elev=elev,
email=mcclear_email,
)
aligned = mcclear_data
ghi_clear = aligned["ghi_clear"].to_numpy(dtype=float)
bni_clear = aligned["bni_clear"].to_numpy(dtype=float)
dhi_clear = aligned["dhi_clear"].to_numpy(dtype=float)
elif model.lower() == "rest2":
if station_code is None:
raise ValueError(
"station_code is required when model='rest2'."
)
rest2_data = fetch_rest2(df.index, station_code)
ghi_clear, bni_clear, dhi_clear = rest2_model(df.index, zenith, rest2_data)
elif model.lower() in ("threlkeld_jordan", "tj"):
# Threlkeld-Jordan (GHI only; for engerer2)
ghi_clear = threlkeld_jordan_model(zenith, df.index.dayofyear.values)
bni_clear = np.full_like(ghi_clear, np.nan)
dhi_clear = np.full_like(ghi_clear, np.nan)
else:
raise ValueError(f"Unknown model: {model}")
df["ghi_clear"] = ghi_clear
df["bni_clear"] = bni_clear
df["dhi_clear"] = dhi_clear
# Calculate clear-sky LWD if temp and rh are available
if "temp" in df.columns and "rh" in df.columns:
df["lwd_clear"] = brutsaert_model(df["temp"], df["rh"])
return df