import numpy as np
from scipy.integrate import quad
from landlab.components.erosion_deposition.generalized_erosion_deposition import (
DEFAULT_MINIMUM_TIME_STEP,
)
from landlab.components.erosion_deposition.generalized_erosion_deposition import (
_GeneralizedErosionDeposition,
)
from landlab.utils.return_array import return_array_at_node
from .ext.calc_qs import calculate_qs_in
ROOT2 = np.sqrt(2.0) # syntactic sugar for precalculated square root of 2
TIME_STEP_FACTOR = 0.5 # factor used in simple subdivision solver
[docs]
class Space(_GeneralizedErosionDeposition):
"""Stream Power with Alluvium Conservation and Entrainment (SPACE)
See the publication:
Shobe, C. M., Tucker, G. E., and Barnhart, K. R.: The SPACE 1.0 model: a
Landlab component for 2-D calculation of sediment transport, bedrock
erosion, and landscape evolution, Geosci. Model Dev., 10, 4577-4604,
`https://doi.org/10.5194/gmd-10-4577-2017 <https://www.geosci-model-dev.net/10/4577/2017/>`_, 2017.
Unlike other some other fluvial erosion componets in Landlab, in this
component (and :py:class:`~landlab.components.ErosionDeposition`) no
erosion occurs in depressions or in areas with adverse slopes. There is no
ability to pass a keyword argument ``erode_flooded_nodes``.
If a depressions are handled (as indicated by the presence of the field
"flood_status_code" at nodes), then deposition occurs throughout the
depression and sediment is passed out of the depression. Where pits are
encountered, then all sediment is deposited at that node only.
Note: If timesteps are large enough that Es*dt (sediment erosion)
exceeds sediment thickness H, the 'adaptive' solver is necessary to
subdivide timesteps. Compare Es and H arrays to determine whether
timesteps are appropriate or too large for the 'basic' solver.
Examples
---------
>>> import numpy as np
>>> from landlab import RasterModelGrid
>>> from landlab.components import (
... FlowAccumulator,
... DepressionFinderAndRouter,
... Space,
... FastscapeEroder,
... )
>>> np.random.seed(seed=5000)
Define grid and initial topography:
* 5x5 grid with base level in the lower left corner
* All other boundary nodes closed
* Initial topography is plane tilted up to the upper right with
noise
>>> mg = RasterModelGrid((5, 5), xy_spacing=10.0)
>>> _ = mg.add_zeros("topographic__elevation", at="node")
>>> mg.at_node["topographic__elevation"] += (
... mg.node_y / 10.0 + mg.node_x / 10.0 + np.random.rand(len(mg.node_y)) / 10.0
... )
>>> mg.set_closed_boundaries_at_grid_edges(
... bottom_is_closed=True,
... left_is_closed=True,
... right_is_closed=True,
... top_is_closed=True,
... )
>>> mg.set_watershed_boundary_condition_outlet_id(
... 0, mg.at_node["topographic__elevation"], -9999.0
... )
>>> fsc_dt = 100.0
>>> space_dt = 100.0
Instantiate Fastscape eroder, flow router, and depression finder
>>> fr = FlowAccumulator(mg, flow_director="D8")
>>> df = DepressionFinderAndRouter(mg)
>>> fsc = FastscapeEroder(mg, K_sp=0.001, m_sp=0.5, n_sp=1)
Burn in an initial drainage network using the Fastscape eroder:
>>> for _ in range(100):
... fr.run_one_step()
... df.map_depressions()
... fsc.run_one_step(dt=fsc_dt)
... mg.at_node["topographic__elevation"][0] -= 0.001 # Uplift
...
Add some soil to the drainage network:
>>> _ = mg.add_zeros("soil__depth", at="node", dtype=float)
>>> mg.at_node["soil__depth"] += 0.5
>>> mg.at_node["topographic__elevation"] += mg.at_node["soil__depth"]
Instantiate the Space component:
>>> ha = Space(
... mg,
... K_sed=0.00001,
... K_br=0.00000000001,
... F_f=0.5,
... phi=0.1,
... H_star=1.0,
... v_s=0.001,
... m_sp=0.5,
... n_sp=1.0,
... sp_crit_sed=0,
... sp_crit_br=0,
... )
Now run the Space component for 2000 short timesteps:
>>> for _ in range(2000): # Space component loop
... fr.run_one_step()
... df.map_depressions()
... ha.run_one_step(dt=space_dt)
... mg.at_node["bedrock__elevation"][0] -= 2e-6 * space_dt
...
Now we test to see if soil depth and topography are right:
>>> np.around(mg.at_node["soil__depth"], decimals=3)
array([0.5 , 0.5 , 0.5 , 0.5 , 0.5 , 0.5 , 0.495, 0.492,
0.491, 0.5 , 0.5 , 0.492, 0.492, 0.49 , 0.5 , 0.5 ,
0.491, 0.49 , 0.484, 0.5 , 0.5 , 0.5 , 0.5 , 0.5 ,
0.5 ])
>>> np.around(mg.at_node["topographic__elevation"], decimals=3)
array([0.423, 1.536, 2.573, 3.511, 4.561, 1.582, 0.424, 0.428,
0.438, 5.51 , 2.54 , 0.428, 0.428, 0.438, 6.526, 3.559,
0.438, 0.438, 0.45 , 7.553, 4.559, 5.541, 6.57 , 7.504,
8.51 ])
References
----------
**Required Software Citation(s) Specific to this Component**
Shobe, C., Tucker, G., Barnhart, K. (2017). The SPACE 1.0 model: a Landlab
component for 2-D calculation of sediment transport, bedrock erosion, and
landscape evolution. Geoscientific Model Development 10(12), 4577 - 4604.
https://dx.doi.org/10.5194/gmd-10-4577-2017
**Additional References**
None Listed
""" # noqa: B950
_name = "Space"
_unit_agnostic = True
_info = {
"flow__link_to_receiver_node": {
"dtype": int,
"intent": "in",
"optional": False,
"units": "-",
"mapping": "node",
"doc": "ID of link downstream of each node, which carries the discharge",
},
"flow__receiver_node": {
"dtype": int,
"intent": "in",
"optional": False,
"units": "-",
"mapping": "node",
"doc": "Node array of receivers (node that receives flow from current node)",
},
"flow__upstream_node_order": {
"dtype": int,
"intent": "in",
"optional": False,
"units": "-",
"mapping": "node",
"doc": "Node array containing downstream-to-upstream ordered list of node IDs",
},
"sediment__influx": {
"dtype": float,
"intent": "out",
"optional": False,
"units": "m3/s",
"mapping": "node",
"doc": "Sediment flux (volume per unit time of sediment entering each node)",
},
"sediment__outflux": {
"dtype": float,
"intent": "out",
"optional": False,
"units": "m3/s",
"mapping": "node",
"doc": "Sediment flux (volume per unit time of sediment leaving each node)",
},
"soil__depth": {
"dtype": float,
"intent": "inout",
"optional": False,
"units": "m",
"mapping": "node",
"doc": "Depth of soil or weathered bedrock",
},
"surface_water__discharge": {
"dtype": float,
"intent": "in",
"optional": False,
"units": "m**3/s",
"mapping": "node",
"doc": "Volumetric discharge of surface water",
},
"topographic__elevation": {
"dtype": float,
"intent": "inout",
"optional": False,
"units": "m",
"mapping": "node",
"doc": "Land surface topographic elevation",
},
"topographic__steepest_slope": {
"dtype": float,
"intent": "in",
"optional": False,
"units": "-",
"mapping": "node",
"doc": "The steepest *downhill* slope",
},
}
_cite_as = """
@Article{gmd-10-4577-2017,
AUTHOR = {Shobe, C. M. and Tucker, G. E. and Barnhart, K. R.},
TITLE = {The SPACE~1.0 model: a~Landlab component for 2-D calculation
of sediment transport, bedrock erosion, and landscape evolution},
JOURNAL = {Geoscientific Model Development},
VOLUME = {10},
YEAR = {2017},
NUMBER = {12},
PAGES = {4577--4604},
URL = {https://www.geosci-model-dev.net/10/4577/2017/},
DOI = {10.5194/gmd-10-4577-2017}
}"""
[docs]
def __init__(
self,
grid,
K_sed=0.002,
K_br=0.002,
F_f=0.0,
phi=0.3,
H_star=0.1,
v_s=1.0,
m_sp=0.5,
n_sp=1.0,
sp_crit_sed=0.0,
sp_crit_br=0.0,
discharge_field="surface_water__discharge",
solver="basic",
dt_min=DEFAULT_MINIMUM_TIME_STEP,
):
"""Initialize the Space model.
Parameters
----------
grid : ModelGrid
Landlab ModelGrid object
K_sed : float, field name, or array
Erodibility for sediment (units vary).
K_br : float, field name, or array
Erodibility for bedrock (units vary).
F_f : float
Fraction of permanently suspendable fines in bedrock [-].
phi : float
Sediment porosity [-].
H_star : float
Sediment thickness required for full entrainment [L].
v_s : float
Effective settling velocity for chosen grain size metric [L/T].
m_sp : float
Drainage area exponent (units vary)
n_sp : float
Slope exponent (units vary)
sp_crit_sed : float, field name, or array
Critical stream power to erode sediment [E/(TL^2)]
sp_crit_br : float, field name, or array
Critical stream power to erode rock [E/(TL^2)]
discharge_field : float, field name, or array
Discharge [L^2/T]. The default is to use the grid field
'surface_water__discharge', which is simply drainage area
multiplied by the default rainfall rate (1 m/yr). To use custom
spatially/temporally varying rainfall, use 'water__unit_flux_in'
to specify water input to the FlowAccumulator.
solver : string
Solver to use. Options at present include:
(1) 'basic' (default): explicit forward-time extrapolation.
Simple but will become unstable if time step is too large.
(2) 'adaptive': subdivides global time step as needed to
prevent slopes from reversing and alluvium from going
negative.
"""
if grid.at_node["flow__receiver_node"].size != grid.size("node"):
raise NotImplementedError(
"A route-to-multiple flow director has been "
"run on this grid. The landlab development team has not "
"verified that SPACE is compatible with "
"route-to-multiple methods. Please open a GitHub Issue "
"to start this process."
)
super().__init__(
grid,
m_sp=m_sp,
n_sp=n_sp,
F_f=F_f,
v_s=v_s,
dt_min=dt_min,
discharge_field=discharge_field,
)
if phi >= 1.0:
raise ValueError("Porosity must be < 1.0")
if phi < 0.0:
raise ValueError("Porosity must be > 0.0")
self._phi = float(phi)
self._porosity_factor = 1.0 / (1.0 - self._phi)
# space specific inits
self._H_star = H_star
self._soil__depth = grid.at_node["soil__depth"]
if "bedrock__elevation" in grid.at_node:
self._bedrock__elevation = grid.at_node["bedrock__elevation"]
else:
self._bedrock__elevation = grid.add_zeros(
"bedrock__elevation", at="node", dtype=float
)
self._bedrock__elevation[:] = (
self._topographic__elevation - self._soil__depth
)
self._sed_erosion_term = np.zeros(grid.number_of_nodes)
self._br_erosion_term = np.zeros(grid.number_of_nodes)
self._Es = np.zeros(grid.number_of_nodes)
self._Er = np.zeros(grid.number_of_nodes)
# K's and critical values can be floats, grid fields, or arrays
# use setters defined below
self.K_sed = K_sed
self.K_br = K_br
self._sp_crit_sed = return_array_at_node(grid, sp_crit_sed)
self._sp_crit_br = return_array_at_node(grid, sp_crit_br)
# Handle option for solver
if solver == "basic":
self.run_one_step = self.run_one_step_basic
elif solver == "adaptive":
self.run_one_step = self.run_with_adaptive_time_step_solver
self._time_to_flat = np.zeros(grid.number_of_nodes)
self._time_to_zero_alluv = np.zeros(grid.number_of_nodes)
self._dzdt = np.zeros(grid.number_of_nodes)
else:
raise ValueError(
"Parameter 'solver' must be one of: " + "'basic', 'adaptive'"
)
@property
def K_br(self):
"""Erodibility of bedrock(units depend on m_sp)."""
return self._K_br
@K_br.setter
def K_br(self, new_val):
self._K_br = return_array_at_node(self._grid, new_val)
@property
def K_sed(self):
"""Erodibility of sediment(units depend on m_sp)."""
return self._K_sed
@K_sed.setter
def K_sed(self, new_val):
self._K_sed = return_array_at_node(self._grid, new_val)
def _calc_erosion_rates(self):
"""Calculate erosion rates."""
# if sp_crits are zero, then this colapses to correct all the time.
if self._n_sp == 1.0:
S_to_the_n = self._slope
else:
S_to_the_n = np.power(self._slope, self._n_sp)
omega_sed = self._K_sed * self._Q_to_the_m * S_to_the_n
omega_br = self._K_br * self._Q_to_the_m * S_to_the_n
omega_sed_over_sp_crit = np.divide(
omega_sed,
self._sp_crit_sed,
out=np.zeros_like(omega_sed),
where=self._sp_crit_sed != 0,
)
omega_br_over_sp_crit = np.divide(
omega_br,
self._sp_crit_br,
out=np.zeros_like(omega_br),
where=self._sp_crit_br != 0,
)
self._sed_erosion_term = omega_sed - self._sp_crit_sed * (
1.0 - np.exp(-omega_sed_over_sp_crit)
) / (
1 - self._phi
) # convert from a volume to a mass flux.
self._br_erosion_term = omega_br - self._sp_crit_br * (
1.0 - np.exp(-omega_br_over_sp_crit)
)
H_over_Hstar = self._soil__depth / self._H_star
self._Es = self._sed_erosion_term * (1.0 - np.exp(-H_over_Hstar))
self._Er = self._br_erosion_term * np.exp(-H_over_Hstar)
@property
def Es(self):
"""Sediment erosion term."""
return self._Es
@property
def Er(self):
"""Bedrock erosion term."""
return self._Er
@property
def H(self):
"""Sediment thickness."""
return self._H
def _calc_qs_in_and_depo_rate(self):
# Choose a method for calculating erosion:
self._calc_hydrology()
self._calc_erosion_rates()
is_flooded_core_node = self._get_flooded_core_nodes()
self._Es[is_flooded_core_node] = 0.0
self._Er[is_flooded_core_node] = 0.0
self._sed_erosion_term[is_flooded_core_node] = 0.0
self._br_erosion_term[is_flooded_core_node] = 0.0
self.sediment_influx[:] = 0
self._depo_rate[:] = 0.0
# iterate top to bottom through the stack, calculate qs
# cythonized version of calculating qs_in
calculate_qs_in(
np.flipud(self._stack),
self._flow_receivers,
self._cell_area_at_node,
self._q,
self._qs,
self.sediment_influx,
self._Es,
self._Er,
self._v_s,
self._F_f,
)
self._depo_rate[self._q > 0] = self._qs[self._q > 0] * (
self._v_s / self._q[self._q > 0]
)
if not self._depressions_are_handled(): # all sed dropped here
self._depo_rate[is_flooded_core_node] = (
self.sediment_influx[is_flooded_core_node]
/ self._cell_area_at_node[is_flooded_core_node]
)
return is_flooded_core_node
[docs]
def run_one_step_basic(self, dt=1.0):
"""Calculate change in rock and alluvium thickness for a time period
'dt'.
Parameters
----------
dt : float
Model timestep [T]
"""
self._calc_qs_in_and_depo_rate()
cores = self._grid.core_nodes
H0 = self._soil__depth.copy()
# now, the analytical solution to soil thickness in time:
# need to distinguish D=kqS from all other cases to save from blowup!
# distinguish cases:
# there is a numerical blow up as well, which is not discussed in the
# paper. when H>>H* the analytical solution (Eq 34) has a term that is
# exp (H/H*) This can becom infinite. We will consider the case of
# H/H*>50 as distinct.
H_over_H_star = self._soil__depth / self._H_star
too_thick = H_over_H_star > 100
H_over_H_star[too_thick] = 100
no_entrainment = self._sed_erosion_term <= 0.0 # this will include pits.
blowup = (
(self._depo_rate == self._sed_erosion_term)
& (self._sed_erosion_term > 0.0)
& (~no_entrainment)
)
full_equation = (~blowup) & (~no_entrainment)
# First do blowup.
# this is Space paper Eq 34. This example has the same issues with
# very thick sed. If sed is very thick AND ero=depo, there is no change.
self._soil__depth[blowup * (~too_thick)] = self._H_star * np.log(
((self._sed_erosion_term[blowup]) / self._H_star) * dt
+ np.exp(H_over_H_star[blowup])
)
# Second, do no entrainment of sediment. This is equation 35.
self._soil__depth[no_entrainment] += (
self._depo_rate[no_entrainment] / (1 - self._phi)
) * dt
# Treat the case of very thick sediments and ero != depo.
self._soil__depth[full_equation * too_thick] += (
(
self._depo_rate[full_equation * (full_equation * too_thick)]
/ (1 - self._phi)
)
- (self._sed_erosion_term[full_equation * too_thick] / (1 - self._phi))
) * dt
# Finally do the full equation (Eq 32) when not too thick.
self._soil__depth[full_equation * (~too_thick)] = self._H_star * np.log(
(
1
/ (
(self._depo_rate[full_equation * (~too_thick)] / (1 - self._phi))
/ (
self._sed_erosion_term[full_equation * (~too_thick)]
/ (1 - self._phi)
)
- 1
)
)
* (
np.exp(
(
self._depo_rate[full_equation * (~too_thick)] / (1 - self._phi)
- (
self._sed_erosion_term[full_equation * (~too_thick)]
/ (1 - self._phi)
)
)
* (dt / self._H_star)
)
* (
(
(
self._depo_rate[full_equation * (~too_thick)]
/ (1 - self._phi)
/ (
self._sed_erosion_term[full_equation * (~too_thick)]
/ (1 - self._phi)
)
)
- 1
)
* np.exp(H_over_H_star[full_equation * (~too_thick)])
+ 1
)
- 1
)
)
# Equation 12 gives dRdt, and Equation 36 gives R. However, we don't
# include dH/dt within timestep in integrating for R.
# This matters when we are starting with very little soil and increasing.
# to do this right I think we need a numerical integral for
# R(t). Unfortunately we have three cases for H(t). These are now
# implemented in the function _dRdt. This loop and the solver can be
# cythonized. But not today.
dR = self._grid.zeros(at="node")
for idx in self.grid.core_nodes:
args = (
self._br_erosion_term[idx],
1.0 / self._H_star,
self._depo_rate[idx] / (1.0 - self._phi),
self._sed_erosion_term[idx] / (1.0 - self._phi),
H0[idx],
)
dR[idx] = quad(_dRdt, 0, dt, args)[0]
self._bedrock__elevation += dR
# finally, determine topography by summing bedrock and soil
self._topographic__elevation[cores] = (
self._bedrock__elevation[cores] + self._soil__depth[cores]
)
[docs]
def run_with_adaptive_time_step_solver(self, dt=1.0):
"""Run step with CHILD-like solver that adjusts time steps to prevent
slope flattening.
Parameters
----------
dt : float
Model timestep [T]
Examples
--------
>>> from landlab import RasterModelGrid
>>> from landlab.components import FlowAccumulator
>>> import numpy as np
>>> rg = RasterModelGrid((3, 4))
>>> z = rg.add_zeros("topographic__elevation", at="node")
>>> z[:] = 0.1 * rg.x_of_node
>>> H = rg.add_zeros("soil__depth", at="node")
>>> H += 0.1
>>> br = rg.add_zeros("bedrock__elevation", at="node")
>>> br[:] = z - H
>>> fa = FlowAccumulator(rg, flow_director="FlowDirectorSteepest")
>>> fa.run_one_step()
>>> sp = Space(
... rg,
... K_sed=1.0,
... K_br=0.1,
... F_f=0.5,
... phi=0.0,
... H_star=1.0,
... v_s=1.0,
... m_sp=0.5,
... n_sp=1.0,
... sp_crit_sed=0,
... sp_crit_br=0,
... solver="adaptive",
... )
>>> sp.run_one_step(dt=10.0)
>>> np.round(sp.Es[5:7], 4)
array([0.0029, 0.0074])
>>> np.round(sp.Er[5:7], 4)
array([0.0032, 0.0085])
>>> np.round(H[5:7], 3)
array([0.088, 0.078])
"""
# Initialize remaining_time, which records how much of the global time
# step we have yet to use up.
remaining_time = dt
z = self._grid.at_node["topographic__elevation"]
br = self._grid.at_node["bedrock__elevation"]
H = self._grid.at_node["soil__depth"]
r = self._flow_receivers
cores = self._grid.core_nodes
first_iteration = True
is_flooded_core_node = self._get_flooded_core_nodes()
# Outer WHILE loop: keep going until time is used up
while remaining_time > 0.0:
# Update all the flow-link slopes.
#
# For the first iteration, we assume this has already been done
# outside the component (e.g., by flow router), but we need to do
# it ourselves on subsequent iterations.
if not first_iteration:
# update the link slopes
self._update_flow_link_slopes()
# update where nodes are flooded. This shouuldn't happen because
# of the dynamic timestepper, but just in case, we update here.
is_flooded_core_node[self._slope < 0] = True
else:
first_iteration = False
is_flooded_core_node = (
self._calc_qs_in_and_depo_rate()
) # THIS IS THE SPEED BOTTLENECK
# Now look at upstream-downstream node pairs, and recording the
# time it would take for each pair to flatten. Take the minimum.
self._dzdt[cores] = self._depo_rate[cores] * self._porosity_factor - (
self._Es[cores] + self._Er[cores]
)
rocdif = self._dzdt - self._dzdt[r]
zdif = z - z[r]
self._time_to_flat[:] = remaining_time
converging = np.where(rocdif < 0.0)[0]
self._time_to_flat[converging] = -(
TIME_STEP_FACTOR * zdif[converging] / rocdif[converging]
)
self._time_to_flat[np.where(zdif <= 0.0)[0]] = remaining_time
# From this, find the maximum stable time step with regard to slope
# evolution.
dt_max1 = np.amin(self._time_to_flat)
# Next we consider time to exhaust regolith
self._time_to_zero_alluv[:] = remaining_time
# poof deposition by phi
dHdt = self._porosity_factor * (self._depo_rate - self._Es)
decreasing_H = np.where(dHdt < 0.0)[0]
self._time_to_zero_alluv[decreasing_H] = -(
TIME_STEP_FACTOR * H[decreasing_H] / dHdt[decreasing_H]
)
# Now find the smallest time that would lead to near-empty alluv
dt_max2 = np.amin(self._time_to_zero_alluv)
# Take the smaller of the limits
dt_max = max(self._dt_min, min(dt_max1, dt_max2))
# Now a vector operation: apply dzdt and dhdt to all nodes
br[cores] -= self._Er[cores] * dt_max
H[cores] += dHdt[cores] * dt_max
z[cores] = br[cores] + H[cores]
# Update remaining time and continue
remaining_time -= dt_max
def _dRdt(t, a, b, c, d, H0):
"""
dRdt = a * exp(-b * H (t))
a = Kr q S*n
b = 1/H*
c = VQs/(Q * (1-phi))
d = Ks q Sn
if d <= 0:
H (t) = (1/b) ln [ d*b*t + exp(H0/H*) ]
"""
# truncate H/H*
if b * H0 > 100:
too_thick = True
else:
too_thick = False
bH0 = min(b * H0, 100)
# set cases for H depending on values of constants.
# deposition only case.
if d <= 0:
H = H0 + (c * t)
else:
# sediment deposition = sediment entrainment.
if c == d:
if too_thick:
H = H0
else:
H = (1 / b) * np.log((d * b * t) + np.exp(bH0))
# full equation 32
else:
if too_thick:
H = H0 + (c - d) * t
else:
term1 = 1.0 / ((c / d) - 1)
term2 = np.exp((c - d) * t * b)
term3 = (c / d - 1) * np.exp(bH0) + 1
interior = term1 * (term2 * (term3) - 1)
H = (1 / b) * np.log(interior)
# calculate dRdt
dRdt = -a * np.exp(-b * H)
return dRdt