05 — Labels and Physical Groups¶
Curriculum slot: Tier 2, slot 05.
Prerequisite: 02 — 2D Cantilever Beam.
Strategy used for results: spec.capture(...) (small).
Purpose¶
apeGmsh gives you two naming namespaces for geometry, and the
same two names flow all the way through to Results:
| Namespace | Call | Survives | Selector on Results |
|---|---|---|---|
| Label (Tier 1) | g.labels.add(dim, tags, name) |
most geometry edits — re-identified after fragment / cut / fuse | results.nodes.get(label="...") |
| Physical group (Tier 2) | g.physical.add(dim, tags, name=...) |
its member entity tags; the canonical OpenSees-visible name | results.nodes.get(pg="...") |
When to use which:
- Labels when the identity has to survive boolean operations.
Example:
"top_flange"as the upper surface of a beam that you'll later fragment against other parts. - Physical groups when the identity is stable and you want a named export target (recorders read PG names).
What this notebook teaches¶
- The two-namespace tagging system on the geometry.
- The three accessor dialects on
FEMData:target=(auto),label=,pg=. - The same-name collision behaviour — labels win on
target=. - Both names work as selectors on the read side too —
results.nodes.get(label="tip", ...)andresults.nodes.get(pg="base_pg", ...)resolve identically to what they did at recorder-declaration time.
1. Imports and parameters¶
from pathlib import Path
import numpy as np
import openseespy.opensees as ops
from apeGmsh import apeGmsh, Results
from apeGmsh.results.spec import Recorders
# Tiny cantilever, same physics as slot 02.
L = 3.0
P = 10_000.0
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 / 6.0
2. Geometry¶
Three points (base → mid → tip) joined by two segments. Three points, two namespaces — perfect for showing both.
g = apeGmsh(model_name="05_labels_and_pgs", verbose=False)
g.begin()
p_base = g.model.geometry.add_point(0.0, 0.0, 0.0, lc=LC)
p_mid = g.model.geometry.add_point(L/2, 0.0, 0.0, lc=LC)
p_tip = g.model.geometry.add_point(L, 0.0, 0.0, lc=LC)
ln_L = g.model.geometry.add_line(p_base, p_mid)
ln_R = g.model.geometry.add_line(p_mid, p_tip)
g.model.sync()
3. Tag entities both ways¶
p_tip→ label"tip"(Tier 1)p_mid→ physical group"mid_pg"(Tier 2)p_base→ both — label"base_lbl"and PG"base_pg"[ln_L, ln_R]→ PG"beam"(needed to assign elements)
Tagging the same node in both namespaces lets us demonstrate precedence in section 5.
# Labels (Tier 1)
g.labels.add(0, [p_tip], "tip")
g.labels.add(0, [p_base], "base_lbl")
# Physical groups (Tier 2)
g.physical.add(0, [p_mid], name="mid_pg")
g.physical.add(0, [p_base], name="base_pg")
g.physical.add(1, [ln_L, ln_R], name="beam")
print("--- labels ---")
print(g.labels.summary())
print("\n--- physical groups ---")
print(g.physical.summary())
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")
5. The three accessor dialects on FEMData¶
| Call | Behaviour |
|---|---|
fem.nodes.get(target="foo") |
auto-resolve — label → PG → part |
fem.nodes.get(label="foo") |
force the label namespace |
fem.nodes.get(pg="foo") |
force the PG namespace |
Precedence. When the same name "foo" exists in both, target=
returns the label. Use label= / pg= explicitly when you need
to force one namespace.
print("--- 'tip' (label only) ---")
print(f" target='tip' -> {sorted(int(n) for n in fem.nodes.get(target='tip').ids)}")
print(f" label='tip' -> {sorted(int(n) for n in fem.nodes.get(label='tip').ids)}")
print("\n--- 'mid_pg' (PG only) ---")
print(f" target='mid_pg' -> {sorted(int(n) for n in fem.nodes.get(target='mid_pg').ids)}")
print(f" pg='mid_pg' -> {sorted(int(n) for n in fem.nodes.get(pg='mid_pg').ids)}")
print("\n--- base: has BOTH label 'base_lbl' AND PG 'base_pg' ---")
print(f" label='base_lbl' -> {sorted(int(n) for n in fem.nodes.get(label='base_lbl').ids)}")
print(f" pg='base_pg' -> {sorted(int(n) for n in fem.nodes.get(pg='base_pg').ids)}")
5b. Same-name collision — who wins?¶
Labels and PGs live in separate Gmsh namespaces (labels carry a
hidden _label: prefix), so registering the same name in both is
not an error — both coexist. The tie-break only matters at
resolution time:
target="clash"→ label wins (Tier 1 tried first)label="clash"→ always the labelpg="clash"→ always the PG
Below we register "clash" as a label on p_mid and as a PG on
p_tip — two different entities — and watch resolution pick them
apart.
g.labels.add(0, [p_mid], "clash") # label -> p_mid
g.physical.add(0, [p_tip], name="clash") # PG -> p_tip
fem = g.mesh.queries.get_fem_data() # refresh broker
print(f"target='clash' -> {sorted(int(n) for n in fem.nodes.get(target='clash').ids)} (label wins)")
print(f"label='clash' -> {sorted(int(n) for n in fem.nodes.get(label='clash').ids)}")
print(f"pg='clash' -> {sorted(int(n) for n in fem.nodes.get(pg='clash').ids)}")
6. Build the OpenSees model — vanilla openseespy¶
Standard cantilever — fix the base via label=, load the tip via
label=. The mixed-namespace cantilever is what the rest of the
notebook reads results from.
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, 0.0, 1.0, 0.0)
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, 1,
)
# fix the base — accessed by LABEL
for n in fem.nodes.get(label="base_lbl").ids:
ops.fix(int(n), 1, 1, 1, 1, 1, 1)
# tip load — also via label
ops.timeSeries("Constant", 1)
ops.pattern("Plain", 1, 1)
for n in fem.nodes.get(label="tip").ids:
ops.load(int(n), 0.0, 0.0, -P, 0.0, 0.0, 0.0)
7. Declare recorders — one per namespace¶
The teaching moment: you can declare recorders by either namespace and the resulting slabs come back addressable by the same name on the read side. We pick:
pg="base_pg"— reactions at the base, declared by PG.label="tip"— displacement at the tip, declared by label.
Both flow through Results identically — the bind contract makes
the namespaces transparent on the read side.
recorders = Recorders()
recorders.nodes(
components=["reaction_force"], pg="base_pg", name="R_base",
)
recorders.nodes(
components=["displacement"], label="tip", name="u_tip",
)
spec = recorders.resolve(fem, ndm=3, ndf=6)
print(spec)
8. Run the analysis with spec.capture(...)¶
from apeGmsh import workdir
OUT = workdir()
results_path = OUT / "capture.h5"
if results_path.exists():
results_path.unlink()
with spec.capture(results_path, fem=fem, ndm=3, ndf=6) as cap:
cap.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)
cap.step(t=ops.getTime())
cap.end_stage()
print(f"wrote {results_path} ({results_path.stat().st_size / 1024:.1f} KB)")
9. Read results back — both namespaces work as selectors¶
Cross-check: pull the tip displacement two different ways and confirm they're identical.
results = Results.from_native(results_path, fem=fem)
print(results.inspect.summary())
# Tip displacement via LABEL
u_via_label = float(results.nodes.get(
component="displacement_z", label="tip", time=-1,
).values[0, 0])
# Same query via target= (auto-resolution, label wins)
tip_id = int(next(iter(fem.nodes.get(target="tip").ids)))
u_via_id = float(results.nodes.get(
component="displacement_z", ids=[tip_id], time=-1,
).values[0, 0])
analytical = -P * L**3 / (3.0 * E * Iz)
print(f"u_z via label='tip' : {u_via_label:.6e} m")
print(f"u_z via ids=[...] : {u_via_id:.6e} m")
print(f"analytical : {analytical:.6e} m")
print()
print(f"both selectors give identical u_z? {abs(u_via_label - u_via_id) < 1e-15}")
# Base reaction via PG
R_z = float(results.nodes.get(
component="reaction_force_z", pg="base_pg", time=-1,
).values.sum())
print(f"Σ R_z at base (via pg='base_pg') : {R_z:+.6e} N (equilibrium: +P = {P:+.6e})")
10. (Optional) Open the post-solve viewer¶
results.viewer(blocking=False)
What this unlocks¶
- One vocabulary for the entire pipeline. Whatever name you pick
(label or PG) at geometry time stays usable on
FEMData, recorders, capture, andResults— same string in every layer. - Tier 1 (label) vs Tier 2 (PG) is a survivability choice, not a capability choice. Both work as selectors throughout. Pick labels for things that need to survive boolean ops; PGs for the canonical solver-facing names.
- Three accessor dialects (
target=,label=,pg=) on both the broker andResults. Use the most specific form in production —target=is the convenience butlabel=/pg=fail loudly if a name doesn't exist where you expect.
From here the curriculum returns to bigger models — notebook 10b shows part assembly (where labels really earn their keep).
g.end()