Skip to content

OpenSees bridge

apeGmsh's OpenSees deck is constructed via the explicit-constructor pattern after the session closes:

from apeGmsh.opensees import apeSees

fem = g.mesh.queries.get_fem_data(dim=3)
ops = apeSees(fem)
ops.model(ndm=3, ndf=6)
# … typed-primitive declarations, explicit fix / mass / patterns …
ops.tcl("model.tcl")     # or ops.py(...), ops.h5(...), ops.run()

The legacy g.opensees session composite and its sub-composites (materials / elements / ingest / inspect / export) were removed in Phase 8 of the bridge teardown (ADR 0009). apeSees has no ingest and no auto-resolution — loads, masses, and SPs must be re-declared explicitly on ops; MP constraints are deferred (see the gold reference in skills/apegmsh/references/opensees-bridge.md).

Public surface

apeGmsh.opensees.apeSees

apeSees(fem: 'FEMData', *, default_orientation: Orientation | None | _UnsetType = _UNSET)

The OpenSees bridge.

Construct with a :class:~apeGmsh.mesh.FEMData snapshot:

.. code-block:: python

ops = apeSees(fem)
ops.model(ndm=3, ndf=6)
steel = ops.uniaxialMaterial.Steel02(fy=420e6, E=200e9, b=0.01)
...

The bridge holds declared state. apeSees.build() returns a :class:BuiltModel (immutable) that emitters consume.

Parameters

fem The FEM snapshot the bridge is built against. default_orientation Orientation field substituted on any ops.geomTransf.<Type>() call where the user supplied neither orientation= nor vecxz=. Defaults to Cartesian() (Z-up) which matches the prevailing structural convention. Pass an explicit None for 2D models, where vecxz is omitted at emit time and an orientation field makes no sense. Pass a custom orientation (e.g. Cartesian(reference_axis=(0,1,0)) for a Y-up CAD import) to set the model-wide default once.

Source code in src/apeGmsh/opensees/apesees.py
def __init__(
    self,
    fem: "FEMData",
    *,
    default_orientation: Orientation | None | _UnsetType = _UNSET,
) -> None:
    self._fem: "FEMData" = fem
    self._primitives: list[Primitive] = []
    self._tags = TagAllocator()
    self._ndm: int | None = None
    self._ndf: int | None = None
    self._fix_records: list[FixRecord] = []
    self._mass_records: list[MassRecord] = []
    # Resolve the sentinel: unset → Cartesian() (Z-up). Explicit
    # None disables the auto-default (2D models).
    if isinstance(default_orientation, _UnsetType):
        self._default_orientation: Orientation | None = Cartesian()
    else:
        self._default_orientation = default_orientation

    # Namespaces.
    self.uniaxialMaterial = _UniaxialMaterialNS(self)
    self.nDMaterial       = _NDMaterialNS(self)
    self.section          = _SectionNS(self)
    self.geomTransf       = _GeomTransfNS(self)
    self.beamIntegration  = _BeamIntegrationNS(self)
    self.timeSeries       = _TimeSeriesNS(self)
    self.pattern          = _PatternNS(self)
    self.element          = _ElementNS(self)
    self.recorder         = _RecorderNS(self)

    # FEM-aware aggregates (Phase 5A) — query-and-act over fem.nodes.
    self.nodes            = _NodeAccessor(self)
    self.constraints      = _ConstraintsNS(self)
    self.numberer         = _NumbererNS(self)
    self.system           = _SystemNS(self)
    self.test             = _TestNS(self)
    self.algorithm        = _AlgorithmNS(self)
    self.integrator       = _IntegratorNS(self)
    self.analysis         = _AnalysisNS(self)

model

model(*, ndm: int, ndf: int) -> None

Set the model dimensionality (ndm) and DOFs/node (ndf).

Source code in src/apeGmsh/opensees/apesees.py
def model(self, *, ndm: int, ndf: int) -> None:
    """Set the model dimensionality (``ndm``) and DOFs/node (``ndf``)."""
    self._ndm = ndm
    self._ndf = ndf

domain_capture

domain_capture(spec: 'DomainCaptureSpec', *, path: 'str | Path', ops: Any = None) -> 'DomainCapture'

Open a :class:DomainCapture for in-process recording.

Live entry point that resolves the supplied :class:DomainCaptureSpec against the bridge's fem snapshot using the bridge's ndm / ndf, then returns a :class:DomainCapture context manager writing to path.

Per Phase 9 D8 ndm / ndf are sourced implicitly from the bridge — the user must have called ops.model(ndm=, ndf=) first. Use :meth:DomainCapture.from_h5 instead when no live bridge is available (sources ndm / ndf from a model.h5 /meta block).

Example::

ops.model(ndm=3, ndf=6)
spec = DomainCaptureSpec(opensees=ops)
spec.nodes(pg="Top", components=["displacement"])
with ops.domain_capture(spec, path="run.h5") as cap:
    cap.begin_stage("gravity", kind="static")
    for _ in range(n):
        ops.analyze(1, 1.0)
        cap.step(t=ops.getTime())
    cap.end_stage()
Raises

RuntimeError If ops.model(ndm=, ndf=) has not been called yet.

Source code in src/apeGmsh/opensees/apesees.py
def domain_capture(
    self,
    spec: "DomainCaptureSpec",
    *,
    path: "str | Path",
    ops: Any = None,
) -> "DomainCapture":
    """Open a :class:`DomainCapture` for in-process recording.

    Live entry point that resolves the supplied
    :class:`DomainCaptureSpec` against the bridge's ``fem``
    snapshot using the bridge's ``ndm`` / ``ndf``, then returns a
    :class:`DomainCapture` context manager writing to ``path``.

    Per Phase 9 D8 ``ndm`` / ``ndf`` are sourced implicitly from
    the bridge — the user must have called ``ops.model(ndm=,
    ndf=)`` first. Use :meth:`DomainCapture.from_h5` instead when
    no live bridge is available (sources ``ndm`` / ``ndf`` from a
    ``model.h5`` ``/meta`` block).

    Example::

        ops.model(ndm=3, ndf=6)
        spec = DomainCaptureSpec(opensees=ops)
        spec.nodes(pg="Top", components=["displacement"])
        with ops.domain_capture(spec, path="run.h5") as cap:
            cap.begin_stage("gravity", kind="static")
            for _ in range(n):
                ops.analyze(1, 1.0)
                cap.step(t=ops.getTime())
            cap.end_stage()

    Raises
    ------
    RuntimeError
        If ``ops.model(ndm=, ndf=)`` has not been called yet.
    """
    if self._ndm is None or self._ndf is None:
        raise RuntimeError(
            "ops.domain_capture: ops.model(ndm=, ndf=) must be "
            "called before opening a DomainCapture (Phase 9 D8 "
            "binds ndm/ndf at resolve time)."
        )
    from ..results.capture._domain import DomainCapture
    resolved = spec._resolve_with_explicit_ndm_ndf(
        self._fem, ndm=self._ndm, ndf=self._ndf,
    )
    return DomainCapture(resolved, path, self._fem, ops=ops)

fix

fix(*, pg: str | None = None, nodes: Iterable[int | Node] | None = None, dofs: tuple[int, ...]) -> None

Apply homogeneous SP constraints (fix).

Exactly one of pg / nodes must be supplied. nodes accepts a mix of plain integer tags and :class:Node instances (from ops.nodes.get(...)); both are normalized to tags. The build pipeline expands pg to a per-node fan-out at emit time.

Source code in src/apeGmsh/opensees/apesees.py
def fix(
    self,
    *,
    pg: str | None = None,
    nodes: Iterable[int | Node] | None = None,
    dofs: tuple[int, ...],
) -> None:
    """Apply homogeneous SP constraints (``fix``).

    Exactly one of ``pg`` / ``nodes`` must be supplied. ``nodes``
    accepts a mix of plain integer tags and :class:`Node`
    instances (from ``ops.nodes.get(...)``); both are normalized
    to tags. The build pipeline expands ``pg`` to a per-node
    fan-out at emit time.
    """
    if (pg is None) == (nodes is None):
        raise ValueError(
            "apeSees.fix: supply exactly one of pg= or nodes= "
            f"(got pg={pg!r}, nodes={nodes!r})."
        )
    nodes_tuple = _iter_tags(nodes) if nodes is not None else None
    self._fix_records.append(
        FixRecord(pg=pg, nodes=nodes_tuple, dofs=tuple(dofs)),
    )

mass

mass(*, pg: str | None = None, nodes: Iterable[int | Node] | None = None, values: tuple[float, ...]) -> None

Attach lumped nodal mass.

Exactly one of pg / nodes must be supplied. nodes accepts plain integers or :class:Node instances.

Source code in src/apeGmsh/opensees/apesees.py
def mass(
    self,
    *,
    pg: str | None = None,
    nodes: Iterable[int | Node] | None = None,
    values: tuple[float, ...],
) -> None:
    """Attach lumped nodal mass.

    Exactly one of ``pg`` / ``nodes`` must be supplied. ``nodes``
    accepts plain integers or :class:`Node` instances.
    """
    if (pg is None) == (nodes is None):
        raise ValueError(
            "apeSees.mass: supply exactly one of pg= or nodes= "
            f"(got pg={pg!r}, nodes={nodes!r})."
        )
    nodes_tuple = _iter_tags(nodes) if nodes is not None else None
    self._mass_records.append(
        MassRecord(pg=pg, nodes=nodes_tuple, values=tuple(values)),
    )

analyze

analyze(*, steps: int, dt: float | None = None) -> int

Build + emit + run the analysis chain via the live emitter.

Builds a :class:BuiltModel, drives a :class:~apeGmsh.opensees.emitter.live.LiveOpsEmitter end-to- end, then issues the analyze call. Returns the openseespy analyze return value (0 on success).

Raises :class:BridgeError if the analysis chain is incomplete (one or more of constraints / numberer / system / test / algorithm / integrator / analysis is missing).

Source code in src/apeGmsh/opensees/apesees.py
def analyze(self, *, steps: int, dt: float | None = None) -> int:
    """Build + emit + run the analysis chain via the live emitter.

    Builds a :class:`BuiltModel`, drives a
    :class:`~apeGmsh.opensees.emitter.live.LiveOpsEmitter` end-to-
    end, then issues the ``analyze`` call. Returns the openseespy
    ``analyze`` return value (0 on success).

    Raises :class:`BridgeError` if the analysis chain is incomplete
    (one or more of constraints / numberer / system / test /
    algorithm / integrator / analysis is missing).
    """
    self._check_analysis_chain_for_analyze()

    # Local import — keeps openseespy out of import-time for users
    # who only emit Tcl / py.
    from .emitter.live import LiveOpsEmitter

    bm = self.build()
    live_emitter = LiveOpsEmitter(wipe=True)
    bm.emit(live_emitter)
    result: int = int(live_emitter.analyze(steps=steps, dt=dt))
    return result

tcl

tcl(path: str, *, run: bool = False, bin: str | None = None) -> None

Emit a Tcl deck to path; optionally subprocess OpenSees.

Source code in src/apeGmsh/opensees/apesees.py
def tcl(
    self,
    path: str,
    *,
    run: bool = False,
    bin: str | None = None,
) -> None:
    """Emit a Tcl deck to ``path``; optionally subprocess OpenSees."""
    from .emitter.tcl import TclEmitter

    bm = self.build()
    emitter = TclEmitter()
    bm.emit(emitter)
    with open(path, "w", encoding="utf-8") as f:
        f.write("\n".join(emitter.lines()) + "\n")

    if not run:
        return

    binary = _resolve_opensees_binary(bin)
    proc = subprocess.run(
        [binary, path],
        capture_output=True,
        text=True,
        check=False,
    )
    if proc.returncode != 0:
        raise RuntimeError(
            f"OpenSees subprocess returned {proc.returncode}.\n"
            f"stdout:\n{proc.stdout}\nstderr:\n{proc.stderr}"
        )

py

py(path: str, *, run: bool = False) -> None

Emit an openseespy Python deck to path; optionally run it.

Source code in src/apeGmsh/opensees/apesees.py
def py(self, path: str, *, run: bool = False) -> None:
    """Emit an openseespy Python deck to ``path``; optionally run it."""
    from .emitter.py import PyEmitter

    bm = self.build()
    emitter = PyEmitter()
    bm.emit(emitter)
    with open(path, "w", encoding="utf-8") as f:
        f.write("\n".join(emitter.lines()) + "\n")

    if not run:
        return

    python_bin = _resolve_python_binary()
    proc = subprocess.run(
        [python_bin, path],
        capture_output=True,
        text=True,
        check=False,
    )
    if proc.returncode != 0:
        raise RuntimeError(
            f"openseespy subprocess returned {proc.returncode}.\n"
            f"stdout:\n{proc.stdout}\nstderr:\n{proc.stderr}"
        )

run

run(*, wipe: bool = True) -> None

Drive an in-process LiveOpsEmitter through the full deck.

This emits every primitive but does NOT call analyze — that is the user's call (or :meth:analyze's). Useful when the user wants to declare a model, populate openseespy state, and then run their own analysis driver.

Source code in src/apeGmsh/opensees/apesees.py
def run(self, *, wipe: bool = True) -> None:
    """Drive an in-process LiveOpsEmitter through the full deck.

    This emits every primitive but does NOT call ``analyze`` —
    that is the user's call (or :meth:`analyze`'s). Useful when
    the user wants to declare a model, populate openseespy state,
    and then run their own analysis driver.
    """
    from .emitter.live import LiveOpsEmitter

    bm = self.build()
    emitter = LiveOpsEmitter(wipe=wipe)
    bm.emit(emitter)

h5

h5(path: str, *, model_name: str | None = None, cuts: 'Sequence[SectionCutDef]' = (), sweeps: 'Sequence[SectionSweepDef]' = ()) -> None

Emit a model-definition HDF5 archive at path.

Phase 8.5 composes the file in two layers:

  1. The broker (self._fem) writes /meta + the neutral zone (/nodes, /elements/{type}, /physical_groups, /labels, /constraints/{kind}, /loads/{kind}/{pattern}, /masses). Broker writers live in :mod:apeGmsh.mesh._femdata_h5_io.
  2. The bridge (an :class:H5Emitter driven through the :class:BuiltModel) appends /opensees/... enrichment.
  3. apeGmsh.cuts v4: if cuts and / or sweeps are supplied, they're persisted under /opensees/cuts/ and /opensees/sweeps/ (writer in :mod:apeGmsh.cuts._h5_io).

If self._fem does not expose a real :class:FEMData surface (e.g. integration tests using a hand-rolled stub), the broker step is skipped: the file ends up with the bridge's own /meta plus /opensees/..., but no neutral zone. Real callers always get the full file shape.

Parameters

path File path to write the HDF5 archive to. model_name Optional human-readable name written to /meta/model_name. Defaults to the path's stem. cuts Optional sequence of :class:apeGmsh.cuts.SectionCutDef to persist under /opensees/cuts/cut_{i}. Each cut travels with the model definition; the viewer auto-loads them on the next results.viewer(model_h5=path). sweeps Optional sequence of :class:apeGmsh.cuts.SectionSweepDef to persist under /opensees/sweeps/sweep_{i}. Each sweep group carries its own cuts/ sub-group in sweep order (see apeGmsh/cuts/ARCHITECTURE.md "## v4").

Source code in src/apeGmsh/opensees/apesees.py
def h5(
    self,
    path: str,
    *,
    model_name: str | None = None,
    cuts: "Sequence[SectionCutDef]" = (),
    sweeps: "Sequence[SectionSweepDef]" = (),
) -> None:
    """Emit a model-definition HDF5 archive at ``path``.

    Phase 8.5 composes the file in two layers:

    1. The **broker** (``self._fem``) writes ``/meta`` + the
       neutral zone (``/nodes``, ``/elements/{type}``,
       ``/physical_groups``, ``/labels``, ``/constraints/{kind}``,
       ``/loads/{kind}/{pattern}``, ``/masses``).  Broker writers
       live in :mod:`apeGmsh.mesh._femdata_h5_io`.
    2. The **bridge** (an :class:`H5Emitter` driven through the
       :class:`BuiltModel`) appends ``/opensees/...`` enrichment.
    3. apeGmsh.cuts v4: if ``cuts`` and / or ``sweeps`` are
       supplied, they're persisted under ``/opensees/cuts/`` and
       ``/opensees/sweeps/`` (writer in
       :mod:`apeGmsh.cuts._h5_io`).

    If ``self._fem`` does not expose a real :class:`FEMData`
    surface (e.g. integration tests using a hand-rolled stub),
    the broker step is skipped: the file ends up with the
    bridge's own ``/meta`` plus ``/opensees/...``, but no neutral
    zone.  Real callers always get the full file shape.

    Parameters
    ----------
    path
        File path to write the HDF5 archive to.
    model_name
        Optional human-readable name written to ``/meta/model_name``.
        Defaults to the path's stem.
    cuts
        Optional sequence of :class:`apeGmsh.cuts.SectionCutDef`
        to persist under ``/opensees/cuts/cut_{i}``.  Each cut
        travels with the model definition; the viewer auto-loads
        them on the next ``results.viewer(model_h5=path)``.
    sweeps
        Optional sequence of :class:`apeGmsh.cuts.SectionSweepDef`
        to persist under ``/opensees/sweeps/sweep_{i}``.  Each
        sweep group carries its own ``cuts/`` sub-group in sweep
        order (see ``apeGmsh/cuts/ARCHITECTURE.md`` "## v4").
    """
    from .emitter.h5 import H5Emitter

    snapshot_id = ""
    try:
        snapshot_id = str(self._fem.snapshot_id)
    except Exception:
        # FEM snapshots produced by some legacy paths may not have
        # a snapshot_id; tolerate gracefully (the H5 emitter writes
        # an empty string into /meta/snapshot_id, which the schema
        # already allows).
        snapshot_id = ""

    name = model_name or _path_stem(path)
    bm = self.build()
    emitter = H5Emitter(model_name=name, snapshot_id=snapshot_id)
    bm.emit(emitter)

    # Single composition path, shared with ModelData.write (ADR
    # 0018 / _internal.compose).  apeSees passes snapshot_id=None:
    # the broker / bridge meta write is authoritative here, so
    # this stays byte-invariant with the pre-extraction code.
    _compose_model_h5(
        self._fem, emitter, path,
        model_name=name,
        ndf=int(self._ndf or 0),
        cuts=cuts,
        sweeps=sweeps,
    )

register

register(prim: _P) -> _P

Register a standalone primitive with the bridge (P11).

Source code in src/apeGmsh/opensees/apesees.py
def register(self, prim: _P) -> _P:
    """Register a standalone primitive with the bridge (P11)."""
    return self._register(prim)

tag_for

tag_for(prim: Primitive) -> int | None

Return prim's allocated tag, or None if unregistered.

Source code in src/apeGmsh/opensees/apesees.py
def tag_for(self, prim: Primitive) -> int | None:
    """Return ``prim``'s allocated tag, or ``None`` if unregistered."""
    return self._tags.tag_for(prim)

build

build() -> BuiltModel

Freeze the declarations into a :class:BuiltModel.

Source code in src/apeGmsh/opensees/apesees.py
def build(self) -> BuiltModel:
    """Freeze the declarations into a :class:`BuiltModel`."""
    if self._ndm is None or self._ndf is None:
        raise RuntimeError(
            "apeSees.model(ndm=..., ndf=...) must be called before "
            "build()."
        )

    tag_for: dict[int, int] = {
        id(p): self._tags.tag_for(p) or 0 for p in self._primitives
    }
    return BuiltModel(
        primitives=tuple(self._primitives),
        tag_for=tag_for,
        ndm=self._ndm,
        ndf=self._ndf,
        fem=self._fem,
        fix_records=tuple(self._fix_records),
        mass_records=tuple(self._mass_records),
    )

Orientation helpers

Used as the orientation= argument on the typed geom_transf primitives (Linear / PDelta / Corotational).

apeGmsh.opensees.Cartesian

Cartesian(reference_axis: ArrayLike = (0.0, 0.0, 1.0))

Constant Cartesian triad. reference_axis defines e3; e1 and e2 are picked deterministically from the global axis least aligned with e3.

The default reference_axis = (0, 0, 1) reproduces the legacy "Z up" convention: horizontal beams get vecxz = (0, 0, 1) and vertical columns fall back to vecxz = (-1, 0, 0) (the sign follows the tangent direction; see :ref:shoebuckle).

Parameters

reference_axis : 3-vector The axis e3. Need not be unit length.

Example

::

from apeGmsh.opensees import Cartesian

# Standard structural convention: Z is vertical
orientation = Cartesian()                          # reference_axis = +Z

# Mechanical CAD convention: Y is vertical
orientation = Cartesian(reference_axis=(0, 1, 0))
Source code in src/apeGmsh/opensees/_orientation.py
def __init__(self, reference_axis: ArrayLike = (0.0, 0.0, 1.0)) -> None:
    e3 = _unit(reference_axis)
    # Pick the global axis least aligned with e3, project it
    # perpendicular to e3, normalise -> e1.
    candidates = (
        np.array([1.0, 0.0, 0.0]),
        np.array([0.0, 1.0, 0.0]),
        np.array([0.0, 0.0, 1.0]),
    )
    idx = int(np.argmin([abs(float(np.dot(c, e3))) for c in candidates]))
    c0 = candidates[idx]
    e1 = c0 - float(np.dot(c0, e3)) * e3
    e1 /= float(np.linalg.norm(e1))
    e2 = np.cross(e3, e1)
    self._e1 = e1
    self._e2 = e2
    self._e3 = e3

apeGmsh.opensees.Cylindrical

Cylindrical(origin: ArrayLike = (0.0, 0.0, 0.0), axis: ArrayLike = (0.0, 0.0, 1.0))

Cylindrical orientation about an axis of revolution.

At a point p:

  • e1 = radial outward, perpendicular to axis
  • e2 = circumferential, axis × e1
  • e3 = axis (constant) ← reference axis for the rule

Use this for ring beams, tank stiffeners, and any beam set whose natural "vertical" is the axis of revolution.

Parameters

origin : 3-vector Any point on the axis of revolution. axis : 3-vector Direction of the axis of revolution. Need not be unit length.

Example

::

from apeGmsh.opensees import Cylindrical

# Vertical tank
orientation = Cylindrical(origin=(0, 0, 0), axis=(0, 0, 1))
Source code in src/apeGmsh/opensees/_orientation.py
def __init__(
    self,
    origin: ArrayLike = (0.0, 0.0, 0.0),
    axis: ArrayLike = (0.0, 0.0, 1.0),
) -> None:
    self._origin = np.asarray(origin, dtype=float)
    self._axis = _unit(axis)

apeGmsh.opensees.Spherical

Spherical(origin: ArrayLike = (0.0, 0.0, 0.0))

Spherical orientation about a fixed origin. Polar axis is global +Z.

At a point p (with r = |p − origin|):

  • e1 = e_θ — along the meridian (south at the equator)
  • e2 = e_φ — along the parallel (east)
  • e3 = e_r — outward radial ← reference axis for the rule

Useful for fan vaults, geodesic ribs, and any beam network with natural radial structure. Note: for a planar curved beam (e.g. a vertical-plane arch), :class:Cartesian with reference_axis in the plane gives the same answer with less ceremony.

Parameters

origin : 3-vector Centre of the sphere.

Example

::

from apeGmsh.opensees import Spherical

orientation = Spherical(origin=(0, 0, 0))
Source code in src/apeGmsh/opensees/_orientation.py
def __init__(self, origin: ArrayLike = (0.0, 0.0, 0.0)) -> None:
    self._origin = np.asarray(origin, dtype=float)

Recorders

Standalone recorder declaration helper. Recorder declarations live on ops.recorder.* in the apeSees bridge.

apeGmsh.opensees.recorder

Typed recorder primitives.

Phase 3B ships three concrete recorder classes mirroring the OpenSees recorder command:

  • :class:Noderecorder Node ...
  • :class:Elementrecorder Element ...
  • :class:MPCOrecorder mpco ... (HDF5)

Each class is a @dataclass(frozen=True, kw_only=True, slots=True); the matching :class:apeGmsh.opensees._internal.ns.recorder._RecorderNS methods take the same kwargs and call self._bridge._register(Cls(...)).

Recorders never compose other primitives (dependencies() returns ()). They are leaves in the dependency graph; the build pipeline emits them after the topology + analysis chain so that each recorder command sees fully-allocated node and element tags.

The pg= form (physical-group fan-out into node/element tags) is declared on the type signatures for forward-compatibility but :meth:_emit raises :class:NotImplementedError until the Phase 4 build pipeline materializes the FEM-snapshot lookup. Recorders constructed today supply explicit nodes= / elements= lists.

OpenSees command shapes

::

recorder Node    -file fname [-time] [-dT dT] [-node n...]
                             -dof d... response
recorder Element -file fname [-time] [-dT dT] [-ele e...]
                             response_tokens...
recorder mpco    fname.mpco  [-N nodal_responses...]
                             [-E elem_responses...]
                             [-T dt $dt | -T nsteps $n]

The -time flag (when time_format="dt") instructs OpenSees to include the simulation-time column in the output file. The default time_format="step" writes only the response columns.

Node dataclass

Node(*, file: str, response: str, nodes: tuple[int, ...] | None = None, pg: str | None = None, dofs: tuple[int, ...], dT: float | None = None, time_format: str = 'step')

Bases: Recorder

recorder Node — record nodal response history.

OpenSees command::

recorder Node -file fname [-time] [-dT dT]
              (-node n1 n2 ... | -nodeRange first last)
              -dof d1 d2 ... response

Exactly one of nodes= (explicit list) or pg= (physical-group label) must be supplied; the build pipeline (Phase 4) materializes the pg= form into a concrete node-tag list. Until then, the pg= path raises :class:NotImplementedError from :meth:_emit.

Parameters

file Output file path. response OpenSees response token ("disp", "vel", "accel", "reaction", "unbalance", ...). nodes Explicit tuple of node tags. Mutually exclusive with pg. pg Physical-group label whose nodes the recorder targets. Mutually exclusive with nodes. Build-pipeline only. dofs DOF indices (1-based, OpenSees convention). At least one required. dT Optional cadence — record only every dT simulation seconds. None records every step. time_format "step" (default) writes only response columns; "dt" emits the OpenSees -time flag, prepending the simulation-time column.

Element dataclass

Element(*, file: str, response: tuple[str, ...], elements: tuple[int, ...] | None = None, pg: str | None = None, dT: float | None = None, time_format: str = 'step')

Bases: Recorder

recorder Element — record element-level response history.

OpenSees command::

recorder Element -file fname [-time] [-dT dT]
                 (-ele e1 e2 ... | -eleRange first last)
                 response_tokens...

response is a tuple of OpenSees response tokens — the simplest case is ("globalForce",) or ("stresses",); element types that nest responses (e.g. fiber sections) take multi-token forms such as ("section", "1", "force").

Exactly one of elements= (explicit list) or pg= (physical- group label) must be supplied; pg= is deferred to Phase 4.

Parameters

file Output file path. response Tuple of OpenSees response tokens (at least one). elements Explicit tuple of element tags. Mutually exclusive with pg. pg Physical-group label whose elements the recorder targets. Mutually exclusive with elements. Build-pipeline only. dT Optional cadence — record only every dT simulation seconds. None records every step. time_format "step" (default) writes only response columns; "dt" emits the OpenSees -time flag.

MPCO dataclass

MPCO(*, file: str, nodal_responses: tuple[str, ...] = (), elem_responses: tuple[str, ...] = (), dT: float | None = None, nsteps: int | None = None)

Bases: Recorder

recorder mpco — write a single HDF5 .mpco file.

OpenSees command::

recorder mpco fname.mpco [-N nodal_responses...]
                         [-E elem_responses...]
                         [-T dt $dt | -T nsteps $n]

The MPCO recorder captures the full response tensor for each requested token (no per-DOF selection at write time); STKO / apeGmsh consumers filter at read time. At least one of nodal_responses or elem_responses must be non-empty.

Cadence is selected by exactly one of dT (seconds) or nsteps (analysis steps). Supplying both raises ValueError; supplying neither records every analysis step.

Parameters

file Output .mpco (HDF5) file path. nodal_responses Tuple of MPCO -N tokens (e.g. ("displacement", "reactionForce")). Empty tuple means no nodal recording. elem_responses Tuple of MPCO -E tokens (e.g. ("stresses", "section.fiber.stress")). Empty tuple means no element recording. dT Optional time-based cadence (seconds). Mutually exclusive with nsteps. nsteps Optional step-based cadence (every N analysis steps). Mutually exclusive with dT.

RecorderRecord dataclass

RecorderRecord(*, category: str, components: tuple[str, ...] = (), raw: tuple[str, ...] = (), pg: tuple[str, ...] = (), label: tuple[str, ...] = (), selection: tuple[str, ...] = (), ids: tuple[int, ...] | None = None, dt: float | None = None, n_steps: int | None = None, name: str | None = None, n_modes: int | None = None, element_class_name: str | None = None)

One category-level declaration entry within a RecorderDeclaration.

Stores already-expanded canonical components (or raw OpenSees tokens via the raw= escape hatch). Shorthand expansion ("displacement"displacement_x/y/z) happens at construction in the namespace method (Phase 9 commit 3), not in this dataclass — by the time a record is built, components are fully expanded.

Parameters

category One of :data:ALL_RECORDER_CATEGORIES. components Tuple of canonical component names. Validated against :data:_CATEGORY_CANONICALS per category, plus indexed canonicals (state_variable_<n>, fiber_stress_<n>, spring_force_<n>) recognized via :func:is_canonical. raw Escape hatch for non-canonical OpenSees tokens (e.g. a custom recorder response). Bypasses canonical validation. pg / label / selection / ids Target selectors. ids= is mutually exclusive with the named selectors. Resolution against FEMData happens at emit time (commit 3). dt / n_steps Recording cadence. At most one may be set; both None records every step. name Optional user-supplied name for this record; auto-generated when None. n_modes Required for category="modal"; rejected for other categories. element_class_name Optional OpenSees C++ class name override for element-level records. Used by the .out transcoder to disambiguate elements that share a flat response size (e.g. tri31 vs SSPquad). Carried from the legacy Recorders.elements contract.

RecorderDeclaration dataclass

RecorderDeclaration(*, records: tuple[RecorderRecord, ...], name: str = 'default', ndm: int = 3, ndf: int = 6, file_root: str = '.')

Bases: Recorder

A bundle of recorder records, registered as a single Primitive.

Captures the bridge's ndm and ndf at construction time (Phase 9 D8 — implicit source-of-truth binding). Drives the file-emit path via :func:emit_recorder_spec in :mod:apeGmsh.opensees._internal.build.

Parameters

records Tuple of :class:RecorderRecord entries. Each is one category-level declaration; emit fans them out into one or more concrete OpenSees recorder commands. name Identifier for this declaration (defaults to "default"). Multiple named declarations can coexist on one bridge. ndm, ndf Snapshot of the bridge's ndm/ndf at construction time. Used downstream for shorthand expansion and validation. The bridge passes these in (Phase 9 D8 — user never repeats ops.model(ndm=, ndf=) values). file_root Directory prefix for emitted .out files. Each record fans out to <file_root>/<decl.name>__<record_name>__<token>.out. Defaults to "." (current working directory).

Numberer

apeGmsh.mesh._numberer.Numberer

Numberer(fem_data: dict)

Renumbers a FEM mesh for solver consumption.

Parameters

fem_data : dict Output of Mesh.get_fem_data(). Must contain: node_tags, node_coords, elem_tags, connectivity, used_tags.

Source code in src/apeGmsh/mesh/_numberer.py
def __init__(self, fem_data: dict) -> None:
    self._raw = fem_data
    self._node_tags   = np.asarray(fem_data['node_tags'], dtype=int)
    self._node_coords = np.asarray(fem_data['node_coords'], dtype=float)
    self._elem_tags   = np.asarray(fem_data['elem_tags'], dtype=int)
    self._connectivity = np.asarray(fem_data['connectivity'], dtype=int)
    self._used_tags   = fem_data.get('used_tags', set(self._connectivity.flatten()))

    # Build Gmsh tag -> raw array index
    self._tag_to_raw_idx: dict[int, int] = {
        int(t): i for i, t in enumerate(self._node_tags)
    }

renumber

renumber(method: str = 'simple', *, base: int = 1, used_only: bool = True) -> NumberedMesh

Produce a solver-ready mesh with contiguous IDs.

Parameters

method : "simple" or "rcm" "simple" — preserves relative order, just makes IDs contiguous. Fast, no optimisation.

``"rcm"``  — Reverse Cuthill-McKee bandwidth minimisation.
Reorders nodes so that the assembled stiffness matrix has
minimal bandwidth.  Recommended for direct solvers.
int

Starting ID (default 1 = Fortran/OpenSees convention; use 0 for C/Python convention).

bool

If True (default), only include nodes that appear in at least one element (skip orphan nodes). Set False to include all nodes from the mesh.

Returns

NumberedMesh

Source code in src/apeGmsh/mesh/_numberer.py
def renumber(
    self,
    method: str = "simple",
    *,
    base: int = 1,
    used_only: bool = True,
) -> NumberedMesh:
    """
    Produce a solver-ready mesh with contiguous IDs.

    Parameters
    ----------
    method : ``"simple"`` or ``"rcm"``
        ``"simple"``  — preserves relative order, just makes IDs
        contiguous.  Fast, no optimisation.

        ``"rcm"``  — Reverse Cuthill-McKee bandwidth minimisation.
        Reorders nodes so that the assembled stiffness matrix has
        minimal bandwidth.  Recommended for direct solvers.

    base : int
        Starting ID (default 1 = Fortran/OpenSees convention;
        use 0 for C/Python convention).

    used_only : bool
        If True (default), only include nodes that appear in at
        least one element (skip orphan nodes).  Set False to
        include all nodes from the mesh.

    Returns
    -------
    NumberedMesh
    """
    # ── Filter nodes ──────────────────────────────────────────
    if used_only:
        mask = np.isin(self._node_tags, list(self._used_tags))
        n_total   = len(self._node_tags)
        gmsh_tags = self._node_tags[mask]
        coords    = self._node_coords[mask]
        n_orphans = n_total - len(gmsh_tags)
        if n_orphans > 0:
            orphan_tags = self._node_tags[~mask]
            print(
                f"[Numberer] WARNING: {n_orphans} orphan node(s) "
                f"skipped (not connected to any element). "
                f"Tags: {orphan_tags.tolist()[:20]}"
                + (f" ... (+{n_orphans - 20} more)"
                   if n_orphans > 20 else "")
            )
    else:
        gmsh_tags = self._node_tags.copy()
        coords    = self._node_coords.copy()

    n_nodes = len(gmsh_tags)

    # ── Temporary 0-based indexing ────────────────────────────
    # Map Gmsh tags -> 0-based indices for internal work
    gtag_to_tmp: dict[int, int] = {
        int(t): i for i, t in enumerate(gmsh_tags)
    }

    # Rewrite connectivity in 0-based tmp indices (vectorized)
    flat = self._connectivity.ravel()
    conn_tmp = np.array(
        [gtag_to_tmp[int(t)] for t in flat],
        dtype=int,
    ).reshape(self._connectivity.shape)

    # ── Compute permutation ───────────────────────────────────
    if method == "rcm":
        perm = _rcm_ordering(n_nodes, conn_tmp)
    elif method == "simple":
        perm = np.arange(n_nodes, dtype=int)
    else:
        raise ValueError(
            f"Unknown method '{method}'. Use 'simple' or 'rcm'."
        )

    # perm[new_pos] = old_pos
    # inverse: inv_perm[old_pos] = new_pos
    inv_perm = np.empty(n_nodes, dtype=int)
    inv_perm[perm] = np.arange(n_nodes)

    # ── Apply permutation ─────────────────────────────────────
    new_coords     = coords[perm]            # reordered coords

    # Rewrite connectivity with new IDs (vectorized)
    new_conn = inv_perm[conn_tmp] + base

    # Element IDs: simple contiguous
    new_elem_ids = np.arange(base, base + len(self._elem_tags), dtype=int)

    # ── Bandwidth ─────────────────────────────────────────────
    bw = _compute_bandwidth(new_conn)

    # ── Build maps ────────────────────────────────────────────
    g2s_node: dict[int, int] = {}
    s2g_node: dict[int, int] = {}
    for new_pos in range(n_nodes):
        old_pos = perm[new_pos]
        gtag = int(gmsh_tags[old_pos])
        sid  = int(inv_perm[old_pos]) + base
        g2s_node[gtag] = sid
        s2g_node[sid]  = gtag

    g2s_elem: dict[int, int] = {}
    s2g_elem: dict[int, int] = {}
    for i, etag in enumerate(self._elem_tags):
        eid = int(new_elem_ids[i])
        g2s_elem[int(etag)] = eid
        s2g_elem[eid]       = int(etag)

    # ── Rewrite node_ids array in order ───────────────────────
    # node_ids[i] = solver ID of the i-th node (in new ordering)
    solver_node_ids = np.arange(base, base + n_nodes, dtype=int)

    result = NumberedMesh(
        node_ids=solver_node_ids,
        node_coords=new_coords,
        elem_ids=new_elem_ids,
        connectivity=new_conn,
        n_nodes=n_nodes,
        n_elems=len(self._elem_tags),
        bandwidth=bw,
        method=method,
        gmsh_to_solver_node=g2s_node,
        solver_to_gmsh_node=s2g_node,
        gmsh_to_solver_elem=g2s_elem,
        solver_to_gmsh_elem=s2g_elem,
    )

    return result

compare_methods

compare_methods() -> dict[str, int]

Compare bandwidth for all available methods.

Returns

dict[str, int] {"simple": bw1, "rcm": bw2}

Source code in src/apeGmsh/mesh/_numberer.py
def compare_methods(self) -> dict[str, int]:
    """
    Compare bandwidth for all available methods.

    Returns
    -------
    dict[str, int]
        ``{"simple": bw1, "rcm": bw2}``
    """
    results = {}
    for method in ("simple", "rcm"):
        data = self.renumber(method=method)
        results[method] = data.bandwidth
    return results

apeGmsh.mesh._numberer.NumberedMesh dataclass

NumberedMesh(node_ids: ndarray, node_coords: ndarray, elem_ids: ndarray, connectivity: ndarray, n_nodes: int = 0, n_elems: int = 0, bandwidth: int = 0, method: str = 'simple', gmsh_to_solver_node: dict[int, int] = dict(), solver_to_gmsh_node: dict[int, int] = dict(), gmsh_to_solver_elem: dict[int, int] = dict(), solver_to_gmsh_elem: dict[int, int] = dict())

Solver-ready mesh with contiguous IDs and bidirectional maps.

All IDs are 1-based (the standard in structural FEM solvers like OpenSees, Abaqus, SAP2000). Set base=0 in :meth:Numberer.renumber for 0-based if your solver needs it.

Attributes

node_ids : ndarray(N,) New contiguous node IDs. node_coords : ndarray(N, 3) Nodal coordinates, same order as node_ids. elem_ids : ndarray(E,) New contiguous element IDs. connectivity : ndarray(E, npe) Element connectivity in terms of new node IDs. n_nodes : int n_elems : int bandwidth : int Semi-bandwidth of the resulting adjacency. method : str Numbering method used ("simple" or "rcm").

Maps ~~~~ gmsh_to_solver_node : dict[int, int] Gmsh node tag -> solver node ID. solver_to_gmsh_node : dict[int, int] Solver node ID -> Gmsh node tag. gmsh_to_solver_elem : dict[int, int] Gmsh element tag -> solver element ID. solver_to_gmsh_elem : dict[int, int] Solver element ID -> Gmsh element tag.

summary

summary() -> str

One-line summary string.

Source code in src/apeGmsh/mesh/_numberer.py
def summary(self) -> str:
    """One-line summary string."""
    return (
        f"NumberedMesh({self.method}): "
        f"{self.n_nodes} nodes, {self.n_elems} elements, "
        f"bandwidth={self.bandwidth}"
    )