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:
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,
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.