Introduction to the NormalFault component

This tutorial provides an introduction to the NormalFault component in the Landlab toolkit. This component takes the following parameters.

Parameters
--------
grid : ModelGrid
faulted_surface : str or ndarray of shape `(n_nodes, )` or list of str
    or ndarrays. 
    Surface that is modified by the NormalFault component. Can be a
    field name or array or a list of strings or ndarrays if the fault.
    should uplift more than one field. Default value is 
    `topographic__elevation`.
fault_throw_rate_through_time : dict, optional
    Dictionary that specifies the time varying throw rate on the fault.
    Expected format is:
    ``fault_throw_rate_through_time = {'time': array, 'rate': array}``
    Default value is a constant rate of 0.001 (units not specified).
fault_dip_angle : float, optional
    Dip angle of the fault in degrees.  Default value is 90 degrees.
fault_trace : dictionary, optional
    Dictionary that specifies the coordinates of two locations on the
    fault trace. Expected format is
    ``fault_trace = {'x1': float, 'y1': float, 'x2': float, 'y2': float}``
    where the vector from ``(x1, y1)`` to ``(x2, y2)`` defines the
    strike of the fault trace. The orientation of the fault dip relative
    to the strike follows the right hand rule.
    Default is for the fault to strike NE.
include_boundaries : boolean, optional
    Flag to indicate if model grid boundaries should be uplifted. If
    set to ``True`` uplifted model grid boundaries will be set to the
    average value of their upstream nodes. Default value is False.


The NormalFault component will divide the model domain into two regions, a 'faulted nodes' region which will experience vertical rock uplift at a rate of

$t \cdot \sin (d)$

where $t$ is the fault throw rate and $d$ is the fault dip angle.

While dip angles less than 90 degrees are permitted, in its present implementation, the NormalFault component does not translate field information laterally.

The fault orientation is specified by two coordinate pairs: (x1, y1) and (x2, y2). The strike of the fault, specified with the right-hand rule convention, is the vector from (x1, y1) to (x2, y2). Give that this component creates a normal fault, in which the footwall moves up relative to the hanging wall, this means that the nodes that are counterclockwise from the strike are the uplifted nodes.

To start, let's import necessary Landlab and Python modules.


In [ ]:
# start by importing necessary modules
import matplotlib.pyplot as plt
import numpy as np

from landlab import HexModelGrid, RasterModelGrid
from landlab.components import (
    FastscapeEroder,
    FlowAccumulator,
    NormalFault,
    StreamPowerEroder,
)
from landlab.plot import imshow_grid

%matplotlib inline

First we will make a default NormalFault.


In [ ]:
grid = RasterModelGrid((6, 6), xy_spacing=10)

grid.add_zeros("topographic__elevation", at="node")

nf = NormalFault(grid)

plt.figure()
imshow_grid(grid, nf.faulted_nodes.astype(int), cmap="viridis")
plt.plot(grid.x_of_node, grid.y_of_node, "c.")
plt.show()

This fault has a strike of NE and dips to the SE. Thus the uplifted nodes (shown in yellow) are in the NW half of the domain.

The default NormalFault will not uplift the boundary nodes. We change this by using the keyword argument include_boundaries. If this is specified, the elevation of the boundary nodes is calculated as an average of the faulted nodes adjacent to the boundaries. This occurs because most Landlab erosion components do not operate on boundary nodes.


In [ ]:
nf = NormalFault(grid, include_boundaries=True)

plt.figure()
imshow_grid(grid, nf.faulted_nodes.astype(int), cmap="viridis")
plt.plot(grid.x_of_node, grid.y_of_node, "c.")
plt.show()

We can add functionality to the NormalFault with other keyword arguments. We can change the fault strike and dip, as well as specify a time series of fault uplift through time.


In [ ]:
grid = RasterModelGrid((60, 100), xy_spacing=10)
z = grid.add_zeros("topographic__elevation", at="node")

nf = NormalFault(grid, fault_trace={"x1": 0, "y1": 200, "y2": 30, "x2": 600})

imshow_grid(grid, nf.faulted_nodes.astype(int), cmap="viridis")

By reversing the order of (x1, y1) and (x2, y2) we can reverse the location of the upthrown nodes (all else equal).


In [ ]:
grid = RasterModelGrid((60, 100), xy_spacing=10)
z = grid.add_zeros("topographic__elevation", at="node")

nf = NormalFault(grid, fault_trace={"y1": 30, "x1": 600, "x2": 0, "y2": 200})

imshow_grid(grid, nf.faulted_nodes.astype(int), cmap="viridis")

We can also specify complex time-rock uplift rate histories, but we'll explore that later in the tutorial.

Next let's make a landscape evolution model with a normal fault. Here we'll use a HexModelGrid to highlight that we can use both raster and non-raster grids with this component.

We will do a series of three numerical experiments and will want to keep a few parameters constant. Since you might want to change them, we are making it easier to change all of them together. They are defined in the next block:


In [ ]:
# here are the parameters to change
K = 0.0005  # stream power coefficient, bigger = streams erode more quickly
U = 0.0001  # uplift rate in meters per year

dt = 1000  # time step in years
dx = 10  # space step in meters

nr = 60  # number of model rows
nc = 100  # number of model columns

In [ ]:
# instantiate the grid
grid = HexModelGrid((nr, nc), dx, node_layout="rect")

# add a topographic__elevation field with noise
z = grid.add_zeros("topographic__elevation", at="node")
z[grid.core_nodes] += 100.0 + np.random.randn(grid.core_nodes.size)

fr = FlowAccumulator(grid)
fs = FastscapeEroder(grid, K_sp=K)
nf = NormalFault(grid, fault_trace={"x1": 0, "x2": 800, "y1": 0, "y2": 500})

# Run this model for 300 100-year timesteps (30,000 years).
for i in range(300):
    nf.run_one_step(dt)
    fr.run_one_step()
    fs.run_one_step(dt)
    z[grid.core_nodes] += 0.0001 * dt

# plot the final topography
imshow_grid(grid, z)

As we can see, the upper left portion of the grid has been uplifted an a stream network has developed over the whole domain.

How might this change when we also uplift the boundaries nodes?


In [ ]:
# instantiate the grid
grid = HexModelGrid((nr, nc), 10, node_layout="rect")

# add a topographic__elevation field with noise
z = grid.add_zeros("topographic__elevation", at="node")
z[grid.core_nodes] += 100.0 + np.random.randn(grid.core_nodes.size)

fr = FlowAccumulator(grid)
fs = FastscapeEroder(grid, K_sp=K)
nf = NormalFault(
    grid, fault_trace={"x1": 0, "x2": 800, "y1": 0, "y2": 500}, include_boundaries=True
)

# Run this model for 300 100-year timesteps (30,000 years).
for i in range(300):
    nf.run_one_step(dt)
    fr.run_one_step()
    fs.run_one_step(dt)
    z[grid.core_nodes] += U * dt

# plot the final topography
imshow_grid(grid, z)

We can see that when the boundary nodes are not included, the faulted region is impacted by the edge boundary conditions differently. Depending on your application, one or the other of these boundary condition options may suite your problem better.

The last thing to explore is the use of the fault_rate_through_time parameter. This allows us to specify generic fault throw rate histories. For example, consider the following history, in which every 100,000 years there is a 10,000 year period in which the fault is active.


In [ ]:
time = (
    np.array(
        [
            0.0,
            7.99,
            8.00,
            8.99,
            9.0,
            17.99,
            18.0,
            18.99,
            19.0,
            27.99,
            28.00,
            28.99,
            29.0,
        ]
    )
    * 10
    * dt
)
rate = np.array([0, 0, 0.01, 0.01, 0, 0, 0.01, 0.01, 0, 0, 0.01, 0.01, 0])

plt.figure()
plt.plot(time, rate)
plt.plot([0, 300 * dt], [0.001, 0.001])
plt.xlabel("Time [years]")
plt.ylabel("Fault Throw Rate [m/yr]")
plt.show()

The default value for uplift rate is 0.001 (units unspecified as it will depend on the x and t units in a model, but in this example we assume time units of years and length units of meters).

This will result in a total of 300 m of fault throw over the 300,000 year model time period. This amount of uplift can also be accommodated by faster fault motion that occurs over shorter periods of time.

Next we plot the cumulative fault throw for the two cases.


In [ ]:
t = np.arange(0, 300 * dt, dt)
rate_constant = np.interp(t, [0, 300 * dt], [0.001, 0.001])
rate_variable = np.interp(t, time, rate)

cumulative_rock_uplift_constant = np.cumsum(rate_constant) * dt
cumulative_rock_uplift_variable = np.cumsum(rate_variable) * dt

plt.figure()
plt.plot(t, cumulative_rock_uplift_constant)
plt.plot(t, cumulative_rock_uplift_variable)
plt.xlabel("Time [years]")
plt.ylabel("Cumulative Fault Throw [m]")
plt.show()

A technical note: Beyond the times specified, the internal workings of the NormalFault will use the final value provided in the rate array.

Let's see how this changes the model results.


In [ ]:
# instantiate the grid
grid = HexModelGrid((nr, nc), 10, node_layout="rect")

# add a topographic__elevation field with noise
z = grid.add_zeros("topographic__elevation", at="node")
z[grid.core_nodes] += 100.0 + np.random.randn(grid.core_nodes.size)

fr = FlowAccumulator(grid)
fs = FastscapeEroder(grid, K_sp=K)
nf = NormalFault(
    grid,
    fault_throw_rate_through_time={"time": time, "rate": rate},
    fault_trace={"x1": 0, "x2": 800, "y1": 0, "y2": 500},
    include_boundaries=True,
)

# Run this model for 300 100-year timesteps (30,000 years).
for i in range(300):
    nf.run_one_step(dt)
    fr.run_one_step()
    fs.run_one_step(dt)
    z[grid.core_nodes] += U * dt

# plot the final topography
imshow_grid(grid, z)

As you can see the resulting topography is very different than in the case with continuous uplift.

For our final example, we'll use NormalFault with a more complicated model in which we have both a soil layer and bedrock. In order to move, material must convert from bedrock to soil by weathering.

First we import remaining modules and set some parameter values


In [ ]:
from landlab.components import DepthDependentDiffuser, ExponentialWeatherer

# here are the parameters to change
K = 0.0005  # stream power coefficient, bigger = streams erode more quickly
U = 0.0001  # uplift rate in meters per year
max_soil_production_rate = (
    0.001
)  # Maximum weathering rate for bare bedrock in meters per year
soil_production_decay_depth = 0.7  # Characteristic weathering depth in meters
linear_diffusivity = 0.01  # Hillslope diffusivity and m2 per years
soil_transport_decay_depth = 0.5  # Characteristic soil transport depth in meters

dt = 100  # time step in years
dx = 10  # space step in meters

nr = 60  # number of model rows
nc = 100  # number of model columns

In [ ]:
?ExponentialWeatherer

Next we create the grid and run the model.


In [ ]:
# instantiate the grid
grid = HexModelGrid((nr, nc), 10, node_layout="rect")

# add a topographic__elevation field with noise
z = grid.add_zeros("topographic__elevation", at="node")
z[grid.core_nodes] += 100.0 + np.random.randn(grid.core_nodes.size)

# create a field for soil depth
d = grid.add_zeros("soil__depth", at="node")

# create a bedrock elevation field
b = grid.add_zeros("bedrock__elevation", at="node")
b[:] = z - d

fr = FlowAccumulator(grid, depression_finder="DepressionFinderAndRouter", routing="D4")
fs = FastscapeEroder(grid, K_sp=K)
ew = ExponentialWeatherer(
    grid,
    soil_production__decay_depth=soil_production_decay_depth,
    soil_production__maximum_rate=max_soil_production_rate,
)

dd = DepthDependentDiffuser(
    grid,
    linear_diffusivity=linear_diffusivity,
    soil_transport_decay_depth=soil_transport_decay_depth,
)

nf = NormalFault(
    grid,
    fault_throw_rate_through_time={"time": [0, 30], "rate": [0.001, 0.001]},
    fault_trace={"x1": 0, "x2": 800, "y1": 0, "y2": 500},
    include_boundaries=False,
)

# Run this model for 300 100-year timesteps (30,000 years).
for i in range(300):

    # Move normal fault
    nf.run_one_step(dt)

    # Route flow
    fr.run_one_step()

    # Erode with water
    fs.run_one_step(dt)

    # We must also now erode the bedrock where relevant. If water erosion
    # into bedrock has occurred, the bedrock elevation will be higher than
    # the actual elevation, so we simply re-set bedrock elevation to the
    # lower of itself or the current elevation.
    b = grid.at_node["bedrock__elevation"]
    b[:] = np.minimum(b, grid.at_node["topographic__elevation"])

    # Calculate regolith-production rate
    ew.calc_soil_prod_rate()

    # Generate and move soil around. This component will update both the
    # soil thickness and topographic elevation fields.
    dd.run_one_step(dt)

    # uplift the whole domain, we need to do this to both bedrock and topography
    z[grid.core_nodes] += U * dt
    b[grid.core_nodes] += U * dt

# plot the final topography
imshow_grid(grid, "topographic__elevation")

We can also examine the soil thickness and soil production rate. Here in the soil depth, we see it is highest along the ridge crests.


In [ ]:
# and the soil depth
imshow_grid(grid, "soil__depth", cmap="viridis")

The soil production rate is highest where the soil depth is low, as we would expect given the exponential form.


In [ ]:
# and the soil production rate
imshow_grid(grid, "soil_production__rate", cmap="viridis")

In [ ]: