import copy
import numpy as np
from landlab import Component
from landlab.grid.mappers import map_node_to_cell
_VALID_METHODS = {"Grid"}
def _assert_method_is_valid(method):
if method not in _VALID_METHODS:
raise ValueError("%s: Invalid method name" % method)
[docs]
class Radiation(Component):
"""Compute 1D and 2D daily incident shortwave radiation.
Landlab component that computes 1D and 2D daily extraterrestiral, clear-sky,
incident shortwave, net shortwave, longwave, and net radiation. This code also
computes relative incidence shortwave radiation compared to a flat surface
calculated at noon.
**References**
Bras, R. L.: Hydrology: an introduction to hydrologic science, Addison
Wesley Publishing Company, Boston, Mass., USA, 643 pp., 1990.
ASCE-EWRI (2005) The ASCE standardized reference evapotranspiration equation.
In: Allen RG, Walter IA, Elliot RL et al (eds) Environmental and Water Resources
Institute (EWRI) of the American Society of Civil Engineers, ASCE,
Standardization of Reference Evapotranspiration Task Committee final report.
American Society of Civil Engineers (ASCE), Reston
Allen, R.G., 1996. Assessing integrity of weather data for reference
evapotranspiration estimation. J. Irrig. Drain. Eng., ASCE 122 (2), 97-106.
Flores-Cervantes, J.H., E. Istanbulluoglu, E.R. Vivoni, and R.L. Bras (2014).
A geomorphic perspective on terrain-modulate organization of vegetation productivity:
Analysis in two semiarid grassland ecosystems in Southwestern United States.
Ecohydrol., 7: 242-257. doi: 10.1002/eco.1333.
.. codeauthor:: Sai Nudurupati & Erkan Istanbulluoglu & Berkan Mertan
Examples
--------
>>> from landlab import RasterModelGrid
>>> from landlab.components import Radiation
>>> import numpy as np
>>> grid = RasterModelGrid((5, 4), xy_spacing=(0.2, 0.2))
>>> z = grid.add_zeros("node", "topographic__elevation")
>>> rad = Radiation(grid)
>>> grid.at_node["topographic__elevation"] = [
... [0.0, 0.0, 0.0, 0.0],
... [1.0, 1.0, 1.0, 1.0],
... [2.0, 2.0, 2.0, 2.0],
... [3.0, 4.0, 4.0, 3.0],
... [4.0, 4.0, 4.0, 4.0],
... ]
>>> rad.current_time = 0.5
>>> rad.update()
>>> grid.at_cell["radiation__net_shortwave_flux"].reshape((3, 2))
array([[251.63813643, 251.63813643],
[251.62345462, 251.62345462],
[251.59409258, 251.59409258]])
>>> grid.at_node["topographic__elevation"] = [
... [0.0, 0.0, 0.0, 0.0],
... [100.0, 100.0, 100.0, 100.0],
... [200.0, 200.0, 200.0, 200.0],
... [300.0, 400.0, 400.0, 300.0],
... [400.0, 400.0, 400.0, 400.0],
... ]
>>> calc_rad = Radiation(grid, current_time=0.0, kt=0.2)
>>> calc_rad.update()
>>> grid.at_cell["radiation__net_shortwave_flux"].reshape((3, 2))
array([[188.10745478, 188.10745478],
[187.84329564, 187.69076199],
[183.82445291, 183.41439585]])
References
----------
**Required Software Citation(s) Specific to this Component**
None Listed
"""
_name = "Radiation"
_unit_agnostic = False
_info = {
"radiation__extraterrestrial_flux": {
"dtype": float,
"intent": "out",
"optional": False,
"units": "W/m^2",
"mapping": "cell",
"doc": "extraterrestrial radiation",
},
"radiation__clearsky_flux": {
"dtype": float,
"intent": "out",
"optional": False,
"units": "W/m^2",
"mapping": "cell",
"doc": "clearsky radiation",
},
"radiation__incoming_shortwave_flux": {
"dtype": float,
"intent": "out",
"optional": False,
"units": "W/m^2",
"mapping": "cell",
"doc": "incident shortwave radiation",
},
"radiation__net_flux": {
"dtype": float,
"intent": "out",
"optional": False,
"units": "W/m^2",
"mapping": "cell",
"doc": "net radiation",
},
"radiation__net_longwave_flux": {
"dtype": float,
"intent": "out",
"optional": False,
"units": "W/m^2",
"mapping": "cell",
"doc": "net incident longwave radiation",
},
"radiation__net_shortwave_flux": {
"dtype": float,
"intent": "out",
"optional": False,
"units": "W/m^2",
"mapping": "cell",
"doc": "net incident shortwave radiation",
},
"radiation__ratio_to_flat_surface": {
"dtype": float,
"intent": "out",
"optional": False,
"units": "None",
"mapping": "cell",
"doc": (
"ratio of incident shortwave radiation on sloped "
"surface to flat surface"
),
},
"topographic__elevation": {
"dtype": float,
"intent": "in",
"optional": False,
"units": "m",
"mapping": "node",
"doc": "Land surface topographic elevation",
},
}
[docs]
def __init__(
self,
grid,
method="Grid",
cloudiness=0.2,
latitude=34.0,
albedo=0.2,
kt=0.17,
clearsky_turbidity=None,
opt_airmass=None,
current_time=0.0,
max_daily_temp=25.0,
min_daily_temp=10.0,
):
"""
Parameters
----------
grid: RasterModelGrid
A grid.
method: {'Grid'}, optional
Currently, only default is available.
cloudiness: float, optional
Cloudiness.
latitude: float, optional
Latitude (radians).
albedo: float, optional
Albedo.
kt: float, optional
Regional coefficient applied to actual KT coefficient (0.15-0.2). Default is 0.15
clearsky_turbidity: float, optional
Clear sky turbidity.
opt_airmass: float, optional
Optical air mass.
max_daily_temp: float, optional
Maximum daily temperature (Celsius)
min_daily_Temp: float, optional
Minimum daily temperature (Celsius)
current_time: float
Current time (years).
"""
super().__init__(grid)
self.current_time = current_time
self._hour = 12
self._method = method
self._N = cloudiness
self._latitude = self._validate_latitude(latitude)
self._A = self._validate_albedo(albedo)
# note that kt provided by the user is just a
# 0.15-0.2 range value meant to indicate the type of region
# where the model is run, e.g 0.2 for coastal regions,
# 0.17 for interior regions, where the default is 0.17.
# this parameter can be used as a calibration coefficient
self._kt = kt
self._n = clearsky_turbidity
self._m = opt_airmass
# For computations requiring temperature
self._Tmin, self._Tmax = self._validate_temperature_range(
min_daily_temp, max_daily_temp
)
_assert_method_is_valid(self._method)
self.initialize_output_fields()
if "Slope" not in self._grid.at_cell:
self._grid.add_zeros("Slope", at="cell", units="radians")
if "Aspect" not in self._grid.at_cell:
self._grid.add_zeros("Aspect", at="cell", units="radians")
self._nodal_values = self._grid["node"]
self._cell_values = self._grid["cell"]
self._slope, self._aspect = grid.calculate_slope_aspect_at_nodes_burrough(
vals="topographic__elevation"
)
self._cell_values["Slope"] = self._slope
self._cell_values["Aspect"] = self._aspect
# Create a 'status' nodal field to store the status_at_node values
# then map it to a cell-based field the same statuses. Use this to
# generate a "closed elevations" field of boolean indexing.self
self._gridCopy = copy.deepcopy(self._grid)
self._gridCopy.add_field(
"radiation_status_at_node", self._grid.status_at_node, at="node"
)
self._cellular_status = map_node_to_cell(
self._gridCopy, "radiation_status_at_node"
)
self._closed_elevations = (
self._cellular_status == self._gridCopy.BC_NODE_IS_CLOSED
)
[docs]
def run_one_step(self, dt=None):
if dt is None:
dt = 1.0 / 365.0
self.current_time += dt
self.update()
def _validate_latitude(self, latitude):
if latitude < -90.0 or latitude > 90.0:
raise ValueError("latitude must be between -90 and 90 degrees")
return latitude
def _validate_albedo(self, albedo):
if albedo < 0.0 or albedo > 1.0:
raise ValueError("albedo must be between 0 and 1")
return albedo
def _validate_temperature_range(self, min_temp, max_temp):
if min_temp > max_temp:
raise ValueError(
f"minimum temperature ({min_temp}) must be less than maximum ({max_temp})"
)
return min_temp, max_temp
@property
def day_of_year(self):
return (self.current_time - np.floor(self.current_time)) * 365
@property
def solar_declination(self):
return 0.409 * np.sin(2.0 * np.pi / 365.0 * self.day_of_year - 1.39)
@property
def relative_distance_factor(self):
return 1 + (0.033 * np.cos(2.0 * np.pi / 365.0 * self.day_of_year))
@property
def actual_vapor_pressure(self):
return 0.6108 * np.exp((17.27 * self._Tmin) / (237.7 + self._Tmin))
[docs]
def update(self):
"""Update fields with current loading conditions.
This method looks to the properties ``current_time`` and
``hour`` and uses their values in updating fields.
"""
self._t = self._hour
# Julian Day - ASCE-EWRI Task Committee Report, Jan-2005 - Eqn 25, (52)
self._julian = (self.current_time - np.floor(self.current_time)) * 365
# Actual Vapor Pressure - ASCE-EWRI Task Committee Report,
# Jan-2005 - Eqn 8, (38)
self._ea = 0.6108 * np.exp((17.27 * self._Tmin) / (237.7 + self._Tmin))
# Solar Declination Angle - ASCE-EWRI Task Committee Report,
# Jan-2005 - Eqn 24,(51)
self._sdecl = 0.409 * np.sin(((np.pi / 180.0) * self._julian) - 1.39)
# Inverse Relative Distance Factor - ASCE-EWRI Task Committee Report,
# Jan-2005 - Eqn 23,(50)
self._dr = 1 + (0.033 * np.cos(np.pi / 180.0 * self._julian))
# Generate spatially distributed field of flat surface to
# sloped surface radiation incidence ratios
self._ratio_flat_surface_calc()
# Extraterrestrial radmodel.docx - ASCE-EWRI (2005), Eqn (21)
self._Rext = (
11.57
* (24.0 / np.pi)
* 4.92
* self._dr
* (
(self._ws * np.sin(self._phi) * np.sin(self._sdecl))
+ (np.cos(self._phi) * np.cos(self._sdecl) * (np.sin(self._ws)))
)
)
# Clear-sky Radiation, Rcs, ASCE-EWRI (2005) as default method
# if (optionally) turbidity and optical air mass are both user defined,
# an exponential decay model Bras (2.25) is used.
# Optical airmass must be >0 or Rcs2 is disregarded (log of zero or / 0 error)
self._rcs2valid = True
if self._m is not None:
self._Rcs2 = self._Rext * np.exp(
-self._n * self._m * (0.128 - 0.054 * np.log10(self._m))
)
else:
self._rcs2valid = False
# Clear-sky Solar Radiation - ASCE-EWRI (2005), Eqn 19
self._Rcs1 = self._Rext * (0.75 + 2 * (10**-5) * self._elevation)
# Rcs1, set to Rcs2 for empirical method, Rcs for accurate method
# Using optical air mass and turbidity is optional when watershed
# is relatively flat. n and m are not required fields at all
if self._n is not None and self._m is not None and self._rcs2valid:
self._Rc = self._Rcs2
else:
self._Rc = self._Rcs1
# KT adjustment factor for incoming short-wave calculations
# this uses the ratio of local to sealevel barometric
# pressure following Allen (1996)
self._Po = 101.325
# Local atmospheric pressure ASCE-EWRI (2005), Eqn (34)
self._P = self._Po * ((293 - 0.0065 * self._elevation) / 293) ** 5.26
# non-constant KT (adjustment coefficient) - Based on Allen (1995)
self._KT = self._kt * (self._P / self._Po) ** 0.5
# Incoming shortwave radiation cannot exceed clear-sky radiation
# Shortwave radiation should ideally be below clearsky radiation
# (Rc), if there is a case where the standard shortwave rad
# formula yields a result greater than Rc, set shortwave
# radiation to the clearsky radiation itself.
self._Rs = np.minimum(
self._KT * self._Rext * np.sqrt(self._Tmax - self._Tmin), self._Rc
)
# Net shortwave Radiation - ASCE-EWRI (2005), Eqn (43)
self._Rns = self._Rs * (1 - self._A)
# Relative Cloudiness - ASCE-EWRI (2005), Eqn (18)
self._u = 1.35 * (self._Rs / self._Rc) - 0.35
# Cloudiness should be within 0.05 and 1 so as to not
# nullify or incorrectly calculate the net longwave radiation
self._u = np.clip(self._u, 0.05, 1.0)
# Net Longwave Radiation - ASCE-EWRI (2005), Eqn (17) in W/M^2
self._Rnl = (
5.67
* (10**-8)
* ((self._Tmax + 273.15) ** 4 + (self._Tmin + 273.15) ** 4)
/ 2
* (0.34 - (0.14 * np.sqrt(self._ea)))
* self._u
)
# Load spatially distributed ratio to flat surface function values
self._cell_values["radiation__ratio_to_flat_surface"] = self._radf
# If sun does not rise, accounted by an invalid trig function
# input, set all NaN grid values to 0.0 instead.
self._radf[np.isnan(self._radf)] = 0.0
self._Rext = 0.0 if np.isnan(self._Rext) else self._Rext
# Net Radiation - ASCE-EWRI (2005), Eqn 15
# Apply the ratio to flat surface to net shortwave
# radiation first, then use spatially distributed
# shortwave radiation to calculate net radiation
np.multiply(
self._Rext,
self._cell_values["radiation__ratio_to_flat_surface"],
out=self._cell_values["radiation__extraterrestrial_flux"],
)
# Clearsky flux
np.multiply(
self._Rc,
self._cell_values["radiation__ratio_to_flat_surface"],
out=self._cell_values["radiation__clearsky_flux"],
)
# Incoming shortwave flux
np.multiply(
self._Rs,
self._cell_values["radiation__ratio_to_flat_surface"],
out=self._cell_values["radiation__incoming_shortwave_flux"],
)
# Net shortwave flux
np.multiply(
self._Rns,
self._cell_values["radiation__ratio_to_flat_surface"],
out=self._cell_values["radiation__net_shortwave_flux"],
)
# Net longwave flux
np.multiply(
self._Rnl,
self._cell_values["radiation__ratio_to_flat_surface"],
out=self._cell_values["radiation__net_longwave_flux"],
)
# Net radiation flux is net shortwave - net longwave
np.subtract(
self._cell_values["radiation__net_shortwave_flux"],
self._cell_values["radiation__net_longwave_flux"],
out=self._cell_values["radiation__net_flux"],
)
def _ratio_flat_surface_calc(self):
"""generate radiation__ratio_to_flat_surface field
This method looks to the slope, aspect values across
the grid provided to the component, then runs calculations
with solar altitude, latitude, and elevation to create a
spatially distributed field of flat surface to sloped surface
ratios / factors.
"""
# self._elevation, elevation of surface above sea level
self._elevation = self._nodal_values["topographic__elevation"]
# Handle invalid values (closed nodes are not invalid)
if np.any(
(self._elevation < 0.0)
& (self._grid.status_at_node != self._grid.BC_NODE_IS_CLOSED)
):
raise ValueError(
"No negative (< 0.0) values allowed in an above sea level elevation field."
)
# Map nodal elevation values to cells to calculate clearsky incidence
# across a spatially distributed field
self._elevation = map_node_to_cell(self._grid, "topographic__elevation")
# Convert latitude to radians and store trig calculations
self._phi = np.radians(self._latitude) # Latitude in Radians
self._sinLat = np.sin(self._phi)
self._cosLat = np.cos(self._phi)
# Get the hour angle using time of day
self._tau = (self._t + 12.0) * np.pi / 12.0 # Hour angle
# Calculate solar altitude using declination angle, hour angle, and latitude
self._alpha = np.arcsin(
np.sin(self._sdecl) * self._sinLat
+ (np.cos(self._sdecl) * self._cosLat * np.cos(self._tau))
) # Solar Altitude/Angle
if self._alpha <= 0.25: # If altitude is -ve,
self._alpha = 0.25 # sun is beyond the horizon
# For less constant calculation, keeping solar altitude trig in fixed variables
self._cosSA = np.cos(self._alpha)
self._sinSA = np.sin(self._alpha)
# Avoid div by zero
if self._cosSA <= 0.0001:
self._cosSA = 0.0001
# Sunset Hour Angle - ASCE-EWRI (2005), Eqn (59)
self._ws = np.arccos(-np.tan(self._sdecl) * np.tan(self._phi))
# Sun's Azimuth calculation code
F = np.tan(self._alpha) * np.tan(self._phi) - (
np.sin(self._sdecl) / (self._cosSA * self._cosLat)
)
# Clip azimuth within these bounds
F = np.clip(F, -0.99999, 0.99999)
if self._t < 12.0:
self._phisun = np.pi - np.arccos(F)
else:
self._phisun = np.pi + np.arccos(F)
self._flat = np.sin(self._alpha)
# solar angle of incidence, Multiplying this with incoming radiation
# gives radiation on sloped surface see Flores-Cervantes, J.H. (2012)
self._sloped = np.cos(self._slope) * self._sinSA + np.sin(
self._slope
) * self._cosSA * np.cos(self._phisun - self._aspect)
self._sloped[self._sloped <= 0.0] = 0.0
# Ratio of cosine of solar incidence angle of sloped surface to that
# of a flat surface
self._radf = self._sloped / self._flat
self._radf[self._radf <= 0.0] = 0.0
self._radf[self._radf > 6.0] = 6.0
# Closed nodes will be omitted from spatially distributed ratio calculations
self._radf[self._closed_elevations] = 0.0