Skip to content

Nonlinear Layered Soil Column

This example extends the layered elastic column with staged PressureDependMultiYield materials and amplitude-dependent sand response.

Application One-dimensional nonlinear site response
Material behavior Pressure-dependent multi-yield sand plasticity
Analysis sequence Elastic gravity, plastic staging, transient excitation
Nonlinear solution Newton line search with Newmark integration
Output Motion, stress, strain, and deformation histories
Execution Serial

Why Material Staging Matters

The soil must begin the earthquake analysis in equilibrium under its own weight. Activating plasticity before gravity is established can introduce unwanted yielding while the initial stress state is still forming.

Femora therefore separates initialization from dynamic response:

flowchart LR
    A[Stage 0<br/>Elastic material] --> B[Establish<br/>gravity stress]
    B --> C[Stage 1<br/>Activate yield surfaces]
    C --> D[Re-equilibrate]
    D --> E[Apply base motion]
    E --> F[Nonlinear response]

The geometry does not change between stages. Femora changes how the registered materials respond and then continues with the same assembled finite-element domain.

Model

The column retains the geometry and initial elastic stiffness of the earlier elastic benchmark. That makes differences in amplification and energy dissipation attributable to material nonlinearity rather than a changed mesh.

Soil Profile

Stratum Thickness (m) \(G_{ref}\) (MPa) Unit weight (kN/m\(^3\)) Friction angle
Dense Ottawa 10.0 145 19.9 40 degrees
Loose Ottawa 6.0 75 19.1 29 degrees
Dense Monterey 2.0 42 19.8 40 degrees

The OpenSees material expects one consistent unit system. The source converts the tabulated moduli from Pa to kPa and density from kg/m\(^3\) to Mg/m\(^3\) before creating the material objects.

The frictional, contraction, dilation, and cyclic-mobility parameters use the OpenSees loose- and dense-sand families as starting values. They are not a substitute for calibration against laboratory or site-specific response data.

Drainage assumption

stdBrick elements with PressureDependMultiYield represent drained soil response. Undrained or partially drained response requires coupled u-p elements, pore-pressure degrees of freedom, and calibrated permeability.

Three materials, five mesh parts

The Dense Ottawa stratum is split into three mesh parts to control vertical discretization, but all three parts reference the same physical nonlinear material. Mesh organization and material identity remain separate choices.

Worked Workflow

Each physical stratum receives reference shear and bulk moduli, density, strength parameters, and twenty nested yield surfaces.

OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
model = Model(
    model_name="nonlinear_layered_soil_column",
    model_path=str(OUTPUT_DIR.resolve()),
)
model.set_results_folder(RESULTS_DIR.resolve().as_posix())

materials = {}
elements = {}
for soil_name, properties in SOILS.items():
    shear_modulus_si = properties["shear_modulus"]
    poisson_ratio = properties["poisson_ratio"]
    density_si = properties["unit_weight"] * 1_000.0 / GRAVITY

    shear_modulus = shear_modulus_si / 1_000.0
    bulk_modulus = (
        2.0
        * shear_modulus_si
        * (1.0 + poisson_ratio)
        / (3.0 * (1.0 - 2.0 * poisson_ratio))
        / 1_000.0
    )
    density = density_si / 1_000.0

    material = model.material.nd.pressure_depend_multi_yield(
        user_name=f"{soil_name}_material",
        nd=3,
        rho=density,
        refShearModul=shear_modulus,
        refBulkModul=bulk_modulus,
        frictionAng=properties["friction_angle"],
        peakShearStra=PEAK_SHEAR_STRAIN,
        refPress=REFERENCE_PRESSURE,
        pressDependCoe=PRESSURE_DEPENDENCE,
        PTAng=properties["phase_transformation_angle"],
        contrac=properties["contraction"],
        dilat1=properties["dilation_1"],
        dilat2=properties["dilation_2"],
        liquefac1=properties["liquefaction_1"],
        liquefac2=properties["liquefaction_2"],
        liquefac3=properties["liquefaction_3"],
        noYieldSurf=20,
        e=properties["void_ratio"],
    )
    materials[soil_name] = material
    elements[soil_name] = model.element.brick.std(
        ndof=3,
        material=material,
        b1=0.0,
        b2=0.0,
        b3=-GRAVITY * density,
    )

Five independently discretized mesh parts form one merged laminar column.

rayleigh_damping = model.damping.frequency_rayleigh(
    user_name="soil_damping",
    f1=2.76,
    f2=13.84,
    damping_factor=0.03,
)
soil_region = model.region.element(
    user_name="nonlinear_soil_column",
    damping=rayleigh_damping,
)

z_bottom = -COLUMN_DEPTH
layer_names = []
for layer_name, soil_name, thickness, element_size in LAYERS:
    model.meshpart.volume.uniform_rectangular_grid(
        user_name=layer_name,
        element=elements[soil_name],
        region=soil_region,
        x_min=0.0,
        x_max=COLUMN_WIDTH,
        y_min=0.0,
        y_max=COLUMN_WIDTH,
        z_min=z_bottom,
        z_max=z_bottom + thickness,
        nx=1,
        ny=1,
        nz=round(thickness / element_size),
    )
    layer_names.append(layer_name)
    z_bottom += thickness

if abs(z_bottom) > 1.0e-9:
    raise RuntimeError(f"Layer thicknesses terminate at z={z_bottom}, expected z=0")

model.assembler.create_section(
    meshparts=layer_names,
    num_partitions=0,
    merge_points=True,
)
model.assembler.assemble(merge_points=True, progress_callback=lambda *_: None)

model.constraint.mp.laminar_boundary(
    bounds=(-COLUMN_DEPTH + 0.1, 0.0),
    dofs=[1, 2, 3],
    direction=3,
)
model.constraint.sp.fix_macro_z_min(dofs=[1, 1, 1], tol=1.0e-6)

The frequency sweep drives the base while VTKHDF records nodal motion and six-component element stress and strain.

motion_directory = motions_dir()
excitation_series = model.time_series.path(
    filePath=(motion_directory / "FrequencySweep.acc").resolve().as_posix(),
    fileTime=(motion_directory / "FrequencySweep.time").resolve().as_posix(),
    factor=GRAVITY,
)
uniform_excitation = model.pattern.uniform_excitation(
    dof=1,
    time_series=excitation_series,
)
response_recorder = model.recorder.vtkhdf(
    file_base_name="site_response.vtkhdf",
    resp_types=["accel", "disp", "vel", "stress3D6", "strain3D6"],
    delta_t=0.01,
)

The process makes the elastic-to-plastic transition explicit. Newton line search handles the nonlinear equilibrium iterations during the response.

constraint_handler = model.analysis.constraint.transformation()
numberer = model.analysis.numberer.rcm()
system = model.analysis.system.bandgeneral()

gravity_algorithm = model.analysis.algorithm.newton()
dynamic_algorithm = model.analysis.algorithm.modifiednewton(factor_once=True)

test = model.analysis.test.normunbalance(tol=1.0e-7, max_iter=30)
dynamic_test = model.analysis.test.normdispincr(tol=1.0e-5, max_iter=5)

gravity_integrator = model.analysis.integrator.newmark(gamma=0.6, beta=0.3025)
dynamic_integrator = model.analysis.integrator.newmark(gamma=0.5, beta=0.25)

elastic_gravity = model.analysis.transient(
    name="elastic_gravity",
    constraint_handler=constraint_handler,
    numberer=numberer,
    system=system,
    algorithm=gravity_algorithm,
    test=test,
    integrator=gravity_integrator,
    dt=1.0,
    num_steps=30,
)

plastic_equilibration = model.analysis.transient(
    name="plastic_equilibration",
    constraint_handler=constraint_handler,
    numberer=numberer,
    system=system,
    algorithm=dynamic_algorithm,
    test=test,
    integrator=gravity_integrator,
    dt=0.01,
    num_steps=100,
)
dynamic_analysis = model.analysis.transient(
    name="nonlinear_frequency_sweep",
    constraint_handler=constraint_handler,
    numberer=numberer,
    system=system,
    algorithm=dynamic_algorithm,
    test=dynamic_test,
    integrator=dynamic_integrator,
    dt=DYNAMIC_DT,
    final_time=DYNAMIC_FINAL_TIME,
    max_retries=1,
    num_sublevels=2,
    num_substeps=2,
)

model.process.add_step(
    model.actions.update_material_stage_to_elastic(),
    "Set nonlinear materials to elastic stage 0",
)
model.process.add_step(elastic_gravity, "Establish gravity stress")
model.process.add_step(
    model.actions.update_material_stage_to_plastic(),
    "Activate plastic material stage 1",
)
model.process.add_step(plastic_equilibration, "Re-equilibrate after staging")
model.process.add_step(uniform_excitation, "Apply the frequency sweep")
model.process.add_step(response_recorder, "Record fields and material response")
model.process.add_step(model.actions.set_time(0.0), "Reset pseudo-time")
model.process.add_step(dynamic_analysis, "Run nonlinear site response")

Results And Post-Processing

Run the maintained companion after OpenSees completes:

python examples/site_response/nonlinear_layered_soil_column_postprocess.py
def generate_results() -> tuple[Path, ...]:
    """Generate amplification and material-hysteresis plots."""
    POSTPROCESS_DIR.mkdir(parents=True, exist_ok=True)
    amplification_plot = POSTPROCESS_DIR / "nonlinear-amplification.png"
    hysteresis_plot = POSTPROCESS_DIR / "shear-hysteresis.png"

    result_pattern = str(RESULTS_DIR / "site_response*.vtkhdf")
    with fm.results.open(result_pattern) as results:
        surface = results.nearest_point(SURFACE_COORDINATE, tolerance=1.0e-6)
        surface_acceleration = results.point_history(
            "acceleration",
            surface,
            component="x",
        )
        numerical_frequency, nonlinear_tf = compute_sweep_amplification(
            results.times,
            surface_acceleration,
        )
        elastic_frequency, elastic_tf = compute_analytical_transfer_function()
        save_amplification_plot(
            numerical_frequency,
            nonlinear_tf,
            elastic_frequency,
            elastic_tf,
            amplification_plot,
        )

        mesh = results.mesh()
        cell_centers = mesh.cell_centers().points
        cell_index = int(
            np.argmin(np.linalg.norm(cell_centers - HYSTERESIS_COORDINATE, axis=1))
        )
        shear_stress = results.cell_history(
            "stress3D6",
            cell_index,
            component=5,
        )
        shear_strain = results.cell_history(
            "strain3D6",
            cell_index,
            component=5,
        )
        save_hysteresis_plot(shear_strain, shear_stress, hysteresis_plot)

    return amplification_plot, hysteresis_plot


def generate_animations() -> tuple[Path, ...]:
    """Generate the optional amplified nonlinear response animation."""
    POSTPROCESS_DIR.mkdir(parents=True, exist_ok=True)
    output_file = POSTPROCESS_DIR / "response.mp4"
    result_pattern = str(RESULTS_DIR / "site_response*.vtkhdf")
    with fm.results.open(result_pattern) as results:
        save_response_movie(
            results,
            output_file,
            deformation_scale=1.0,
            stride=20,
            frame_rate=50,
        )
    return (output_file,)
Complete post-processing source
# =============================================================================
# Femora: Fast Efficient Meta-modeling for OpenSees-based Resilience Analysis
# Copyright 2026 Amin Pakzad and Pedro Arduino
# Developed at the UW Geotechnical Lab
# SPDX-License-Identifier: Apache-2.0
# =============================================================================

# femora-colab-source: examples/site_response/layered_elastic_soil_column_postprocess.py

"""Plot and animate the nonlinear layered soil-column response."""

from __future__ import annotations

from pathlib import Path

import femora as fm
import matplotlib.pyplot as plt
import numpy as np
from scipy.signal import hilbert

from femora.utils.paths import motions_dir

if __package__:
    from .layered_elastic_soil_column_postprocess import (
        compute_analytical_transfer_function,
        save_response_movie,
    )
else:
    from layered_elastic_soil_column_postprocess import (
        compute_analytical_transfer_function,
        save_response_movie,
    )


OUTPUT_DIR = Path("example_outputs") / "nonlinear_layered_soil_column"
RESULTS_DIR = OUTPUT_DIR / "results"
POSTPROCESS_DIR = OUTPUT_DIR / "post_processing"

SURFACE_COORDINATE = np.array([0.0, 0.0, 0.0])
HYSTERESIS_COORDINATE = np.array([0.5, 0.5, -5.0])
MAX_FREQUENCY = 22.0
GRAVITY = 9.81


def compute_sweep_amplification(
    response_time: np.ndarray,
    relative_surface_acceleration: np.ndarray,
) -> tuple[np.ndarray, np.ndarray]:
    """Estimate the fundamental response locally along the frequency sweep."""
    motion_directory = motions_dir()
    input_time = np.loadtxt(motion_directory / "FrequencySweep.time")
    input_acceleration = (
        np.loadtxt(motion_directory / "FrequencySweep.acc") * GRAVITY
    )
    base_acceleration = np.interp(
        response_time,
        input_time,
        input_acceleration,
    )
    surface_acceleration = relative_surface_acceleration + base_acceleration

    analytic_input = hilbert(base_acceleration)
    phase = np.unwrap(np.angle(analytic_input))
    instantaneous_frequency = np.gradient(phase, response_time) / (2.0 * np.pi)
    envelope = np.abs(analytic_input)
    edge_margin = min(1.0, 0.05 * float(np.ptp(response_time)))
    valid = (
        (envelope > 0.1 * float(np.max(envelope)))
        & (response_time > response_time[0] + edge_margin)
        & (response_time < response_time[-1] - edge_margin)
        & (instantaneous_frequency >= 0.5)
        & (instantaneous_frequency <= MAX_FREQUENCY)
    )
    valid_indices = np.flatnonzero(valid)
    if valid_indices.size == 0:
        raise ValueError("The recorded interval does not contain the frequency sweep")

    frequencies = np.arange(0.5, MAX_FREQUENCY + 0.05, 0.1)
    amplification = np.full(frequencies.shape, np.nan, dtype=float)
    for index, frequency in enumerate(frequencies):
        center = valid_indices[
            np.argmin(
                np.abs(instantaneous_frequency[valid_indices] - frequency)
            )
        ]
        window_duration = float(np.clip(4.0 / frequency, 1.0, 4.0))
        window = np.abs(response_time - response_time[center]) <= window_duration / 2.0
        local_time = response_time[window] - response_time[center]
        basis = np.column_stack(
            (
                np.cos(phase[window]),
                np.sin(phase[window]),
                np.ones(np.count_nonzero(window)),
                local_time,
            )
        )
        base_coefficients = np.linalg.lstsq(
            basis,
            base_acceleration[window],
            rcond=None,
        )[0]
        surface_coefficients = np.linalg.lstsq(
            basis,
            surface_acceleration[window],
            rcond=None,
        )[0]
        base_amplitude = np.hypot(*base_coefficients[:2])
        if base_amplitude > np.finfo(float).eps:
            amplification[index] = (
                np.hypot(*surface_coefficients[:2]) / base_amplitude
            )

    return frequencies, amplification


def save_amplification_plot(
    numerical_frequency: np.ndarray,
    nonlinear_transfer_function: np.ndarray,
    elastic_frequency: np.ndarray,
    elastic_transfer_function: np.ndarray,
    output_file: Path,
) -> None:
    """Compare nonlinear amplification with the elastic reference profile."""
    mask = (numerical_frequency > 0.0) & (numerical_frequency <= MAX_FREQUENCY)
    figure, axes = plt.subplots(
        2,
        1,
        figsize=(9.0, 6.4),
        sharex=True,
        constrained_layout=True,
    )
    axes[0].plot(
        numerical_frequency[mask],
        np.abs(nonlinear_transfer_function[mask]),
        color="#315c6d",
        linewidth=1.6,
    )
    axes[0].set_ylabel("Amplification")
    axes[0].set_title("Nonlinear sweep response (fundamental)", loc="left")

    axes[1].plot(
        elastic_frequency,
        np.abs(elastic_transfer_function),
        color="#b65f3a",
        linewidth=1.5,
        linestyle="--",
    )
    axes[1].set(
        xlabel="Frequency (Hz)",
        ylabel="Amplification",
        xlim=(0.0, MAX_FREQUENCY),
    )
    axes[1].set_title("Elastic reference", loc="left")
    for axis in axes:
        axis.grid(alpha=0.25)
        axis.spines[["top", "right"]].set_visible(False)
    figure.savefig(output_file, dpi=180)
    plt.close(figure)


def save_hysteresis_plot(
    shear_strain: np.ndarray,
    shear_stress: np.ndarray,
    output_file: Path,
) -> None:
    """Plot the representative xz shear stress-strain response."""
    figure, axis = plt.subplots(figsize=(6.8, 5.4), constrained_layout=True)
    axis.plot(
        100.0 * shear_strain,
        shear_stress,
        color="#315c6d",
        linewidth=1.25,
    )
    axis.set(xlabel="Shear strain, gamma_xz (%)", ylabel="Shear stress, tau_xz (kPa)")
    axis.grid(alpha=0.25)
    axis.spines[["top", "right"]].set_visible(False)
    figure.savefig(output_file, dpi=180)
    plt.close(figure)


def generate_results() -> tuple[Path, ...]:
    """Generate amplification and material-hysteresis plots."""
    POSTPROCESS_DIR.mkdir(parents=True, exist_ok=True)
    amplification_plot = POSTPROCESS_DIR / "nonlinear-amplification.png"
    hysteresis_plot = POSTPROCESS_DIR / "shear-hysteresis.png"

    result_pattern = str(RESULTS_DIR / "site_response*.vtkhdf")
    with fm.results.open(result_pattern) as results:
        surface = results.nearest_point(SURFACE_COORDINATE, tolerance=1.0e-6)
        surface_acceleration = results.point_history(
            "acceleration",
            surface,
            component="x",
        )
        numerical_frequency, nonlinear_tf = compute_sweep_amplification(
            results.times,
            surface_acceleration,
        )
        elastic_frequency, elastic_tf = compute_analytical_transfer_function()
        save_amplification_plot(
            numerical_frequency,
            nonlinear_tf,
            elastic_frequency,
            elastic_tf,
            amplification_plot,
        )

        mesh = results.mesh()
        cell_centers = mesh.cell_centers().points
        cell_index = int(
            np.argmin(np.linalg.norm(cell_centers - HYSTERESIS_COORDINATE, axis=1))
        )
        shear_stress = results.cell_history(
            "stress3D6",
            cell_index,
            component=5,
        )
        shear_strain = results.cell_history(
            "strain3D6",
            cell_index,
            component=5,
        )
        save_hysteresis_plot(shear_strain, shear_stress, hysteresis_plot)

    return amplification_plot, hysteresis_plot


def generate_animations() -> tuple[Path, ...]:
    """Generate the optional amplified nonlinear response animation."""
    POSTPROCESS_DIR.mkdir(parents=True, exist_ok=True)
    output_file = POSTPROCESS_DIR / "response.mp4"
    result_pattern = str(RESULTS_DIR / "site_response*.vtkhdf")
    with fm.results.open(result_pattern) as results:
        save_response_movie(
            results,
            output_file,
            deformation_scale=1.0,
            stride=20,
            frame_rate=50,
        )
    return (output_file,)


def main() -> None:
    """Generate all documented post-processing outputs."""
    generated = (*generate_results(), *generate_animations())
    print("Post-processing outputs:")
    for output in generated:
        print(f"  {output.resolve()}")


if __name__ == "__main__":
    main()

nonlinear-amplification.png follows the frequency sweep in time and fits the fundamental surface response locally at each excitation frequency. It is not a linear transfer function: yielding generates harmonics and makes the response depend on loading history, so its peaks are expected to be broader and lower than the elastic reference.

Nonlinear sweep amplification and elastic reference

shear-hysteresis.png extracts the \(xz\) stress-strain history from a representative Loose Ottawa element. Closed loops show hysteretic energy dissipation that is absent from the elastic benchmark.

Nonlinear xz shear stress-strain response

The optional response.mp4 animation shows amplified horizontal deformation at physical scale while preserving the physical-stratum colors used throughout the site-response examples.

Run The Example

Use Open in Colab for the complete browser workflow. A nonlinear analysis is more expensive than the elastic examples because equilibrium must be checked at every time step.

For local export:

python examples/site_response/nonlinear_layered_soil_column.py

To execute OpenSees as well:

$env:FEMORA_OPENSEES = "D:\path\to\OpenSees.exe"
python examples/site_response/nonlinear_layered_soil_column.py

The solver run writes VTKHDF fields under example_outputs/nonlinear_layered_soil_column/results/. Femora reports the analysis step immediately if nonlinear equilibrium cannot be reached.

Complete source
# =============================================================================
# Femora: Fast Efficient Meta-modeling for OpenSees-based Resilience Analysis
# Copyright 2026 Amin Pakzad and Pedro Arduino
# Developed at the UW Geotechnical Lab
# SPDX-License-Identifier: Apache-2.0
# =============================================================================

# femora-colab-input: examples/inputs/motions/FrequencySweep.acc
# femora-colab-input: examples/inputs/motions/FrequencySweep.time
# femora-postprocess: examples/site_response/nonlinear_layered_soil_column_postprocess.py

# %% [markdown]
# # Nonlinear Layered Soil Column
#
# Build a staged PressureDependMultiYield soil profile, establish gravity in
# its elastic state, activate plasticity, and run transient site response.

# %%
"""Run a staged nonlinear layered-soil analysis with the current Femora API."""

from __future__ import annotations

import os
from pathlib import Path

from femora import Model, runtime
from femora.utils.paths import motions_dir


OUTPUT_DIR = Path("example_outputs") / "nonlinear_layered_soil_column"
RESULTS_DIR = OUTPUT_DIR / "results"
OPENSEES = os.environ.get("FEMORA_OPENSEES")
PLOT_MODEL = False

COLUMN_WIDTH = 1.0
COLUMN_DEPTH = 18.0
GRAVITY = 9.81
DYNAMIC_DT = 0.001
DYNAMIC_FINAL_TIME = 40.0
PEAK_SHEAR_STRAIN = 0.1
REFERENCE_PRESSURE = 80.0
PRESSURE_DEPENDENCE = 0.5

SOILS = {
    "dense_ottawa": {
        "shear_modulus": 145.0e6,
        "unit_weight": 19.9,
        "poisson_ratio": 0.3,
        "friction_angle": 40.0,
        "phase_transformation_angle": 27.0,
        "contraction": 0.03,
        "dilation_1": 0.8,
        "dilation_2": 5.0,
        "liquefaction_1": 0.0,
        "liquefaction_2": 0.0,
        "liquefaction_3": 0.0,
        "void_ratio": 0.45,
    },
    "loose_ottawa": {
        "shear_modulus": 75.0e6,
        "unit_weight": 19.1,
        "poisson_ratio": 0.3,
        "friction_angle": 29.0,
        "phase_transformation_angle": 29.0,
        "contraction": 0.21,
        "dilation_1": 0.0,
        "dilation_2": 0.0,
        "liquefaction_1": 10.0,
        "liquefaction_2": 0.02,
        "liquefaction_3": 1.0,
        "void_ratio": 0.85,
    },
    "dense_monterey": {
        "shear_modulus": 42.0e6,
        "unit_weight": 19.8,
        "poisson_ratio": 0.3,
        "friction_angle": 40.0,
        "phase_transformation_angle": 27.0,
        "contraction": 0.03,
        "dilation_1": 0.8,
        "dilation_2": 5.0,
        "liquefaction_1": 0.0,
        "liquefaction_2": 0.0,
        "liquefaction_3": 0.0,
        "void_ratio": 0.45,
    },
}

LAYERS = [
    ("dense_ottawa_lower", "dense_ottawa", 2.6, 1.3),
    ("dense_ottawa_middle", "dense_ottawa", 2.4, 1.2),
    ("dense_ottawa_upper", "dense_ottawa", 5.0, 1.0),
    ("loose_ottawa", "loose_ottawa", 6.0, 0.5),
    ("dense_monterey", "dense_monterey", 2.0, 0.5),
]


# %% [markdown]
# ## Define the nonlinear materials
#
# Femora uses a kN-m-s unit system here. Reference moduli are therefore
# converted from Pa to kPa and density from kg/m3 to Mg/m3.

# %%
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
model = Model(
    model_name="nonlinear_layered_soil_column",
    model_path=str(OUTPUT_DIR.resolve()),
)
model.set_results_folder(RESULTS_DIR.resolve().as_posix())

materials = {}
elements = {}
for soil_name, properties in SOILS.items():
    shear_modulus_si = properties["shear_modulus"]
    poisson_ratio = properties["poisson_ratio"]
    density_si = properties["unit_weight"] * 1_000.0 / GRAVITY

    shear_modulus = shear_modulus_si / 1_000.0
    bulk_modulus = (
        2.0
        * shear_modulus_si
        * (1.0 + poisson_ratio)
        / (3.0 * (1.0 - 2.0 * poisson_ratio))
        / 1_000.0
    )
    density = density_si / 1_000.0

    material = model.material.nd.pressure_depend_multi_yield(
        user_name=f"{soil_name}_material",
        nd=3,
        rho=density,
        refShearModul=shear_modulus,
        refBulkModul=bulk_modulus,
        frictionAng=properties["friction_angle"],
        peakShearStra=PEAK_SHEAR_STRAIN,
        refPress=REFERENCE_PRESSURE,
        pressDependCoe=PRESSURE_DEPENDENCE,
        PTAng=properties["phase_transformation_angle"],
        contrac=properties["contraction"],
        dilat1=properties["dilation_1"],
        dilat2=properties["dilation_2"],
        liquefac1=properties["liquefaction_1"],
        liquefac2=properties["liquefaction_2"],
        liquefac3=properties["liquefaction_3"],
        noYieldSurf=20,
        e=properties["void_ratio"],
    )
    materials[soil_name] = material
    elements[soil_name] = model.element.brick.std(
        ndof=3,
        material=material,
        b1=0.0,
        b2=0.0,
        b3=-GRAVITY * density,
    )


# %% [markdown]
# ## Build and assemble the layered mesh

# %%
rayleigh_damping = model.damping.frequency_rayleigh(
    user_name="soil_damping",
    f1=2.76,
    f2=13.84,
    damping_factor=0.03,
)
soil_region = model.region.element(
    user_name="nonlinear_soil_column",
    damping=rayleigh_damping,
)

z_bottom = -COLUMN_DEPTH
layer_names = []
for layer_name, soil_name, thickness, element_size in LAYERS:
    model.meshpart.volume.uniform_rectangular_grid(
        user_name=layer_name,
        element=elements[soil_name],
        region=soil_region,
        x_min=0.0,
        x_max=COLUMN_WIDTH,
        y_min=0.0,
        y_max=COLUMN_WIDTH,
        z_min=z_bottom,
        z_max=z_bottom + thickness,
        nx=1,
        ny=1,
        nz=round(thickness / element_size),
    )
    layer_names.append(layer_name)
    z_bottom += thickness

if abs(z_bottom) > 1.0e-9:
    raise RuntimeError(f"Layer thicknesses terminate at z={z_bottom}, expected z=0")

model.assembler.create_section(
    meshparts=layer_names,
    num_partitions=0,
    merge_points=True,
)
model.assembler.assemble(merge_points=True, progress_callback=lambda *_: None)

model.constraint.mp.laminar_boundary(
    bounds=(-COLUMN_DEPTH + 0.1, 0.0),
    dofs=[1, 2, 3],
    direction=3,
)
model.constraint.sp.fix_macro_z_min(dofs=[1, 1, 1], tol=1.0e-6)


# %% [markdown]
# ## Define excitation and nonlinear output

# %%
motion_directory = motions_dir()
excitation_series = model.time_series.path(
    filePath=(motion_directory / "FrequencySweep.acc").resolve().as_posix(),
    fileTime=(motion_directory / "FrequencySweep.time").resolve().as_posix(),
    factor=GRAVITY,
)
uniform_excitation = model.pattern.uniform_excitation(
    dof=1,
    time_series=excitation_series,
)
response_recorder = model.recorder.vtkhdf(
    file_base_name="site_response.vtkhdf",
    resp_types=["accel", "disp", "vel", "stress3D6", "strain3D6"],
    delta_t=0.01,
)


# %% [markdown]
# ## Stage gravity and activate plasticity
#
# Stage 0 establishes geostatic stress with elastic material response. Stage 1
# activates the yield surfaces, followed by a short re-equilibration analysis.

# %%
constraint_handler = model.analysis.constraint.transformation()
numberer = model.analysis.numberer.rcm()
system = model.analysis.system.bandgeneral()

gravity_algorithm = model.analysis.algorithm.newton()
dynamic_algorithm = model.analysis.algorithm.modifiednewton(factor_once=True)

test = model.analysis.test.normunbalance(tol=1.0e-7, max_iter=30)
dynamic_test = model.analysis.test.normdispincr(tol=1.0e-5, max_iter=5)

gravity_integrator = model.analysis.integrator.newmark(gamma=0.6, beta=0.3025)
dynamic_integrator = model.analysis.integrator.newmark(gamma=0.5, beta=0.25)

elastic_gravity = model.analysis.transient(
    name="elastic_gravity",
    constraint_handler=constraint_handler,
    numberer=numberer,
    system=system,
    algorithm=gravity_algorithm,
    test=test,
    integrator=gravity_integrator,
    dt=1.0,
    num_steps=30,
)

plastic_equilibration = model.analysis.transient(
    name="plastic_equilibration",
    constraint_handler=constraint_handler,
    numberer=numberer,
    system=system,
    algorithm=dynamic_algorithm,
    test=test,
    integrator=gravity_integrator,
    dt=0.01,
    num_steps=100,
)
dynamic_analysis = model.analysis.transient(
    name="nonlinear_frequency_sweep",
    constraint_handler=constraint_handler,
    numberer=numberer,
    system=system,
    algorithm=dynamic_algorithm,
    test=dynamic_test,
    integrator=dynamic_integrator,
    dt=DYNAMIC_DT,
    final_time=DYNAMIC_FINAL_TIME,
    max_retries=1,
    num_sublevels=2,
    num_substeps=2,
)

model.process.add_step(
    model.actions.update_material_stage_to_elastic(),
    "Set nonlinear materials to elastic stage 0",
)
model.process.add_step(elastic_gravity, "Establish gravity stress")
model.process.add_step(
    model.actions.update_material_stage_to_plastic(),
    "Activate plastic material stage 1",
)
model.process.add_step(plastic_equilibration, "Re-equilibrate after staging")
model.process.add_step(uniform_excitation, "Apply the frequency sweep")
model.process.add_step(response_recorder, "Record fields and material response")
model.process.add_step(model.actions.set_time(0.0), "Reset pseudo-time")
model.process.add_step(dynamic_analysis, "Run nonlinear site response")


# %% [markdown]
# ## Export and optionally execute

# %%
tcl_file = OUTPUT_DIR / "nonlinear_layered_soil_column.tcl"
model.export_to_tcl(
    filename=str(tcl_file.resolve()),
    progress_callback=lambda *_: None,
)

print("\nNonlinear layered soil column")
print(f"  Nodes:       {model.assembled_mesh.n_points}")
print(f"  Elements:    {model.assembled_mesh.n_cells}")
print(f"  Materials:   {len(materials)} nonlinear soil materials")
print(f"  Tcl model:   {tcl_file.resolve()}")

if OPENSEES is None:
    print("  Solver:      not run (set FEMORA_OPENSEES to run OpenSees)")
else:
    runtime.run(
        tcl_file,
        executable=OPENSEES,
        cwd=OUTPUT_DIR.resolve(),
    )
    print("  Solver:      completed")


# %% [markdown]
# ## Optional visualization

# %%
if PLOT_MODEL:
    model.assembler.plot(show_edges=True, scalars="MaterialTag")