Skip to content

The OpenSees Bridge

apeGmsh ships with a first-class bridge to OpenSees. The bridge takes a FEMData snapshot from an apeGmsh session, combines it with material definitions, element assignments, boundary conditions, and explicitly re-declared loads/masses, and produces ready-to-run Tcl or openseespy scripts. You never touch raw node numbering or element connectivity -- the bridge resolves physical groups against the snapshot.

The legacy in-session g.opensees.* composite (and the apeGmsh.solvers package) was removed in the Phase-8 teardown (ADR 0009 -- no back-compat shim). The OpenSees surface is now a single class, apeSees, constructed after the session from a FEMData snapshot:

from apeGmsh.opensees import apeSees

fem = g.mesh.queries.get_fem_data(dim=3)
ops = apeSees(fem)
ops.model(ndm=3, ndf=3)

apeSees exposes typed namespaces and explicit declaration verbs:

Namespace / verb Purpose
ops.nDMaterial, ops.uniaxialMaterial, ops.section typed material / section primitives
ops.geomTransf, ops.beamIntegration beam local frame + integration
ops.element.<Type>(pg=…) element assignment by physical group
ops.fix, ops.mass homogeneous SP + lumped mass (explicit)
ops.timeSeries, ops.pattern load patterns + prescribed SP (explicit)
ops.recorder.<Type> recorder declarations
ops.tcl / .py / .h5 / .run / .analyze emit / run

Plus the lifecycle entry points: ops.model(ndm=, ndf=) (first) and ops.build() (usually implicit -- each emit builds internally).

The big behavioural change: no ingest. The old bridge had an ingest step that pulled session-declared g.loads / g.masses / g.constraints into the deck. apeSees has no ingest and no auto-resolution. It reads the fem snapshot only to resolve pg= / label= selectors to node/element tags and to get coordinates/connectivity. Loads, masses and SPs you want in OpenSees must be re-declared explicitly on ops (§4). Session declarations still flow into the model.h5 neutral zone for the viewer / Results -- they are just not in the runnable Tcl/Py/Live deck.

The overall pipeline is:

geometry --> mesh --> FEMData snapshot
    --> apeSees(fem) --> typed materials/elements
    --> explicit fix/mass/patterns --> emit (tcl/py/h5/run)

1. Model Dimensions -- ops.model

Tell the bridge how many spatial dimensions and DOFs per node your model has:

ops.model(ndm=3, ndf=3)

Both arguments are keyword-only. Typical combinations:

ndm ndf Use case
2 2 2-D solid (ux, uy)
2 3 2-D frame (ux, uy, rz)
3 3 3-D solid (ux, uy, uz)
3 6 3-D frame or shell (ux, uy, uz, rx, ry, rz)

ops.model(...) must be the first call -- materials, elements, fix, mass, and patterns all depend on it. ndf sets the required length of the tuples you pass to ops.fix(dofs=…), ops.mass(values=…), and p.load(forces=…). build() raises early if any assigned element type is incompatible with the declared ndm/ndf.

apeSees(fem, default_orientation=Cartesian()) sets a model-wide geomTransf orientation default (Z-up). Pass None for 2-D models; pass a custom Orientation for a Y-up CAD import.

2. Materials

Three typed namespaces mirror how OpenSees organises material-like objects. Every method has an explicit, fully-typed signature (no **kwargs) and returns a handle -- there are no string names and no registry. Capture the handle in a variable and pass it by reference to the element constructor; handles auto-register on the bridge.

2.1 nDMaterial -- continuum solids

Use ops.nDMaterial.<Type> for any element that takes an nDMaterial in OpenSees: tetrahedra, bricks, quads, and triangles.

conc = ops.nDMaterial.ElasticIsotropic(E=30e9, nu=0.2, rho=2400)

The returned conc handle is passed later as ops.element.FourNodeTetrahedron(..., material=conc). The method name is the OpenSees nDMaterial type -- ElasticIsotropic, J2Plasticity, DruckerPrager, etc.

2.2 uniaxialMaterial -- trusses and springs

Use ops.uniaxialMaterial.<Type> for trusses, corotational trusses, zeroLength springs, and fiber-section beams.

steel = ops.uniaxialMaterial.Steel02(fy=420e6, E=200e9, b=0.01)

Steel02-family uses fy= (the legacy Fy= is gone).

2.3 Section -- shells and fiber sections

Use ops.section.<Type> for shell elements and fiber sections for beams. The most common shell section is ElasticMembranePlateSection, combining membrane and bending behaviour.

slab = ops.section.ElasticMembranePlateSection(
    E=30e9, nu=0.2, h=0.2, rho=2400,
)

from apeGmsh.opensees.section.fiber import FiberPoint
sec = ops.section.Fiber(
    fibers=(FiberPoint(material=steel, y=0.0, z=0.0, area=0.01),),
)

Which namespace: ops.nDMaterial -> solid elements; ops.uniaxialMaterial -> truss / spring / fibre-section beams; ops.section -> shells and fiber sections for beams. The constructors are not chainable across namespaces -- write a separate statement for each handle.

3. Element Assignment

ops.element.<Type>(pg="PG", ...) writes every mesh element in that physical group as <Type>. PG resolution is "FEM-direct" against fem. The call returns a typed ElementGroup.

3.1 Assigning element types -- ops.element.<Type>

ops.element.<Type>(
    pg="PG",          # physical-group or label name
    *,
    material=,       # nDMaterial / uniaxialMaterial handle
    section=,        # section handle (shells / fiber beams)
    transf=,         # geomTransf handle (beams only)
    integration=,    # beamIntegration handle (force-based beams)
    body_force=,     # element body/gravity force (solids)
    pressure=,       # 2-D solids
    **scalars,        # element-specific scalar parameters
)

pg= resolves against both physical groups and apeGmsh labels (labels resolve automatically -- no promote_to_physical). Keep PG names dimension-unique; an ambiguous pg= that exists at multiple dimensions is an error.

Material / section / transf / integration are passed as handles (the variables the constructors returned), not string names:

  • Solid elements need an nDMaterial handle (ops.nDMaterial.*)
  • Truss elements need a uniaxialMaterial handle (ops.uniaxialMaterial.*)
  • Shell elements need a section handle (ops.section.*)
  • Beam elements take a transf handle plus either section properties as scalar kwargs (elasticBeamColumn) or a beamIntegration handle (forceBeamColumn)

Examples:

# 3-D solid tetrahedron -- gravity is an ELEMENT body_force param
ops.element.FourNodeTetrahedron(
    pg="Body", material=conc,
    body_force=(0.0, 0.0, -9.81 * 2400),
)

# 2-D plane-stress quad
ops.element.quad(
    pg="Plate", material=steel_2d,
    thick=0.01, eleType="PlaneStress",
)

# Truss with cross-section area
ops.element.corotTruss(pg="Diagonals", material=steel, A=3.14e-4)

# Elastic beam-column (3-D)
t = ops.geomTransf.PDelta(vecxz=(0, 0, 1))
ops.element.elasticBeamColumn(
    pg="Columns", transf=t,
    A=0.04, E=200e9, G=77e9, J=1e-4, Iy=2e-4, Iz=2e-4,
)

# Force-based beam-column with a fiber section
integ = ops.beamIntegration.Lobatto(section=sec, n_ip=5)
ops.element.forceBeamColumn(pg="Cols", transf=t, integration=integ)

# Shell (section-based)
ops.element.ShellMITC4(pg="SlabSurface", section=slab)

There is no eleLoad pattern verb -- distributed/body loads are element parameters (body_force=, pressure=), not loads.

3.2 Supported element types

Family Types
3-D solid FourNodeTetrahedron, TenNodeTetrahedron, stdBrick, bbarBrick, SSPbrick
2-D solid quad, tri31, SSPquad
Shell ShellMITC3, ShellMITC4, ShellDKGQ, ASDShellQ4
Truss truss, corotTruss
Beam elasticBeamColumn, forceBeamColumn, ElasticTimoshenkoBeam

If your Gmsh mesh uses second-order elements but the assigned OpenSees type only supports first-order, build() issues a UserWarning and discards mid-side nodes automatically.

3.3 Geometric transformations -- ops.geomTransf

Beam elements require a geometric transformation defining their local coordinate system. The constructor returns a handle you pass to ops.element.* as transf=:

t = ops.geomTransf.PDelta(vecxz=(0, 0, 1))   # or .Linear / .Corotational
ops.element.elasticBeamColumn(pg="Columns", transf=t, A=, E=, )
  • vecxz -- the local x-z plane vector (3-D only; omit for 2-D models)
  • the namespace method name is the transform type: .Linear, .PDelta, .Corotational

3.4 Boundary conditions -- ops.fix

Apply homogeneous single-point constraints to every node in a physical group:

ops.fix(pg="BasePlate", dofs=(1, 1, 1))
ops.fix(pg="PinnedBase", dofs=(1, 1, 1, 0, 0, 0))   # ndf=6
ops.fix(nodes=[101, 102], dofs=(1, 1, 1))           # explicit-node form

The dofs tuple must have exactly ndf entries (1 = fixed, 0 = free). Homogeneous SPs are model-level here; non-zero prescribed displacements go inside a pattern via p.sp (§4).

Solid faces: For solid meshes where you declared a face SP via g.loads.face_sp(...) on the session, that record resolves into fem.nodes.sp but is not ingested. Re-declare it explicitly: homogeneous -> ops.fix(pg=…, dofs=…); prescribed -> p.sp(...) inside a pattern. See guide_loads.md §11.

4. Loads, Masses, and SP -- Re-declared Explicitly

There is no ingest step. apeSees does not read the session's g.loads / g.masses / g.constraints, nor the resolved fem.nodes.loads / fem.nodes.masses / fem.nodes.sp records. To put loads / masses / SP in the runnable deck you re-declare them explicitly on ops. (The session declarations still resolve into FEMData and persist into the model.h5 neutral zone for the viewer / Results -- they are simply not pulled into the deck.)

4.1 Masses -- ops.mass

ops.mass(pg="Roof", values=(m, m, m, 0.0, 0.0, 0.0))

values is an ndf-length tuple. No pattern grouping, no ordering concerns -- one lumped-mass declaration per physical group.

4.2 Loads and prescribed SP -- patterns

Nodal loads and non-zero prescribed displacements are pattern-scoped:

ts = ops.timeSeries.Linear()              # also Constant/Path/Trig/Pulse
with ops.pattern.Plain(series=ts) as p:   # also UniformExcitation
    p.load(pg="Tip", forces=(0.0, 0.0, -5e4))
    p.sp(pg="LoadingPin", dof=3, value=0.01)   # prescribed displacement

p.load / p.sp fan a pg= across the group's nodes at build time; node= takes an explicit tag or a Node from ops.nodes.get(...). Each session load pattern you declared with g.loads.pattern(...) becomes a separate ops.pattern.* block here, each with its own timeSeries. The forces= tuple length must match the model ndf.

Distributed/body loads (gravity, surface pressure) are not patterns -- they are element parameters (body_force=, pressure=) on the ops.element.* call (§3.1).

4.3 Migration of the old ingest call

Old g.opensees.ingest.X(fem) New
.loads(fem) with ops.pattern.Plain(series=ts) as p: p.load(pg=…, forces=…)
.masses(fem) ops.mass(pg=…, values=…)
.sp(fem) (homogeneous face_sp) ops.fix(pg=…, dofs=…)
.sp(fem) (prescribed face_sp) p.sp(pg=…, dof=…, value=…)
gravity via g.loads.gravity(...) element body_force=(b1,b2,b3) param
.constraints(fem, tie_penalty=) deferred -- no path (§4.4)

4.4 Multi-point constraints are DEFERRED

⚠️ Multi-point constraints (tie / rigid_link / equal_dof / rigid_diaphragm / node_to_surface / tied_contact / mortar, and embedded rebar) are deferred in apeSees -- there is no OpenSees emission path. The Emitter protocol exposes only node / fix / mass / element / sp; there is no equalDOF, rigidLink, rigidDiaphragm, or ASDEmbeddedNodeElement. This is deferred by design (ADR 0009; src/apeGmsh/opensees/architecture/_DEFERRED.md).

Consequences:

  • Declare the constraints on the session as usual -- they resolve into FEMData and are persisted into the model.h5 neutral zone by ops.h5(path), so the viewer / Results render them.
  • They are not written to any runnable Tcl/Py/Live deck. A model whose load path depends on a tie / rigid link will be wrong if you run the emitted deck as-is.
  • To run such a model today, hand-emit the constraint commands by iterating fem.nodes.constraints / fem.elements.constraints into raw openseespy yourself, or wait for the deferred feature.

Homogeneous fixities (ops.fix) and prescribed displacements (p.sp) do have a path -- only the multi-point couplings are deferred.

5. Building the Model -- build()

ops.build()   # -> immutable BuiltModel

ops.build() resolves every typed declaration and pg= selector against the bound fem snapshot and returns an immutable BuiltModel that the emitters consume. You rarely call it directly -- ops.tcl / py / h5 / run each build internally.

build() raises early with a pointed error if the model is inconsistent: a missing geomTransf on a beam, an ndm/ndf mismatch, an ambiguous pg=, or a dofs mask whose length is not ndf.

6. Emit / Run

apeSees writes the built model to disk or runs it in process. These are separate statements -- not a fluent chain. Each tcl / py / h5 / run calls build() internally.

6.1 Tcl script -- ops.tcl(path)

ops.tcl("model.tcl")

Produces a complete OpenSees Tcl input file: model builder, nodes, materials/sections/transforms, element connectivity with physical-group comments, fix commands, nodal masses, and load patterns. Multi-point constraints are not emitted (deferred -- §4.4).

6.2 openseespy script -- ops.py(path)

ops.py("model.py", run=False)   # run=True subprocesses the script

Produces an equivalent Python script using openseespy.opensees as ops. The structure mirrors the Tcl output.

ops.tcl(...) and ops.py(...) are independent statements -- write them on separate lines, not as a chain:

ops.tcl("model.tcl")
ops.py("model.py")

6.3 Native HDF5 + live run

ops.h5("model.h5")              # bridge /opensees/ zone + broker neutral zone
ops.run()                       # in-process openseespy (LiveOpsEmitter)
ops.analyze(steps=10, dt=0.01)  # drive the analysis chain

ops.h5(path) writes the canonical model.h5: the bridge's emitted /opensees/ zone plus the broker neutral zone (which carries the session's loads/masses/constraints for the viewer / Results).

6.4 Recorders

rec = ops.recorder.Node(...)    # ops.recorder.<Type>

Recorder declarations live on ops.recorder.<Type>. They are emitted alongside the model in tcl / py / h5. See guide_recorders_reference.md.

7. Inspection

Inspection is broker-side or post-emit -- there is no inspect sub-composite on the bridge.

7.1 Summary

fem.inspect.summary()

Returns a multi-line string from the broker: registered record sets, node/element counts by type and physical group. The quickest sanity check before emitting.

7.2 Broker tables

fem.inspect.node_table()
fem.inspect.element_table()

DataFrames of the broker's nodes and elements -- coordinates, connectivity, physical-group membership. Useful for verifying placement before emitting.

7.3 Post-emit reader

from apeGmsh.opensees.emitter.h5_reader import open as open_h5

open_h5("model.h5")

The reference reader inspects an emitted model.h5 -- the ground-truth check of what actually went into the deck.

8. Complete Example

End-to-end: create a concrete block, mesh, take the snapshot, declare materials/BCs/loads explicitly on the bridge, and emit.

from apeGmsh import apeGmsh
from apeGmsh.opensees import apeSees

# ── Session ───────────────────────────────────────────────
with apeGmsh("concrete_block") as g:

    # ── Geometry ──────────────────────────────────────────
    # A simple 2m x 1m x 1m block
    g.model.geometry.add_box(0, 0, 0,  2.0, 1.0, 1.0, label="Block")

    # Label the base surface for boundary conditions
    g.model.selection.select_surfaces(on_plane=("z", 0.0)).label("Base")

    # Label the top surface for loading
    g.model.selection.select_surfaces(on_plane=("z", 1.0)).label("Top")

    g.model.geometry.synchronize()

    # Promote the labels the solver needs into physical groups
    g.physical.from_label("Block", name="Block")
    g.physical.from_label("Base",  name="Base")
    g.physical.from_label("Top",   name="Top")

    # ── Mesh ──────────────────────────────────────────────
    g.mesh.generation.set_size_global(0.15)
    g.mesh.generation.generate(3)

    # ── FEMData snapshot ──────────────────────────────────
    fem = g.mesh.queries.get_fem_data(dim=3)

# ── OpenSees -- post-session, explicit declarations ──────
ops = apeSees(fem)
ops.model(ndm=3, ndf=3)

# ── Material ──────────────────────────────────────────────
conc = ops.nDMaterial.ElasticIsotropic(E=30e9, nu=0.2, rho=2400)

# ── Element assignment (gravity is an element body_force) ─
ops.element.FourNodeTetrahedron(
    pg="Block", material=conc,
    body_force=(0.0, 0.0, -9.81 * 2400),
)

# ── Boundary conditions ──────────────────────────────────
ops.fix(pg="Base", dofs=(1, 1, 1))

# ── Loads -- RE-DECLARED explicitly (no ingest) ──────────
with ops.pattern.Plain(series=ops.timeSeries.Linear()) as p:
    p.load(pg="Top", forces=(0.0, 0.0, -50e3))

# ── Sanity check + emit ──────────────────────────────────
fem.inspect.summary()
ops.tcl("block_model.tcl")
ops.py("block_model.py")

The emitted files are self-contained model definitions. Run them with opensees block_model.tcl or python block_model.py after appending your analysis commands (the bridge intentionally omits analysis setup).

9. Practical Advice

Declaration order

ops.model(...) must come first. After that, materials, elements, fix, mass, and patterns can be declared in the order that reads best; they are resolved at build() (implicit in emit). Capture every material/section/transf/integration handle in a variable before the element call that consumes it.

Physical groups vs. labels

pg= resolves either a Gmsh physical-group name or an apeGmsh label -- labels resolve automatically (FEM-direct), so promote_to_physical is not required for the OpenSees workflow. Keep PG names dimension-unique so pg= is never ambiguous.

Beam elements need geomTransf

Beam elements do not use a material registry. Their section properties are scalar kwargs (A=0.04, E=200e9) for elasticBeamColumn, or a beamIntegration handle for forceBeamColumn, but they always require a geomTransf handle. Forgetting it is the most common beam-model error -- build() catches it.

Shell elements need ndf=6

Shell elements require six DOFs per node. If your model mixes shells with solids, set ndf=6 for the entire model.

The deck stops at model definition

The emitted scripts omit analysis commands (integrator, algorithm, system). Append your analysis block or source/import the generated file from a driver script. Recorders, if you declared them on ops.recorder.*, are emitted.

Tie / non-conformal interfaces are DEFERRED

You can declare ties for non-matching meshes on the session:

g.constraints.tie("ColumnTop", "SlabBottom", dofs=[1, 2, 3])

The tie resolves into FEMData and persists into model.h5 for the viewer / Results, but apeSees has no OpenSees emission path for it (deferred -- §4.4). A model whose load path depends on a tie will be wrong if you run the emitted deck as-is. To run such a model today, hand-emit the constraint commands from fem.nodes.constraints / fem.elements.constraints into raw openseespy yourself, or wait for the deferred feature.

Loads/masses/SP are not ingested

The single biggest behavioural change from the old bridge: there is no ingest step and no auto-resolution. apeSees does not read g.loads / g.masses / g.constraints or the resolved fem.nodes.* records. Re-declare loads/masses/SP explicitly on ops (§4). The session declarations are still preserved for the viewer / Results via the model.h5 neutral zone -- they just do not enter the runnable deck automatically.

Emit calls are separate statements

ops.tcl(...), ops.py(...), ops.h5(...), ops.run() are not fluent. Write each on its own line; each builds internally.