Skip to content

Elastic Cantilever

This tutorial builds a four-element 3D cantilever, applies a transverse tip load, runs a linear static analysis in OpenSees, and checks the computed displacement against the Euler-Bernoulli solution.

Format Tutorial
Level Beginner
Analysis Linear static
Execution Serial
Model size 5 nodes, 4 elements

What You Will Build

The dashed line is the four-element model, while the solid curve shows its exaggerated displaced shape. The fixed support restrains all six degrees of freedom at node N1, and the load acts in the negative global z direction at node N5.

Run The Demo

Use Open in Colab above for a browser-based run. The first code cell installs Femora from GitHub, and fm.runtime.setup("colab") downloads and validates the portable OpenSees runtime automatically.

For a local run, configure the OpenSees executable and run the Python file:

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

Without FEMORA_OPENSEES, the script still assembles and exports the model. Set PLOT_MODEL = True in the configuration block to inspect the assembled mesh interactively.

Walk Through The Model

1. Configure The Example

The engineering properties and run options are collected at the top of the script. These values are shared by the model definition and the analytical check, so a controlled change only needs to be made once.

LENGTH = 4.0
TIP_LOAD = -1_000.0
ELASTIC_MODULUS = 200.0e9
AREA = 0.04
SECOND_MOMENT = 1.333333333e-4
SHEAR_MODULUS = 76.923e9
TORSIONAL_CONSTANT = 2.25e-4

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

2. Create The Building Blocks

The section stores the member stiffness properties. The transformation defines the local axes, and the element combines both into an OpenSees beam formulation.

OUTPUT_DIR.mkdir(parents=True, exist_ok=True)

model = Model(
    model_name="elastic_cantilever",
    model_path=str(OUTPUT_DIR.resolve()),
)
model.set_results_folder(OUTPUT_DIR.resolve().as_posix())

section = model.section.beam.elastic(
    user_name="cantilever_section",
    E=ELASTIC_MODULUS,
    A=AREA,
    Iz=SECOND_MOMENT,
    Iy=SECOND_MOMENT,
    G=SHEAR_MODULUS,
    J=TORSIONAL_CONSTANT,
)
transformation = model.transformation.transformation3d(
    transf_type="Linear",
    vecxz_x=0.0,
    vecxz_y=0.0,
    vecxz_z=1.0,
)
beam_element = model.element.beam.elastic(
    ndof=6,
    section=section,
    transformation=transformation,
)

Femora's managers assign tags and retain the relationships among these objects. You provide the engineering properties; the managers handle their model ownership and solver references.

3. Mesh And Assemble The Member

The line mesh part creates four cells between the two end coordinates. The assembly section then compiles that part into a serial model.

model.meshpart.line.single_line(
    user_name="cantilever",
    element=beam_element,
    x0=0.0,
    y0=0.0,
    z0=0.0,
    x1=LENGTH,
    y1=0.0,
    z1=0.0,
    number_of_lines=4,
)
model.assembler.create_section(
    ["cantilever"],
    num_partitions=0,
    merge_points=True,
)
model.assembler.assemble(merge_points=True, progress_callback=lambda *_: None)

num_partitions=0 explicitly keeps this assembly section serial. After assembly, model.assembled_mesh is a PyVista UnstructuredGrid with five points and four line cells.

4. Constrain And Select The Assembled Nodes

The fixed condition is applied to the plane at x=0. The tip is selected from the assembled model by position, so the script does not depend on a manually predicted node tag.

model.constraint.sp.fix_x(
    xCoordinate=0.0,
    dofs=[1, 1, 1, 1, 1, 1],
    tol=1.0e-9,
)

tip_nodes = model.mask.nodes.near_point(
    point=(LENGTH, 0.0, 0.0),
    radius=1.0e-9,
)
if len(tip_nodes) != 1:
    raise RuntimeError(f"Expected one cantilever tip node, found {len(tip_nodes)}")

Prefer selections over hard-coded solver tags

Spatial masks keep downstream operations tied to the model geometry. This is safer when discretization, assembly order, or tag starts change.

5. Apply The Load And Record The Response

A linear time series ramps the plain pattern from zero to the full load. The node load and recorder both use the selected tip node.

load_history = model.time_series.linear(factor=1.0)
lateral_load = model.pattern.plain(time_series=load_history)
lateral_load.add_load.node(
    node_mask=tip_nodes,
    values=[0.0, 0.0, TIP_LOAD, 0.0, 0.0, 0.0],
)

displacement_file = OUTPUT_DIR / "tip_displacement.out"
tip_recorder = model.recorder.node(
    file_name=displacement_file.resolve().as_posix(),
    nodes=tip_nodes.to_tags(),
    dofs=[3],
    resp_type="disp",
    time=True,
    precision=12,
)

6. Define The Analysis And Process

The analysis owns the numerical solution settings. The process determines when the load pattern, recorder, and analysis appear in the exported Tcl script.

static_analysis = model.analysis.static(
    name="tip_load",
    constraint_handler=model.analysis.constraint.transformation(),
    numberer=model.analysis.numberer.rcm(),
    system=model.analysis.system.bandgeneral(),
    algorithm=model.analysis.algorithm.linear(),
    test=model.analysis.test.normunbalance(tol=1.0e-10, max_iter=10),
    integrator=model.analysis.integrator.loadcontrol(incr=0.1),
    num_steps=10,
)

model.process.add_step(lateral_load, "Apply the cantilever tip load")
model.process.add_step(tip_recorder, "Record the tip displacement")
model.process.add_step(static_analysis, "Run the linear static analysis")

The ten load-control steps each add 0.1 to the load factor, so the final step reaches the full -1,000 N load.

Expected Result

A verified run prints the model size and the final comparison:

Elastic cantilever model
  Nodes:       5
  Elements:    4
  Tcl model:   .../example_outputs/elastic_cantilever/elastic_cantilever.tcl
  Tip disp.:   -8.000000e-04 m
  Analytical:  -8.000000e-04 m
  Rel. error:  0.000e+00
  Verification: passed

For a prismatic Euler-Bernoulli cantilever with a transverse tip force,

\[ u_{tip} = \frac{P L^3}{3 E I} = \frac{(-1000)(4)^3}{3(200\times10^9)(1.333333333\times10^{-4})} = -8.0\times10^{-4}\ \text{m}. \]

The numerical and analytical values should agree to floating-point precision for this linear elastic model.

Generated Files

The default example_outputs/elastic_cantilever/ directory contains:

File Purpose
elastic_cantilever.tcl Complete OpenSees input model
tip_displacement.out OpenSees time and tip-displacement history
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
# =============================================================================

# %% [markdown]
# # Elastic Cantilever with Femora
#
# Build a four-element 3D cantilever, run it with OpenSees, and compare the
# computed tip displacement with the Euler-Bernoulli solution.

# %%
"""Build and solve a small elastic cantilever with the current Femora API."""

from __future__ import annotations

import os
from pathlib import Path
import subprocess

import numpy as np

from femora import Model


LENGTH = 4.0
TIP_LOAD = -1_000.0
ELASTIC_MODULUS = 200.0e9
AREA = 0.04
SECOND_MOMENT = 1.333333333e-4
SHEAR_MODULUS = 76.923e9
TORSIONAL_CONSTANT = 2.25e-4

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


# %% [markdown]
# ## Create the building blocks
#
# The section and transformation define the beam behavior. The element combines
# those reusable definitions into the formulation used by the line mesh part.

# %%
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)

model = Model(
    model_name="elastic_cantilever",
    model_path=str(OUTPUT_DIR.resolve()),
)
model.set_results_folder(OUTPUT_DIR.resolve().as_posix())

section = model.section.beam.elastic(
    user_name="cantilever_section",
    E=ELASTIC_MODULUS,
    A=AREA,
    Iz=SECOND_MOMENT,
    Iy=SECOND_MOMENT,
    G=SHEAR_MODULUS,
    J=TORSIONAL_CONSTANT,
)
transformation = model.transformation.transformation3d(
    transf_type="Linear",
    vecxz_x=0.0,
    vecxz_y=0.0,
    vecxz_z=1.0,
)
beam_element = model.element.beam.elastic(
    ndof=6,
    section=section,
    transformation=transformation,
)


# %% [markdown]
# ## Mesh and assemble the member
#
# Femora creates four independent line elements and compiles them into the
# assembled model.

# %%
model.meshpart.line.single_line(
    user_name="cantilever",
    element=beam_element,
    x0=0.0,
    y0=0.0,
    z0=0.0,
    x1=LENGTH,
    y1=0.0,
    z1=0.0,
    number_of_lines=4,
)
model.assembler.create_section(
    ["cantilever"],
    num_partitions=0,
    merge_points=True,
)
model.assembler.assemble(merge_points=True, progress_callback=lambda *_: None)


# %% [markdown]
# ## Constrain and select the assembled nodes
#
# Fix the root and locate the tip geometrically instead of predicting its solver
# tag.

# %%
model.constraint.sp.fix_x(
    xCoordinate=0.0,
    dofs=[1, 1, 1, 1, 1, 1],
    tol=1.0e-9,
)

tip_nodes = model.mask.nodes.near_point(
    point=(LENGTH, 0.0, 0.0),
    radius=1.0e-9,
)
if len(tip_nodes) != 1:
    raise RuntimeError(f"Expected one cantilever tip node, found {len(tip_nodes)}")


# %% [markdown]
# ## Apply the load and record the response
#
# The load pattern and recorder both act on the selected tip node.

# %%
load_history = model.time_series.linear(factor=1.0)
lateral_load = model.pattern.plain(time_series=load_history)
lateral_load.add_load.node(
    node_mask=tip_nodes,
    values=[0.0, 0.0, TIP_LOAD, 0.0, 0.0, 0.0],
)

displacement_file = OUTPUT_DIR / "tip_displacement.out"
tip_recorder = model.recorder.node(
    file_name=displacement_file.resolve().as_posix(),
    nodes=tip_nodes.to_tags(),
    dofs=[3],
    resp_type="disp",
    time=True,
    precision=12,
)


# %% [markdown]
# ## Define the analysis and process
#
# The analysis stores the solution settings. The process places the pattern,
# recorder, and analysis into the exported execution sequence.

# %%
static_analysis = model.analysis.static(
    name="tip_load",
    constraint_handler=model.analysis.constraint.transformation(),
    numberer=model.analysis.numberer.rcm(),
    system=model.analysis.system.bandgeneral(),
    algorithm=model.analysis.algorithm.linear(),
    test=model.analysis.test.normunbalance(tol=1.0e-10, max_iter=10),
    integrator=model.analysis.integrator.loadcontrol(incr=0.1),
    num_steps=10,
)

model.process.add_step(lateral_load, "Apply the cantilever tip load")
model.process.add_step(tip_recorder, "Record the tip displacement")
model.process.add_step(static_analysis, "Run the linear static analysis")


# %% [markdown]
# ## Export the model

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

print("\nElastic cantilever model")
print(f"  Nodes:       {model.assembled_mesh.n_points}")
print(f"  Elements:    {model.assembled_mesh.n_cells}")
print(f"  Tcl model:   {tcl_file.resolve()}")


# %% [markdown]
# ## Run OpenSees and verify the response
#
# The Colab setup cell configures `FEMORA_OPENSEES` automatically. For a local
# run, set the same environment variable before executing this script.

# %%
if OPENSEES is None:
    print("  Solver:      not run (set FEMORA_OPENSEES to run OpenSees)")
else:
    completed = subprocess.run(
        [OPENSEES, str(tcl_file.resolve())],
        cwd=OUTPUT_DIR.resolve(),
        text=True,
        capture_output=True,
        check=False,
    )
    if completed.returncode != 0:
        if completed.stdout.strip():
            print(completed.stdout.rstrip())
        if completed.stderr.strip():
            print(completed.stderr.rstrip())
        raise RuntimeError(f"OpenSees failed with exit code {completed.returncode}")

    recorder_data = np.atleast_2d(np.loadtxt(displacement_file))
    numerical = float(recorder_data[-1, -1])
    analytical = TIP_LOAD * LENGTH**3 / (
        3.0 * ELASTIC_MODULUS * SECOND_MOMENT
    )
    relative_error = abs((numerical - analytical) / analytical)

    if not np.isclose(numerical, analytical, rtol=1.0e-9, atol=1.0e-12):
        raise RuntimeError(
            "Cantilever verification failed: "
            f"numerical={numerical:.12e}, analytical={analytical:.12e}"
        )

    print(f"  Tip disp.:   {numerical:.6e} m")
    print(f"  Analytical:  {analytical:.6e} m")
    print(f"  Rel. error:  {relative_error:.3e}")
    print("  Verification: passed")


# %% [markdown]
# ## Optional visualization

# %%
if PLOT_MODEL:
    model.assembled_mesh.plot(
        show_edges=True,
        line_width=5,
        color="#d67a2f",
        background="#f4f0e8",
    )

Try A Controlled Change

Double LENGTH from 4.0 to 8.0 and run the model again. Because tip displacement scales with \(L^3\), the magnitude should increase by a factor of eight while the model remains linear.

For the architecture behind each stage, return to Building Blocks, Assembly, Loading, Analysis, and Process.