02 — 2D Cantilever Beam¶
Curriculum slot: Tier 1, slot 02.
Prerequisite: 01 — Hello Plate.
Strategy used for results: spec.emit_recorders(...) — live classic OpenSees recorders.
Problem¶
A prismatic cantilever of length $L$ is fully fixed at $x = 0$ and loaded at its free tip by a vertical point force $P$ (downward).
P↓
(base)●──────────────────────●(tip) +z
◀───── L = 3 m ─────▶ │
└── +x
Linear-elastic Euler-Bernoulli theory gives the closed-form tip deflection
$$ \delta_{\text{tip}} \;=\; -\,\dfrac{P\,L^{3}}{3\,E\,I} $$
and the deflected shape
$$ w(x) \;=\; -\,\dfrac{P\,x^{2}}{6\,E\,I}\,(3L - x). $$
Why this notebook is interesting¶
- It demonstrates the other results-acquisition strategy from 01:
spec.emit_recorders(...)— apeGmsh pushes classicops.recorder("Node", ...)calls into the liveopsdomain, the output.outfiles land on disk, andResults.from_recorders(...)transcodes them. - It shows the
begin_stage/end_stagelifecycle that scopes recorder output to a named stage — same shape as the multi-stage runs we'll see in notebook 19. - Visualisation: the deflected shape vs the closed-form
Euler-Bernoulli cubic, all driven from
Results.nodes.get(...).
1. Imports and parameters¶
from pathlib import Path
import matplotlib.pyplot as plt
import numpy as np
import openseespy.opensees as ops
from apeGmsh import apeGmsh, Results
from apeGmsh.results.spec import Recorders
# Geometry / load (consistent units: m, Pa, N)
L = 3.0
P = 10_000.0 # tip force magnitude (applied as -P in +z)
# Elastic material / cross-section (3D beam-column needs all of these)
E = 2.1e11
nu = 0.3
A = 1.0e-3
Iy = 1.0e-5
Iz = 1.0e-5 # bending about y-axis -> vertical deflection in +z
J = 2.0e-5
G = E / (2.0 * (1.0 + nu))
# Mesh density
LC = L / 10.0
2. Geometry¶
A single line — base point at the origin, tip point at $x = L$.
g = apeGmsh(model_name="02_cantilever_beam_2D", verbose=False)
g.begin()
p_base = g.model.geometry.add_point(0.0, 0.0, 0.0, lc=LC)
p_tip = g.model.geometry.add_point(L, 0.0, 0.0, lc=LC)
line = g.model.geometry.add_line(p_base, p_tip)
g.model.sync()
3. Physical groups¶
Three names — base for the fixed end, tip for the loaded end,
beam for the line that becomes the elements.
g.physical.add(0, [p_base], name="base")
g.physical.add(0, [p_tip], name="tip")
g.physical.add(1, [line], name="beam")
4. Mesh¶
g.mesh.generation.generate(1)
fem = g.mesh.queries.get_fem_data()
print(f"mesh built: {fem.info.n_nodes} nodes, {fem.info.n_elems} elements")
5. Build the OpenSees model — vanilla openseespy¶
The 'beam' is elasticBeamColumn in 3D (-ndm 3 -ndf 6) with a
linear geometric transformation. Base fully fixed; tip carries
$-P$ in $+z$.
ops.wipe()
ops.model("basic", "-ndm", 3, "-ndf", 6)
# nodes
for nid, xyz in fem.nodes.get():
ops.node(int(nid), float(xyz[0]), float(xyz[1]), float(xyz[2]))
# geometric transformation (local-y aligned with global +y)
TRANSF_TAG = 1
ops.geomTransf("Linear", TRANSF_TAG, 0.0, 1.0, 0.0)
# elastic-beam-column elements
for group in fem.elements.get(target="beam"):
for eid, conn in zip(group.ids, group.connectivity):
ops.element(
"elasticBeamColumn", int(eid),
int(conn[0]), int(conn[1]),
A, E, G, J, Iy, Iz, TRANSF_TAG,
)
# fully fix the base point
for n in fem.nodes.get(target="base").ids:
ops.fix(int(n), 1, 1, 1, 1, 1, 1)
# downward point load at the tip
ops.timeSeries("Constant", 1)
ops.pattern("Plain", 1, 1)
for n in fem.nodes.get(target="tip").ids:
ops.load(int(n), 0.0, 0.0, -P, 0.0, 0.0, 0.0)
6. Declare recorders¶
Two named records:
displacementon all nodes — full deflection field for the plot.reactiononpg="base"— both forces and moments (the"reaction"shorthand expands to all six components inndf=6).
Modal records intentionally omitted — emit_recorders raises on those.
Use spec.capture(...) if you need eigenmodes (notebook 17 demonstrates).
recorders = Recorders()
recorders.nodes(
components=["displacement"], name="u_all",
)
recorders.nodes(
components=["reaction"], pg="base", name="R_base",
)
spec = recorders.resolve(fem, ndm=3, ndf=6)
print(spec)
7. Run the analysis with spec.emit_recorders(...)¶
Strategy contrast vs notebook 01:
01 (spec.capture) |
02 (spec.emit_recorders) |
|
|---|---|---|
| Who writes the file | apeGmsh native HDF5 | OpenSees recorder .out files |
| Per-step hook | cap.step(t=...) after each analyze |
none — recorders fire automatically |
| Output layout | one .h5 |
one .out per record per stage, prefixed <stage>__ |
| Read back | Results.from_native(...) |
Results.from_recorders(..., stage_id=...) |
Both strategies use the same begin_stage / end_stage lifecycle
and yield the same Results API on read — that's the spec-as-seam
design at work.
from apeGmsh import workdir
OUT = workdir()
out_dir = OUT / "recorders"
if out_dir.exists():
for f in out_dir.glob("*.out"):
f.unlink()
with spec.emit_recorders(out_dir) as live:
live.begin_stage("static", kind="static")
ops.system("BandGeneral")
ops.numberer("Plain")
ops.constraints("Plain")
ops.test("NormUnbalance", 1e-10, 10)
ops.algorithm("Linear")
ops.integrator("LoadControl", 1.0)
ops.analysis("Static")
status = ops.analyze(1)
assert status == 0, f"ops.analyze returned {status}"
live.end_stage()
print(f"recorder output → {out_dir}/")
for f in sorted(out_dir.glob("*.out")):
print(f" {f.name} ({f.stat().st_size} B)")
8. Read results back — Results.from_recorders(stage_id=...)¶
Pass the same spec and the output directory; stage_id="static"
matches the prefix emit_recorders wrote to disk. apeGmsh transcodes
the .out files into a native HDF5 (cached on disk by mtime + spec
hash) and the resulting Results object exposes the same composite
API as in notebook 01.
results = Results.from_recorders(
spec, out_dir, fem=fem, stage_id="static",
)
print(results.inspect.summary())
# Tip vertical deflection (verification target)
tip_id = int(next(iter(fem.nodes.get(target="tip").ids)))
u_tip = results.nodes.get(
component="displacement_z", ids=[tip_id], time=-1,
)
delta_fem = float(u_tip.values[0, 0])
delta_closed = -P * L**3 / (3.0 * E * Iz)
print(f"tip deflection (FEM) : {delta_fem:+.6e} m")
print(f"−P L^3 / (3 E I) : {delta_closed:+.6e} m (closed form)")
print(f"relative error : {abs(delta_fem - delta_closed) / abs(delta_closed):.2e}")
# Base reaction — equilibrium check
R_z = results.nodes.get(
component="reaction_force_z", pg="base", time=-1,
).values.sum()
M_y = results.nodes.get(
component="reaction_moment_y", pg="base", time=-1,
).values.sum()
print(f"Σ R_z at base : {R_z:+.6e} N (equilibrium: +P = {P:+.6e})")
print(f"Σ M_y at base : {M_y:+.6e} N·m (closed form: −P L = {-P * L:+.6e})")
9. Plot the deflected shape vs Euler-Bernoulli¶
Pull displacement_z at every node, pair each value with the node's
$x$-coordinate via the bound FEM, and overlay the closed-form curve.
Linear elastic, prismatic section, point load at the tip — the FEM cubic should track the analytical cubic to round-off.
u_all = results.nodes.get(component="displacement_z", time=-1)
x_lookup = dict(zip(
np.asarray(fem.nodes.ids, dtype=np.int64).tolist(),
np.asarray(fem.nodes.coords)[:, 0].tolist(),
))
x_arr = np.array([x_lookup[int(n)] for n in u_all.node_ids])
u_arr = u_all.values[0]
order = np.argsort(x_arr)
x_fine = np.linspace(0, L, 200)
w_closed = -P * x_fine**2 / (6.0 * E * Iz) * (3.0 * L - x_fine)
fig, ax = plt.subplots(figsize=(6, 4))
ax.plot(x_fine, w_closed, "-", color="k", lw=1, label="closed-form −Px²(3L−x)/(6EI)")
ax.plot(x_arr[order], u_arr[order], "o", ms=5, color="C0", label="FEM (emit_recorders)")
ax.set_xlabel("x [m]"); ax.set_ylabel("w(x) [m]")
ax.set_title("Cantilever deflected shape")
ax.legend(); ax.grid(True, alpha=0.3)
fig.tight_layout()
10. (Optional) Open the post-solve viewer¶
The viewer also opens a transcoded-from-recorders Results object —
the read API is identical regardless of which strategy produced the file.
results.viewer(blocking=False)
What this unlocks¶
- You've now seen both result-acquisition strategies side by side
—
spec.capture(notebook 01, native HDF5) vsspec.emit_recorders(this notebook, classic recorder.outfiles). - Same declaration vocabulary, same
begin_stage/end_stagelifecycle, sameResultsread API. - The trade-off is mostly format: capture is the apeGmsh-native path with broadest coverage (including modal); emit_recorders preserves classic OpenSees recorder semantics in cluster-friendly text files.
From here the curriculum turns to multi-element models (notebook 04 — portal frame), naming patterns (05 — labels and PGs), and beyond.
g.end()