10b — Part Assembly with Transforms¶
Curriculum slot: Tier 3, slot 10.5.
Prerequisite: 10 — Parts Basics.
Strategy used for results: spec.emit_recorders(...) — live classic recorders.
Purpose¶
The most useful application of the Parts composite — instancing
a template geometry multiple times with transforms — and showing
that the part labels survive all the way through to Results
as first-class selectors:
col = Part(name="column").begin()
# ... build the column geometry inside its own session ...
col.end()
with apeGmsh(model_name="assembly") as g:
g.parts.add(col, label="col_0", translate=(0.0, 0, 0))
g.parts.add(col, label="col_1", translate=(4.0, 0, 0))
g.parts.add(col, label="col_2", translate=(8.0, 0, 0))
# ... run analysis ...
# Read each column's tip independently — by part label.
u_0 = results.nodes.get(component="displacement_x", label="col_0")
Under the hood, Part.end() auto-persists the part's geometry to a
tempfile STEP, and each g.parts.add(...) re-imports it with the
transform applied. Same mechanism g.parts.import_step(file_path, translate=...) uses for CAD library parts.
Problem¶
A 3 m vertical cantilever template, replicated at $x = 0$, $x = 4$, $x = 8$. Each column gets its own fixed base and tip horizontal load $P$. Each column's tip deflection must match $\delta_{\text{tip}} = P L^3 / (3 E I)$ — pure translation doesn't alter stiffness.
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.core.Part import Part
from apeGmsh.results.spec import Recorders
# Geometry / instancing
L = 3.0 # column height
N_COLS = 3 # how many instances
DX = 4.0 # spacing in +x
COLUMN_X_OFFSETS = [i * DX for i in range(N_COLS)]
# Load
P = 10_000.0 # tip horizontal load in +x
# Material / cross-section
E = 2.1e11
nu = 0.3
G = E / (2.0 * (1.0 + nu))
A, Iy, Iz, J = 1.0e-3, 1.0e-5, 1.0e-5, 2.0e-5
LC = L / 10.0
2. Build the template Part in isolation¶
Part(name=...).begin() opens an isolated Gmsh session. Build
exactly the geometry the part needs (line + endpoints) and call
end() — that auto-persists the part to a tempfile.
col_template = Part(name="cantilever_column").begin(verbose=False)
col_template.model.geometry.add_point(0.0, 0.0, 0.0, lc=LC)
col_template.model.geometry.add_point(0.0, 0.0, L, lc=LC)
col_template.model.geometry.add_line(1, 2) # OCC tags 1 + 2
col_template.model.sync()
col_template.end()
3. Assemble three instances¶
Each g.parts.add(part, label=..., translate=...) re-imports the
template's STEP at the given offset and registers it under the
given label. The label becomes the addressable name for the
rest of the pipeline — selectors, recorders, Results.
g_ctx = apeGmsh(model_name="10b_part_assembly", verbose=False)
g = g_ctx.__enter__()
for i, x_off in enumerate(COLUMN_X_OFFSETS):
g.parts.add(
col_template,
label=f"col_{i}",
translate=(x_off, 0.0, 0.0),
)
print(f"parts registered: {g.parts.labels()}")
4. Mesh¶
g.mesh.generation.generate(1)
fem = g.mesh.queries.get_fem_data()
print(f"mesh: {fem.info.n_nodes} nodes, {fem.info.n_elems} elements")
for lbl in sorted(g.parts.labels()):
n = len(list(fem.nodes.get(target=lbl).ids))
e = sum(len(gr.ids) for gr in fem.elements.get(target=lbl))
print(f" {lbl}: {n} nodes, {e} elements")
5. Per-column base + tip identification¶
Each column's base sits at $z = 0$ and tip at $z = L$. Classify the label's mesh nodes by coordinate so we can fix bases / load tips.
tag_to_idx = {int(t): i for i, t in enumerate(fem.nodes.ids)}
def base_and_tip(label: str) -> tuple[int, int]:
base = tip = None
for nid in fem.nodes.get(target=label).ids:
z = float(fem.nodes.coords[tag_to_idx[int(nid)], 2])
if abs(z - 0.0) < 1e-9:
base = int(nid)
elif abs(z - L) < 1e-9:
tip = int(nid)
assert base is not None and tip is not None
return base, tip
column_nodes = {lbl: base_and_tip(lbl) for lbl in sorted(g.parts.labels())}
for lbl, (b, t) in column_nodes.items():
print(f" {lbl}: base={b}, tip={t}")
6. Build the OpenSees model — vanilla openseespy¶
Element emission iterates per part label via
fem.elements.get(target=lbl). The label resolves through the
standard chain (label → PG → part) — same idiom as everywhere else.
ops.wipe()
ops.model("basic", "-ndm", 3, "-ndf", 6)
for nid, xyz in fem.nodes.get():
ops.node(int(nid), float(xyz[0]), float(xyz[1]), float(xyz[2]))
ops.geomTransf("Linear", 1, 1.0, 0.0, 0.0) # vecxz = +x; columns along +z
for lbl in sorted(g.parts.labels()):
for group in fem.elements.get(target=lbl):
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, 1,
)
# Fix each base; apply +x load at each tip
for lbl, (base, tip) in column_nodes.items():
ops.fix(int(base), 1, 1, 1, 1, 1, 1)
ops.timeSeries("Constant", 1)
ops.pattern("Plain", 1, 1)
for lbl, (base, tip) in column_nodes.items():
ops.load(int(tip), P, 0.0, 0.0, 0.0, 0.0, 0.0)
7. Declare recorders — one per part label¶
The teaching moment for this notebook: declare recorders by part label, one per column. Each column's slab on the read side comes back independently addressable by its label.
We could equivalently declare a single global displacement record;
splitting them shows that part labels work as a first-class selector
all the way through.
recorders = Recorders()
for lbl in sorted(g.parts.labels()):
recorders.nodes(
components=["displacement"], label=lbl, name=f"u_{lbl}",
)
spec = recorders.resolve(fem, ndm=3, ndf=6)
print(spec)
8. Run the analysis with spec.emit_recorders(...)¶
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")
ops.analyze(1)
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)")
9. Read each column's tip displacement — by label¶
Pull each column's slab independently. The tip is the node at
$z = L$ within each part — combine label= (additive selector) with
post-hoc filtering on the FEM coordinates.
results = Results.from_recorders(
spec, out_dir, fem=fem, stage_id="static",
)
print(results.inspect.summary())
analytical = P * L**3 / (3.0 * E * Iy)
print(f"{'part':>6s} {'tip dx':>14s} {'error %':>10s}")
worst = 0.0
for lbl in sorted(g.parts.labels()):
_, tip_id = column_nodes[lbl]
# First read by label= to see the per-part slab; then narrow by ids=
# (selectors are mutex with ids on the read side, so two calls).
per_part = results.nodes.get(component="displacement_x", label=lbl, time=-1)
# tip_id is one of the IDs in per_part.node_ids — locate and pick it
j = int(np.where(per_part.node_ids == tip_id)[0][0])
d_tip = float(per_part.values[0, j])
err = abs(d_tip - analytical) / abs(analytical) * 100.0
worst = max(worst, err)
print(f"{lbl:>6s} {d_tip:>14.6e} {err:>9.4f} %")
print(f"\nanalytical reference : {analytical:.6e} m")
print(f"worst-case error : {worst:.4f} %")
10. Plot the assembly's deformed shape¶
Pull displacement_x for each part separately — the label=
selector keeps the slabs cleanly partitioned per column.
scale = 30.0
fig, ax = plt.subplots(figsize=(8, 5))
for i, lbl in enumerate(sorted(g.parts.labels())):
ux = results.nodes.get(
component="displacement_x", label=lbl, time=-1,
)
# match each row's node ID to its (x, z) coordinate
z_per_node = []
x_per_node = []
for n in ux.node_ids:
idx = tag_to_idx[int(n)]
x_per_node.append(fem.nodes.coords[idx, 0])
z_per_node.append(fem.nodes.coords[idx, 2])
x_ref = np.array(x_per_node)
z_ref = np.array(z_per_node)
x_def = x_ref + scale * ux.values[0]
ax.plot(x_ref, z_ref, "o-", color="lightgray", lw=1, ms=4,
label="reference" if i == 0 else None)
ax.plot(x_def, z_ref, "o-", color=f"C{i}", lw=1.2, ms=5,
label=f"{lbl} (×{scale:g})")
ax.set_aspect("equal")
ax.set_xlabel("x [m]"); ax.set_ylabel("z [m]")
ax.set_title("Assembly — three instanced columns under tip load")
ax.legend()
fig.tight_layout()
11. (Optional) Open the post-solve viewer¶
results.viewer(blocking=False)
What this unlocks¶
- Template-plus-transform assembly via
g.parts.add(part, translate=..., rotate=...). Same mechanism handles CAD-imported parts viag.parts.import_step(file_path, translate=..., rotate=...). - Part labels as first-class selectors all the way through the
pipeline —
fem.nodes.get(target=lbl), recorder declarations, andresults.nodes.get(label=lbl)all resolve identically. - Per-instance results. Declaring one recorder per part label produces independently readable slabs — natural pattern for comparing nominally identical members in an assembly.
Next: notebook 12 — interface ties (non-matching meshes joined via the apeGmsh tie constraint).
g_ctx.__exit__(None, None, None)