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 brings the session in three ways (ADR 0051): MP constraints (g.constraints.*) auto-emit; loads (g.loads.*) and prescribed displacements (g.displacements.*) are opt-in — a resolved load case reaches the deck only when a bridge pattern imports it with p.from_model(case) (or you author it with p.load(...)); masses and support fixities / SPs are re-declared explicitly on ops (ops.fix(pg=, dofs=), ops.mass(pg=, values=)).

Loads do not auto-emit. A g.loads.* case reaches the solver only via p.from_model(case) inside a pattern (or an ad-hoc p.load). Nothing auto-emits, so there is no double-count trap; the deck is authoritative — the bridge applies exactly the cases you import and does not audit the geometry's declared cases, so a case you don't import is simply not applied. A staged model must keep every pattern stage-scoped (s.pattern(series=...)) — a global pattern + ops.stage(...) raises BridgeError.

Since the teardown, the bridge has been progressively widened:

  • Loads are opt-in (ADR 0051). A g.loads.* case reaches the runnable Tcl/Py deck (and the live/run path) only when a bridge pattern imports it with p.from_model(case) — or you author the load directly with p.load(...). Nothing auto-emits; the deck is authoritative (see the note above).
  • MP constraints emit automatically from fem.nodes.constraints / fem.elements.constraints (ADR 0022, Phase 7b) — equalDOF / rigidLink / rigidDiaphragm / ASDEmbeddedNodeElement lines land in the runnable Tcl/Py deck without an ingest step. The apeSees.h5(path) write target persists per-record details under /opensees/constraints/ (additive minor schema bump 2.6.0 → 2.7.0). Auto-emits a Transformation constraint handler when MP constraints are present and the user has not declared one.
  • Staged analysis ships via ops.stage(name) — see Staged analysis below for the user- surface walkthrough, and the in-repo internals doc at staged-analysis.md for the per-stage emit pipeline.
  • Read-side broker. OpenSeesModel.from_h5(path, fem_root=) provides a frozen read-only view of the persisted /opensees/ zone with the embedded FEMData lazily attached (ADR 0019). Re-emit via om.build("tcl", path) / om.build("py", path) / om.build("live") without rehydrating the apeSees primitives.

For the full user-facing surface (typed materials, sections, elements, recorders, patterns, analysis chain, staged analysis, SSI helpers, cuts and sweeps), see the in-repo api-design.md.

Public surface

apeGmsh.opensees.apeSees

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

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,
    opensees: "OpenSeesTarget | None" = None,
) -> None:
    self._fem: "FEMData" = fem
    # Last live emitter from :meth:`analyze` — retained so post-run live
    # queries (e.g. :meth:`ladruno_projection_tie_force`) can reach the
    # openseespy session that just ran. ``None`` until a live analyze runs.
    self._live_emitter: "LiveOpsEmitter | None" = None
    # Which OpenSees runtime the subprocess paths bind, and the live
    # fork expectation.  ``None`` → env-var / PATH fallback (the
    # pre-target behaviour).  See :mod:`apeGmsh.opensees._target`.
    self._opensees: "OpenSeesTarget | None" = opensees
    self._primitives: list[Primitive] = []
    # name -> primitive alias table (bridge-side; the primitive
    # stays pure/tag-less, so names never touch the lineage hash or
    # the h5 schema).  Populated by ``_register(..., name=...)`` and
    # read by ``_resolve`` so reference kwargs accept a name string
    # as well as the object handle.
    self._names: dict[str, Primitive] = {}
    self._tags = TagAllocator()
    self._ndm: int | None = None
    self._ndf: int | None = None
    self._fix_records: list[FixRecord] = []
    self._mass_records: list[MassRecord] = []
    # ADR 0065 Tier 2 — opt-in: stream per-node masses from the snapshot
    # at emit instead of one bridge MassRecord per node. Set by
    # ``mass_from_model()``; threaded into the BuiltModel.
    self._mass_from_model: bool = False
    # ADR 0049 — ``ops.ndf`` directives (element-less decoupled nodes only).
    self._ndf_records: list[NdfRecord] = []
    self._region_records: list[RegionAssignmentRecord] = []
    self._rayleigh_records: list[RayleighRecord] = []
    self._damping_attach_records: list[DampingAttachRecord] = []
    self._modal_damping_records: list[ModalDampingRecord] = []
    self._initial_stress_records: list[InitialStressRecord] = []
    # Phase SSI-2.A: closed StageRecord instances accumulate here as
    # ``with ops.stage(name) as s:`` blocks exit.  ``stage_records``
    # being non-empty switches BuiltModel.emit into the staged
    # emission path (per-stage analyze loops with loadConst /
    # wipeAnalysis / hook-list clear between).
    self._stage_records: list[StageRecord] = []
    # Ladruno-fork stack profiler: ordered ``(verb, args)`` control
    # entries recorded by ``ops.profiler.<verb>(...)``.  The deck
    # emitters (tcl / py) flush these bracketing the appended
    # ``analyze`` line — ``start`` / ``reset`` before, ``stop`` /
    # ``report`` / ``memory`` after (see ``_split_profiler_records``).
    # Live single-call profiling does NOT consume this; it is driven by
    # the ``profile=`` kwarg family on :meth:`analyze`.
    self._profiler_records: list[
        tuple[str, tuple[int | float | str, ...]]
    ] = []
    # Tracks the currently-open _StageBuilder, if any, so
    # ``apeSees.stage()`` can refuse nested ``with`` blocks
    # (post-merge cleanup, red-team M4).  None when no stage is
    # being built.  Cleared by ``_StageBuilder.__exit__``.
    self._open_stage_builder: "_StageBuilder | None" = None
    # Phase SSI-2.D (PR-C) recorder claiming: when ``s.recorder(rec)``
    # PULLs a registered recorder spec into a stage's pool, the
    # spec's ``id(...)`` lands here so the global post-element
    # emit loop knows to SKIP it (the stage's emit will drive
    # ``_emit_recorder_spec`` inside the stage block instead).
    # The recorder stays in ``_primitives`` so its allocated tag
    # remains discoverable via ``tag_for[id(p)]``.
    self._stage_claimed_recorder_ids: set[int] = set()
    # Stage-bound constraint claiming: when ``s.embedded(name=...)``
    # / ``s.equal_dof(name=...)`` / etc. CLAIMS a resolved
    # constraint record from ``fem.{nodes,elements}.constraints``,
    # the record's ``id(...)`` lands here so the global MP-
    # constraint emit loop SKIPS it.  The record stays on the
    # FEMData broker (broker is immutable from the bridge's
    # perspective) but emits inside the owning stage's block
    # via ``emit_stage_mp_constraints``.  Doubles as the
    # double-claim detector across stage builders.
    self._stage_claimed_constraint_ids: set[int] = set()
    # ADR 0093 S7 (INV-6) stage-bound INTERFACE claiming, kept as a
    # PARALLEL id-set rather than folded into the MP set above: the
    # two pools are released and re-emitted by different passes
    # (``emit_stage_mp_constraints`` vs ``emit_stage_interfaces``),
    # and a shared set would let an interface claim satisfy — or
    # collide with — an MP claim.  Same semantics otherwise: the
    # record stays on ``fem.elements.interfaces`` and the base
    # ``emit_interfaces`` pass SKIPS it; doubles as the double-claim
    # detector across stage builders.
    self._stage_claimed_interface_ids: set[int] = set()
    # ADR 0051 (BL-3) stage-scoped pattern claiming: when
    # ``s.pattern(series=)`` creates a stage-owned ``Plain``, its
    # ``id(...)`` lands here so the global post-element pattern emit
    # loop SKIPS it (the stage's emit drives ``emit_pattern_spec`` /
    # ``_emit_one_pattern_partitioned`` inside the stage block).
    # The pattern stays in ``_primitives`` so its tag remains
    # discoverable via ``tag_for[id(p)]``.  Mirrors
    # ``_stage_claimed_recorder_ids``.
    self._stage_claimed_pattern_ids: set[int] = set()
    # ADR 0052 slice 1: the single shared ``Constant`` series (factor
    # 1.0) that every stage's HOLD pattern references.  Created
    # lazily on the first ``s.support(...)`` across any stage (so a
    # model with no supports emits no extra series), then reused —
    # one series, not one per stage (ADR 0052 Resolved decision §3).
    self._hold_series: "TimeSeries | None" = None
    # 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.fault            = _FaultNS(self)
    self.element          = _ElementNS(self)
    self.recorder         = _RecorderNS(self)
    self.profiler         = _ProfilerNS(self)
    self.damping          = _DampingNS(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)
    self.strategy         = _StrategyNS(self)

opensees property

opensees: 'OpenSeesTarget | None'

The :class:OpenSeesTarget bound on construction, or None.

all_fix_records property

all_fix_records: 'tuple[tuple[str, FixRecord], ...]'

All fix records — global + every stage's pool.

Returns a tuple of (origin, record) pairs where origin is either "global" or f"stage {stage.name!r}". Order: global pool first (in registration order), then each stage in stage_records order, then each record within a stage in registration order.

all_mass_records property

all_mass_records: 'tuple[tuple[str, MassRecord], ...]'

All mass records — global + every stage's pool.

Same shape as :attr:all_fix_records.

all_region_records property

all_region_records: 'tuple[tuple[str, RegionAssignmentRecord], ...]'

All region records — global + every stage's pool.

Phase SSI-2.D PR-C introspection symmetry (matches the :attr:all_fix_records / :attr:all_mass_records shape). Validator V3 (PR-A) guarantees no name= collision across scopes, so the user-facing name is unambiguous per (origin, record) pair.

all_recorder_specs property

all_recorder_specs: 'tuple[tuple[str, Recorder], ...]'

All recorder specs — global + every stage's pool.

Global recorders are sourced from self._primitives filtered to :class:Recorder instances and EXCLUDING any spec claimed by s.recorder(...); the per-stage entries come from each :class:StageRecord's recorder_specs. Origin is "global" or f"stage {stage.name!r}".

capabilities

capabilities() -> 'OpenSeesCapabilities'

Probe the in-process openseespy build (live path).

Imports openseespy in the active interpreter and reports whether it looks like the Ladruno fork (has_fork), exposes the fork-only profiler command, its version() string, and its build stamp (the exact git hash the binary was compiled from, on fork builds that ship ladrunoBuild). Raises if openseespy is not installed. This introspects the live runtime only — the subprocess paths bind their own interpreter / binary via :class:OpenSeesTarget.

Source code in src/apeGmsh/opensees/apesees.py
def capabilities(self) -> "OpenSeesCapabilities":
    """Probe the in-process openseespy build (live path).

    Imports openseespy in the active interpreter and reports whether
    it looks like the Ladruno fork (``has_fork``), exposes the
    fork-only ``profiler`` command, its ``version()`` string, and its
    ``build`` stamp (the exact git hash the binary was compiled from,
    on fork builds that ship ``ladrunoBuild``).
    Raises if openseespy is not installed.  This introspects the
    **live** runtime only — the subprocess paths bind their own
    interpreter / binary via :class:`OpenSeesTarget`.
    """
    from ._target import probe_live_capabilities

    return probe_live_capabilities()

model

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

Set the model dimensionality (ndm) and the envelope ndf.

Per-node ndf is inferred from the declared element classes (ADR 0048) — ndf here is only the OpenSees model envelope (model BasicBuilder -ndm K -ndf N) and the fallback for nodes inference cannot see: element-less / decoupled nodes, and nodes touched only by adaptive elements (the zeroLength family). Element-attached nodes get their inferred value as a per-node -ndf override, emitted only where it differs from this envelope. There is no per-node ndf to declare on the geometry session — g.node_ndf was removed; the elements you declare determine it.

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

    Per-node ``ndf`` is **inferred** from the declared element
    classes (ADR 0048) — ``ndf`` here is only the OpenSees model
    **envelope** (``model BasicBuilder -ndm K -ndf N``) and the
    **fallback** for nodes inference cannot see: element-less /
    decoupled nodes, and nodes touched only by adaptive elements
    (the zeroLength family). Element-attached nodes get their
    inferred value as a per-node ``-ndf`` override, emitted only
    where it differs from this envelope. There is no per-node
    ``ndf`` to declare on the geometry session — ``g.node_ndf``
    was removed; the elements you declare determine it.
    """
    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,
    )
    # Pass the live bridge through so DomainCapture materialises a
    # sidecar model.h5 and composes its ``/opensees/`` zone into the
    # run file (ADR 0020 Composed-file pattern).  Without this the
    # capture file carries only ``/model/`` + ``/stages/`` — and the
    # broker's neutral ``/model/meta`` has no bridge ``ndf`` (the
    # broker doesn't know the OpenSees envelope), so
    # ``OpenSeesModel.from_h5(path, fem_root="/model")`` would read
    # ``ndf=0``.  Forwarding the bridge lets
    # ``NativeWriter.write_opensees_from`` propagate the envelope
    # ndf onto ``/model/meta`` so mixed-ndf models round-trip through
    # ``Results.from_native``.
    #
    # The sidecar is written via ``self.h5(...)``.  Every build
    # forwards the bridge now: ADR 0055 Phase 5 lifted the
    # partitioned-staged ``h5()`` guard (P5.1), so the Composed run
    # file carries ``/opensees/stages`` + ``/opensees/partitions``
    # + the envelope ndf for partitioned staged captures too
    # (P5.3) — the feedstock the stage-aware viewer reads.  The
    # one remaining staged raise site (stage-claimed phantom
    # nodes, emitter gate-2) is handled by ``DomainCapture``'s
    # __enter__ degrade: it warns and proceeds sidecar-less.
    return DomainCapture(resolved, path, self._fem, ops=ops, bridge=self)

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, ...], overwrite: bool = False) -> None

Attach lumped nodal mass.

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

overwrite (Phase SSI-2.E) opts the record out of validator V2's cross-tier duplicate-mass check. Rare at the global tier but kept for symmetry with the stage-bound :meth:_StageBuilder.mass — see that method for the typical use case.

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

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

    ``overwrite`` (Phase SSI-2.E) opts the record out of validator
    V2's cross-tier duplicate-mass check.  Rare at the global tier
    but kept for symmetry with the stage-bound :meth:`_StageBuilder.mass`
    — see that method for the typical use case.
    """
    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),
            overwrite=bool(overwrite),
        ),
    )

mass_from_model

mass_from_model() -> None

Stream per-node lumped masses straight from the model snapshot.

Equivalent to looping ops.mass(nodes=[m.node_id], values=m.mass) over every entry in fem.nodes.masses (e.g. the per-node tributary masses produced by g.masses.volume(...)), but without materializing one bridge MassRecord per node — the snapshot masses are streamed at emit time. On a multi-million-node model this avoids a multi-GB resident list and millions of small objects (ADR 0065 Tier 2). Emits byte-identical deck lines and honours per-node ndf via the same fit_dof_vector as :meth:mass.

Model-wide declaration (no arguments). May be combined with explicit :meth:mass calls only on disjoint node sets — overlap raises at emit (nodal mass is additive under MP assembly). Deck/live emit only; the H5 archival emitter rejects it (masses already persist in model.h5 via fem.nodes.masses).

Source code in src/apeGmsh/opensees/apesees.py
def mass_from_model(self) -> None:
    """Stream per-node lumped masses straight from the model snapshot.

    Equivalent to looping ``ops.mass(nodes=[m.node_id], values=m.mass)``
    over every entry in ``fem.nodes.masses`` (e.g. the per-node tributary
    masses produced by ``g.masses.volume(...)``), but **without
    materializing one bridge ``MassRecord`` per node** — the snapshot
    masses are streamed at emit time. On a multi-million-node model this
    avoids a multi-GB resident list and millions of small objects (ADR
    0065 Tier 2). Emits byte-identical deck lines and honours per-node
    ``ndf`` via the same ``fit_dof_vector`` as :meth:`mass`.

    Model-wide declaration (no arguments). May be combined with explicit
    :meth:`mass` calls only on *disjoint* node sets — overlap raises at
    emit (nodal mass is additive under MP assembly). Deck/live emit only;
    the H5 archival emitter rejects it (masses already persist in
    ``model.h5`` via ``fem.nodes.masses``).
    """
    self._mass_from_model = True

ndf

ndf(target: object = None, *, ndf: int) -> None

State the per-node ndf of an element-LESS decoupled node (ADR 0049 — the sole explicit per-node ndf channel).

Every other node's ndf is inferred from its incident element classes (ADR 0048). ops.ndf exists only for nodes inference cannot reach — a spring/dashpot ground, a control node, or a mass anchor created via g.decouple_node(...) that no element touches.

Parameters

target The decoupled-node handle returned by g.decouple_node(...) (a DecoupledNodeDef) or its integer node tag. The handle is resolved to its tag at build time (so a handle materialized after meshing resolves correctly); a still-unmeshed handle fails loud at build. ndf The DOF count to assign the node.

Raises (at build) :class:BridgeError if target is a mesh node, an element-touched node (its ndf is inferred — restating it would create a two-headed model), or an unresolved handle. The stated value is also checked by gates G1–G3 (adaptive endpoints, constraint masters, referenced fix/mass/load/sp DOFs).

Source code in src/apeGmsh/opensees/apesees.py
def ndf(self, target: object = None, *, ndf: int) -> None:
    """State the per-node ``ndf`` of an element-LESS decoupled node
    (ADR 0049 — the sole explicit per-node ndf channel).

    Every other node's ndf is **inferred** from its incident element
    classes (ADR 0048). ``ops.ndf`` exists only for nodes inference cannot
    reach — a spring/dashpot **ground**, a control node, or a mass anchor
    created via ``g.decouple_node(...)`` that no element touches.

    Parameters
    ----------
    target
        The decoupled-node handle returned by ``g.decouple_node(...)`` (a
        ``DecoupledNodeDef``) **or** its integer node tag. The handle is
        resolved to its tag at **build** time (so a handle materialized
        after meshing resolves correctly); a still-unmeshed handle fails
        loud at build.
    ndf
        The DOF count to assign the node.

    Raises (at build) :class:`BridgeError` if *target* is a mesh node, an
    element-touched node (its ndf is inferred — restating it would create
    a two-headed model), or an unresolved handle. The stated value is also
    checked by gates G1–G3 (adaptive endpoints, constraint masters,
    referenced fix/mass/load/sp DOFs).
    """
    if target is None:
        raise ValueError(
            "apeSees.ndf: a target is required — pass the decoupled-node "
            "handle from g.decouple_node(...) or its integer tag."
        )
    if not isinstance(ndf, int) or isinstance(ndf, bool) or ndf < 1:
        raise ValueError(
            f"apeSees.ndf: ndf must be a positive int (got {ndf!r})."
        )
    if isinstance(target, bool):
        raise ValueError(
            f"apeSees.ndf: target must be a decoupled-node handle or an "
            f"int tag (got bool {target!r})."
        )
    if isinstance(target, int):
        self._ndf_records.append(NdfRecord(handle=None, tag=int(target), ndf=ndf))
    else:
        # A handle (DecoupledNodeDef) — store it raw; resolve_ndf_overlay
        # dereferences ``.tag`` at build (fail-loud on a None tag).
        self._ndf_records.append(NdfRecord(handle=target, tag=None, ndf=ndf))

initial_stress

initial_stress(*, name: str, pg: str | None = None, elements: Iterable[int] | None = None, sigma_xx: float, sigma_yy: float, sigma_zz: float, ramp_steps: int, lambda_install: float = 1.0) -> 'InitialStressRecord'

Initialize an in-situ stress tensor on ASDPlasticMaterial3D elements.

Emits the OpenSees parameter / addToParameter / updateParameter ramp pattern that STKO uses to inject a pre-stressed state without applying gravity-driven body loads. The factor ramps linearly 0 → 1 over ramp_steps analyze calls and plateaus at 1.0 thereafter; the target stress baked into the ramp is sigma_* × lambda_install, so passing lambda_install < 1.0 produces a partial-installation (convergence-confinement) result.

Exactly one of pg / elements must be supplied. This primitive is declarative only — the actual stress advancement happens at analyze time, via the per-step dispatcher this primitive registers with. Call ops.analyze(steps=ramp_steps, dt=...) or pass analyze_steps=ramp_steps to :meth:tcl / :meth:py for the ramp to take effect.

Parameters

name Unique Tcl-identifier-safe label. Used to name the emitted proc / state container. pg Physical group whose elements receive the ramped stress. elements Explicit list of FEM element ids. XOR with pg. sigma_xx, sigma_yy, sigma_zz Target Cauchy stress per component (compression negative). ramp_steps Number of analyze steps over which the factor reaches 1.0. Must be >= 1. lambda_install Fraction of target to install (default 1.0). Must be in (0, 1].

Source code in src/apeGmsh/opensees/apesees.py
def initial_stress(
    self,
    *,
    name: str,
    pg: str | None = None,
    elements: Iterable[int] | None = None,
    sigma_xx: float,
    sigma_yy: float,
    sigma_zz: float,
    ramp_steps: int,
    lambda_install: float = 1.0,
) -> "InitialStressRecord":
    """Initialize an in-situ stress tensor on ASDPlasticMaterial3D elements.

    Emits the OpenSees ``parameter`` / ``addToParameter`` /
    ``updateParameter`` ramp pattern that STKO uses to inject a
    pre-stressed state without applying gravity-driven body loads.
    The factor ramps linearly 0 → 1 over ``ramp_steps`` analyze
    calls and plateaus at 1.0 thereafter; the target stress baked
    into the ramp is ``sigma_* × lambda_install``, so passing
    ``lambda_install < 1.0`` produces a partial-installation
    (convergence-confinement) result.

    Exactly one of ``pg`` / ``elements`` must be supplied.  This
    primitive is **declarative only** — the actual stress
    advancement happens at analyze time, via the per-step
    dispatcher this primitive registers with.  Call
    ``ops.analyze(steps=ramp_steps, dt=...)`` or pass
    ``analyze_steps=ramp_steps`` to :meth:`tcl` / :meth:`py` for
    the ramp to take effect.

    Parameters
    ----------
    name
        Unique Tcl-identifier-safe label.  Used to name the
        emitted proc / state container.
    pg
        Physical group whose elements receive the ramped stress.
    elements
        Explicit list of FEM element ids.  XOR with ``pg``.
    sigma_xx, sigma_yy, sigma_zz
        Target Cauchy stress per component (compression negative).
    ramp_steps
        Number of analyze steps over which the factor reaches 1.0.
        Must be ``>= 1``.
    lambda_install
        Fraction of target to install (default 1.0).  Must be in
        ``(0, 1]``.
    """
    record = _build_initial_stress_record(
        source_label="apeSees.initial_stress",
        name=name, pg=pg, elements=elements,
        sigma_xx=sigma_xx, sigma_yy=sigma_yy, sigma_zz=sigma_zz,
        ramp_steps=ramp_steps, lambda_install=lambda_install,
    )
    self._initial_stress_records.append(record)
    # Phase SSI-2.A: return the record so callers can pass it to
    # ``with ops.stage(...) as s: s.add(record)`` which moves it
    # from this bridge-global pool into the stage's pool.
    # Non-staged callers can ignore the return value — the record
    # is already registered and will emit in the flat path.
    return record

convergence_confinement

convergence_confinement(*, name: str, pg: str | None = None, elements: Iterable[int] | None = None, sigma_xx: float = 0.0, sigma_yy: float = 0.0, sigma_zz: float = 0.0, lambda_target: float, n_steps: int) -> 'InitialStressRecord'

Convergence-confinement helper (Phase SSI-3).

Thin wrapper over :meth:initial_stress for the tunnelling convergence-confinement pattern: ramp a target stress on a boundary region to lambda_target × sigma over n_steps analyze steps. Matches the _stressCtrl_11-style proc from SSI/Interaccion/analysis_steps.tcl:19753-19767.

Differs from :meth:initial_stress in two cosmetic ways:

  • lambda_target (renamed from lambda_install) — more natural reading at the call site for confinement / relaxation contexts.
  • n_steps (renamed from ramp_steps) — matches the spec's naming.

At least one of sigma_xx / sigma_yy / sigma_zz must be non-zero (typically only one — single-component relaxation is the canonical SSI use case).

Returns the underlying :class:InitialStressRecord; pass it to s.add(...) inside a stage block to bind to that stage.

Parameters

name Unique Tcl-identifier-safe label. pg, elements Same XOR semantics as :meth:initial_stress. sigma_xx, sigma_yy, sigma_zz Target Cauchy stress per component (compression negative). At least one must be non-zero. lambda_target Fraction of target stress to install — i.e. the relaxation (or confinement) coefficient. Must be in (0, 1]. n_steps Number of analyze steps over which the factor reaches 1.0 internally. After the cap, the cumulative is sigma × lambda_target.

Source code in src/apeGmsh/opensees/apesees.py
def convergence_confinement(
    self,
    *,
    name: str,
    pg: str | None = None,
    elements: Iterable[int] | None = None,
    sigma_xx: float = 0.0,
    sigma_yy: float = 0.0,
    sigma_zz: float = 0.0,
    lambda_target: float,
    n_steps: int,
) -> "InitialStressRecord":
    """Convergence-confinement helper (Phase SSI-3).

    Thin wrapper over :meth:`initial_stress` for the tunnelling
    convergence-confinement pattern: ramp a target stress on a
    boundary region to ``lambda_target`` × ``sigma`` over
    ``n_steps`` analyze steps.  Matches the
    ``_stressCtrl_11``-style proc from
    ``SSI/Interaccion/analysis_steps.tcl:19753-19767``.

    Differs from :meth:`initial_stress` in two cosmetic ways:

    * ``lambda_target`` (renamed from ``lambda_install``) — more
      natural reading at the call site for confinement / relaxation
      contexts.
    * ``n_steps`` (renamed from ``ramp_steps``) — matches the
      spec's naming.

    At least one of ``sigma_xx`` / ``sigma_yy`` / ``sigma_zz`` must
    be non-zero (typically only one — single-component relaxation
    is the canonical SSI use case).

    Returns the underlying :class:`InitialStressRecord`; pass it to
    ``s.add(...)`` inside a stage block to bind to that stage.

    Parameters
    ----------
    name
        Unique Tcl-identifier-safe label.
    pg, elements
        Same XOR semantics as :meth:`initial_stress`.
    sigma_xx, sigma_yy, sigma_zz
        Target Cauchy stress per component (compression negative).
        At least one must be non-zero.
    lambda_target
        Fraction of target stress to install — i.e. the relaxation
        (or confinement) coefficient.  Must be in ``(0, 1]``.
    n_steps
        Number of analyze steps over which the factor reaches 1.0
        internally.  After the cap, the cumulative is
        ``sigma × lambda_target``.
    """
    if sigma_xx == 0.0 and sigma_yy == 0.0 and sigma_zz == 0.0:
        raise ValueError(
            "apeSees.convergence_confinement: at least one of "
            "sigma_xx / sigma_yy / sigma_zz must be non-zero."
        )
    return self.initial_stress(
        name=name,
        pg=pg,
        elements=elements,
        sigma_xx=sigma_xx,
        sigma_yy=sigma_yy,
        sigma_zz=sigma_zz,
        ramp_steps=n_steps,
        lambda_install=lambda_target,
    )

imposed_displacement

imposed_displacement(*, pg: str | None = None, nodes: Iterable[int] | None = None, ux: float | None = None, uy: float | None = None, uz: float | None = None, pattern_factor: float = 1.0, series: 'TimeSeries | None' = None) -> 'Plain'

Imposed-displacement pattern helper (Phase SSI-3).

Emits one pattern Plain containing sp NODE DOF VALUE prescribed-displacement entries for every (node, dof) pair where the corresponding ux / uy / uz is non-None. Used for fault-slip kinematics, support-settlement scenarios, and any other prescribed-displacement driver.

STKO equivalent: pattern Plain N tsTag -fact F { sp NODE DOF VAL ... } from SSI/Interaccion y Falla/analysis_steps.tcl:22832-23253. Where STKO uses -fact F on the pattern, this helper folds the same scaling into the auto-created Linear(factor=F) time series — numerically identical, simpler API.

Parameters

pg, nodes XOR: exactly one of pg (physical-group name) or nodes (iterable of FEM node ids) must be supplied. ux, uy, uz Scalar broadcast: every targeted node gets the same prescribed displacement in this DOF. None (default) skips the DOF. At least one of the three must be set. pattern_factor Multiplier folded into the auto-created Linear time series. Default 1.0 (no scaling). Matches STKO's -fact F semantics: the actual applied displacement at simulation-time t is value × pattern_factor × t. series Optional explicit :class:TimeSeries to use. Must be already registered with the bridge. When supplied, pattern_factor is ignored — the user is in full control of the time-history shape.

Returns

Plain The registered :class:Plain pattern. This is a global (non-staged) pattern: it is valid only in a non-staged deck (global pattern + ops.analyze). Per ADR 0051 §5 a model may not mix a global pattern with stages — combining this with ops.stage(...) raises :class:BridgeError at build. For prescribed motion inside a staged deck, author the sp on a stage pattern instead (with s.pattern(series=...) as p: p.sp(...)).

Notes

Per-node-varying displacements are NOT supported in v1 — every targeted node gets the same scalar. For different values per node, call imposed_displacement multiple times with disjoint nodes= lists, or construct the Plain pattern manually via ops.pattern.Plain(...).

Source code in src/apeGmsh/opensees/apesees.py
def imposed_displacement(
    self,
    *,
    pg: str | None = None,
    nodes: Iterable[int] | None = None,
    ux: float | None = None,
    uy: float | None = None,
    uz: float | None = None,
    pattern_factor: float = 1.0,
    series: "TimeSeries | None" = None,
) -> "Plain":
    """Imposed-displacement pattern helper (Phase SSI-3).

    Emits one ``pattern Plain`` containing ``sp NODE DOF VALUE``
    prescribed-displacement entries for every (node, dof) pair
    where the corresponding ``ux`` / ``uy`` / ``uz`` is non-None.
    Used for fault-slip kinematics, support-settlement scenarios,
    and any other prescribed-displacement driver.

    STKO equivalent:
    ``pattern Plain N tsTag -fact F { sp NODE DOF VAL ... }``
    from ``SSI/Interaccion y Falla/analysis_steps.tcl:22832-23253``.
    Where STKO uses ``-fact F`` on the pattern, this helper folds
    the same scaling into the auto-created ``Linear(factor=F)``
    time series — numerically identical, simpler API.

    Parameters
    ----------
    pg, nodes
        XOR: exactly one of ``pg`` (physical-group name) or
        ``nodes`` (iterable of FEM node ids) must be supplied.
    ux, uy, uz
        Scalar broadcast: every targeted node gets the same
        prescribed displacement in this DOF.  ``None`` (default)
        skips the DOF.  At least one of the three must be set.
    pattern_factor
        Multiplier folded into the auto-created ``Linear`` time
        series.  Default ``1.0`` (no scaling).  Matches STKO's
        ``-fact F`` semantics: the actual applied displacement
        at simulation-time ``t`` is
        ``value × pattern_factor × t``.
    series
        Optional explicit :class:`TimeSeries` to use.  Must be
        already registered with the bridge.  When supplied,
        ``pattern_factor`` is ignored — the user is in full
        control of the time-history shape.

    Returns
    -------
    Plain
        The registered :class:`Plain` pattern.  This is a **global**
        (non-staged) pattern: it is valid only in a non-staged deck
        (global pattern + ``ops.analyze``).  Per ADR 0051 §5 a model
        may not mix a global pattern with stages — combining this
        with ``ops.stage(...)`` raises :class:`BridgeError` at build.
        For prescribed motion inside a staged deck, author the ``sp``
        on a stage pattern instead (``with s.pattern(series=...) as
        p: p.sp(...)``).

    Notes
    -----
    Per-node-varying displacements are NOT supported in v1 —
    every targeted node gets the same scalar.  For different
    values per node, call ``imposed_displacement`` multiple times
    with disjoint ``nodes=`` lists, or construct the ``Plain``
    pattern manually via ``ops.pattern.Plain(...)``.
    """
    if (pg is None) == (nodes is None):
        raise ValueError(
            "apeSees.imposed_displacement: supply exactly one of "
            f"pg= or nodes= (got pg={pg!r}, nodes={nodes!r})."
        )
    if ux is None and uy is None and uz is None:
        raise ValueError(
            "apeSees.imposed_displacement: at least one of ux / "
            "uy / uz must be supplied."
        )
    if pattern_factor == 0.0:
        raise ValueError(
            "apeSees.imposed_displacement: pattern_factor must be "
            "non-zero (a zero factor produces an inert pattern)."
        )

    # DOF-index validation against the model's ndf (red-team H3).
    # ``uz`` maps to DOF 3, which only exists on ndf>=3 models;
    # emitting ``sp NODE 3 VALUE`` on an ndf=2 model produces an
    # OpenSees parse error ("invalid dof").  Catch upfront with a
    # clear error pointing at the offending kwarg.
    if self._ndf is not None:
        dof_kwargs = (("ux", 1, ux), ("uy", 2, uy), ("uz", 3, uz))
        for kw, dof_idx, val in dof_kwargs:
            if val is not None and dof_idx > self._ndf:
                raise ValueError(
                    f"apeSees.imposed_displacement: {kw}= targets "
                    f"DOF {dof_idx}, but the model's ndf is "
                    f"{self._ndf}.  Drop {kw}= or call "
                    f"ops.model(..., ndf={dof_idx}) first."
                )

    # Default time series: Linear scaled by pattern_factor.
    # Folds STKO's ``-fact F`` semantics into the time-series
    # factor instead of an explicit ``-fact`` on the pattern
    # (apeGmsh's Plain pattern primitive doesn't carry one).
    if series is None:
        series = self.timeSeries.Linear(factor=float(pattern_factor))

    # Construct the Plain pattern via the namespace so it gets
    # registered + tagged.
    plain = self.pattern.Plain(series=series)
    # Populate the sp records.  Plain's recording API accepts
    # either pg= or node=; we route based on the helper's input.
    dof_values: tuple[tuple[int, float | None], ...] = (
        (1, ux), (2, uy), (3, uz),
    )
    with plain:
        if pg is not None:
            for dof, value in dof_values:
                if value is None:
                    continue
                plain.sp(pg=pg, dof=dof, value=float(value))
        else:
            assert nodes is not None
            for node in nodes:
                for dof, value in dof_values:
                    if value is None:
                        continue
                    plain.sp(node=int(node), dof=dof, value=float(value))
    return plain

stage

stage(name: str) -> '_StageBuilder'

Open a staged-analysis block (Phase SSI-2.A).

Nested with ops.stage(...) blocks are NOT supported — opening a second stage builder while another is still open raises RuntimeError. The lexical-vs-emit-order semantics would otherwise be confusing (the inner builder's exit fires first, registering the inner stage BEFORE the outer in _stage_records, which is the opposite of what readers expect).

Usage::

with ops.stage(name="insitu") as s:
    s.add(ops.initial_stress(name="rock", ..., ramp_steps=10))
    s.analysis(
        test=ops.test.NormDispIncr(tol=1e-4, max_iter=150),
        algorithm=ops.algorithm.Newton(),
        integrator=ops.integrator.LoadControl(dlam=0.1),
        constraints=ops.constraints.Plain(),
        numberer=ops.numberer.RCM(),
        system=ops.system.UmfPack(),
        analysis=ops.analysis.Static(),
    )
    s.run(n_increments=10, dt=0.1)

Each stage emits its own analysis-chain primitives, its own analyze loop (hook-wrapped if any s.add(initial_stress(...)) registered a ramp), and a between-stages cleanup block (loadConst -time 0.0 + wipeAnalysis + hook-list clear).

Multiple with ops.stage(...) blocks accumulate in registration order; they emit in that order at deck-emit time.

Validation happens on with exit: every stage must have a complete analysis chain (all six chain kwargs + the analysis directive) and an s.run(...) call.

Returns

_StageBuilder Context manager that collects per-stage records and emits a :class:StageRecord to the bridge on close.

Source code in src/apeGmsh/opensees/apesees.py
def stage(self, name: str) -> "_StageBuilder":
    """Open a staged-analysis block (Phase SSI-2.A).

    Nested ``with ops.stage(...)`` blocks are NOT supported —
    opening a second stage builder while another is still open
    raises ``RuntimeError``.  The lexical-vs-emit-order semantics
    would otherwise be confusing (the inner builder's __exit__
    fires first, registering the inner stage BEFORE the outer in
    ``_stage_records``, which is the opposite of what readers
    expect).

    Usage::

        with ops.stage(name="insitu") as s:
            s.add(ops.initial_stress(name="rock", ..., ramp_steps=10))
            s.analysis(
                test=ops.test.NormDispIncr(tol=1e-4, max_iter=150),
                algorithm=ops.algorithm.Newton(),
                integrator=ops.integrator.LoadControl(dlam=0.1),
                constraints=ops.constraints.Plain(),
                numberer=ops.numberer.RCM(),
                system=ops.system.UmfPack(),
                analysis=ops.analysis.Static(),
            )
            s.run(n_increments=10, dt=0.1)

    Each stage emits its own analysis-chain primitives, its own
    analyze loop (hook-wrapped if any ``s.add(initial_stress(...))``
    registered a ramp), and a between-stages cleanup block
    (``loadConst -time 0.0`` + ``wipeAnalysis`` + hook-list clear).

    Multiple ``with ops.stage(...)`` blocks accumulate in
    registration order; they emit in that order at deck-emit time.

    Validation happens on ``with`` exit: every stage must have a
    complete analysis chain (all six chain kwargs + the analysis
    directive) and an ``s.run(...)`` call.

    Returns
    -------
    _StageBuilder
        Context manager that collects per-stage records and emits
        a :class:`StageRecord` to the bridge on close.
    """
    if not name:
        raise ValueError("apeSees.stage: name= must be non-empty.")
    if self._open_stage_builder is not None:
        raise RuntimeError(
            "apeSees.stage: a stage is already open "
            f"(name={self._open_stage_builder._name!r}).  Close it "
            "before opening another — nested ``with ops.stage(...)``"
            " blocks would register stages in lexically-reversed "
            "order at emit time."
        )
    builder = _StageBuilder(self, str(name))
    self._open_stage_builder = builder
    return builder

region

region(*, name: str, pg: str | None = None, nodes: Iterable[int | Node] | None = None) -> None

Assign nodes to a named OpenSees Region.

Each name collects all nodes registered against it (across multiple calls, across explicit nodes= and pg= resolutions) and emits a single region $tag -node n1 n2 ... line at build time with a freshly allocated region tag. Useful for damping assignments and any future recorder that filters by region.

Exactly one of pg / nodes must be supplied; nodes accepts a mix of plain integer tags and :class:Node instances (matching :meth:fix / :meth:mass).

End users typically call this through :meth:Node.region or :meth:NodeSet.region rather than directly.

Source code in src/apeGmsh/opensees/apesees.py
def region(
    self,
    *,
    name: str,
    pg: str | None = None,
    nodes: Iterable[int | Node] | None = None,
) -> None:
    """Assign nodes to a named OpenSees Region.

    Each ``name`` collects all nodes registered against it
    (across multiple calls, across explicit ``nodes=`` and
    ``pg=`` resolutions) and emits a single
    ``region $tag -node n1 n2 ...`` line at build time with a
    freshly allocated region tag.  Useful for damping
    assignments and any future recorder that filters by region.

    Exactly one of ``pg`` / ``nodes`` must be supplied; ``nodes``
    accepts a mix of plain integer tags and :class:`Node`
    instances (matching :meth:`fix` / :meth:`mass`).

    End users typically call this through :meth:`Node.region` or
    :meth:`NodeSet.region` rather than directly.
    """
    if not name:
        raise ValueError("apeSees.region: name= must be non-empty.")
    if (pg is None) == (nodes is None):
        raise ValueError(
            "apeSees.region: 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._region_records.append(
        RegionAssignmentRecord(
            name=str(name), pg=pg, nodes=nodes_tuple,
        ),
    )

analyze

analyze(*, steps: int, dt: float | None = None, strategy: 'Ladder | None' = None, profile: str | None = None, profile_run: str | None = None, profile_deep: bool = False, profile_memory: bool = False, profile_per_step: bool = False) -> 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).

strategy (ADR 0057 Phase A) attaches a solution-strategy ladder to the analyze loop — on a failed increment the live runner escalates through the ladder's algorithm rungs (the declared chain algorithm is rung 0), restoring rung 0 after a rescue and logging escalations to the live emitter's strategy_events. Exhaustion returns the failing rc.

When profile is given, the live run is bracketed by the Ladruno fork's stack profiler: profiler start [flags] before the analyze loop and profiler report <profile> [-run profile_run] after, with profile_deep / profile_memory / profile_per_step toggling the start flags. Requires the fork build — the live emitter raises a clear error on stock openseespy. (Deck-mode profiling uses the explicit ops.profiler.* verbs instead, and does NOT consume the profile= kwargs here.)

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

Phase SSI-2.A: staged models (ops.stage(...) blocks declared) are NOT supported by live execution. Emit a Tcl or Py deck via :meth:tcl / :meth:py and run it via the OpenSees binary / openseespy subprocess instead.

Source code in src/apeGmsh/opensees/apesees.py
def analyze(
    self,
    *,
    steps: int,
    dt: float | None = None,
    strategy: "Ladder | None" = None,
    profile: str | None = None,
    profile_run: str | None = None,
    profile_deep: bool = False,
    profile_memory: bool = False,
    profile_per_step: bool = False,
) -> 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).

    ``strategy`` (ADR 0057 Phase A) attaches a solution-strategy
    ladder to the analyze loop — on a failed increment the live
    runner escalates through the ladder's algorithm rungs (the
    declared chain algorithm is rung 0), restoring rung 0 after a
    rescue and logging escalations to the live emitter's
    ``strategy_events``.  Exhaustion returns the failing rc.

    When ``profile`` is given, the live run is bracketed by the Ladruno
    fork's stack profiler: ``profiler start [flags]`` before the analyze
    loop and ``profiler report <profile> [-run profile_run]`` after,
    with ``profile_deep`` / ``profile_memory`` / ``profile_per_step``
    toggling the ``start`` flags. Requires the fork build — the live
    emitter raises a clear error on stock openseespy. (Deck-mode
    profiling uses the explicit ``ops.profiler.*`` verbs instead, and
    does NOT consume the ``profile=`` kwargs here.)

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

    Phase SSI-2.A: staged models (``ops.stage(...)`` blocks
    declared) are NOT supported by live execution.  Emit a Tcl
    or Py deck via :meth:`tcl` / :meth:`py` and run it via the
    OpenSees binary / openseespy subprocess instead.
    """
    if self._stage_records:
        raise NotImplementedError(
            "apeSees.analyze: live execution does not support "
            "staged models in Phase SSI-2.A "
            f"(got {len(self._stage_records)} stage(s)).  Use "
            "ops.tcl(path, run=True) or ops.py(path, run=True) to "
            "emit a staged deck and run it via the OpenSees binary "
            "/ openseespy subprocess instead."
        )
    self._check_analysis_chain_for_analyze()
    self._check_explicit_solver_compat()

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

    bm = self.build()
    self._assert_fork_if_required()
    live_emitter = LiveOpsEmitter(wipe=True)
    # Retain for post-run live queries (e.g. ladruno_projection_tie_force);
    # the in-process openseespy session stays alive after analyze returns.
    self._live_emitter = live_emitter
    bm.emit(live_emitter)
    if profile is not None:
        start_flags: list[str] = []
        if profile_deep:
            start_flags.append("-deep")
        if profile_memory:
            start_flags.append("-memory")
        if profile_per_step:
            start_flags.append("-perStep")
        live_emitter.profiler("start", *start_flags)
    spec: StrategySpec | None = None
    if strategy is not None:
        # Rung 0 = the flat chain's declared algorithm (the last
        # one registered wins, matching emission order).
        base = next(
            (p for p in reversed(self._primitives)
             if isinstance(p, SolutionAlgorithm)),
            None,
        )
        spec = strategy.to_spec(base=base)
    result: int = int(
        live_emitter.analyze(steps=steps, dt=dt, strategy=spec)
    )
    if profile is not None:
        report_args: list[str] = [profile]
        if profile_run is not None:
            report_args += ["-run", profile_run]
        live_emitter.profiler("report", *report_args)
    return result

ladruno_projection_tie_force

ladruno_projection_tie_force(node: int, dof: int) -> float

Tie force f = M(a_raw - a_proj) at (node, dof) from the last projection step (≈ LS-DYNA *DATABASE_NCFORC).

Recovers the interface force a non-matching equation-tied interface (g.constraints.tie(..., enforce="equation")) carries, via the fork ladrunoProjectionTieForce query (ADR-30 P3 / ADR 0068 P5). dof is 1-based (OpenSees convention).

Requires a prior live :meth:analyze with a LadrunoProjection constraint handler active. Fork-only: a stock build raises RuntimeError (see :data:~apeGmsh.opensees.emitter.live. _TIE_FORCE_FORK_REQUIRED).

For a recorded time history of the tie force instead of a single post-run value, use the recorder route: ops.recorder.Ladruno(nodal_responses=("constraintTieForce",)) and read it back with results.nodes.get(component="constraint_tie_force_x") (explicit analyses only — the recorder channel is scattered by the explicit CentralDifferenceLadruno integrator).

Source code in src/apeGmsh/opensees/apesees.py
def ladruno_projection_tie_force(self, node: int, dof: int) -> float:
    """Tie force ``f = M(a_raw - a_proj)`` at ``(node, dof)`` from the last
    projection step (≈ LS-DYNA ``*DATABASE_NCFORC``).

    Recovers the interface force a non-matching equation-tied interface
    (``g.constraints.tie(..., enforce="equation")``) carries, via the fork
    ``ladrunoProjectionTieForce`` query (ADR-30 P3 / ADR 0068 P5). ``dof``
    is 1-based (OpenSees convention).

    Requires a prior **live** :meth:`analyze` with a ``LadrunoProjection``
    constraint handler active. Fork-only: a stock build raises
    ``RuntimeError`` (see :data:`~apeGmsh.opensees.emitter.live.
    _TIE_FORCE_FORK_REQUIRED`).

    For a recorded **time history** of the tie force instead of a single
    post-run value, use the recorder route:
    ``ops.recorder.Ladruno(nodal_responses=("constraintTieForce",))`` and
    read it back with
    ``results.nodes.get(component="constraint_tie_force_x")`` (explicit
    analyses only — the recorder channel is scattered by the explicit
    ``CentralDifferenceLadruno`` integrator).
    """
    if self._live_emitter is None:
        raise BridgeError(
            "apeSees.ladruno_projection_tie_force: no live analysis has "
            "run. Call analyze(...) first (the live path); the query reads "
            "the last projection step. For a recorded time history, record "
            "ops.recorder.Ladruno(nodal_responses=('constraintTieForce',)) "
            "and read results.nodes.get(component='constraint_tie_force_x')."
        )
    return self._live_emitter.ladruno_projection_tie_force(node, dof)

ladruno_contact_force

ladruno_contact_force(node: int) -> float

Total normal contact-force magnitude on an NTS slave node.

The sum over that node's active master-segment pairs of tn = kn·<−gap>₊ (fork ladrunoContactForce, ADR-39 B3). Works in 2-D and 3-D. Requires a prior live :meth:analyze; fork-only.

Four limits, none of which the return value can tell you about — read them before using this number:

  • NTS lane only. It is fed exclusively from the segment / end-cap branch, so a mortar or rigid-plane slave always reads 0.0. Those lanes have no force query at all; recover their forces from reactions or the penalty-depth identity instead.
  • A magnitude, not a vector. Near a corner or the 2-D D4 end-cap the pair normal is not axis-aligned, so this does not equal any single global force component. The fork's own guide says so.
  • Zero is ambiguous. 0.0 means "not in contact" and "no contact engine in this domain". Call :meth:ladruno_contact_info and check total_contacts to tell them apart — not n_contacts, which counts the NTS lane only and reads 0 on a perfectly live mortar-only model.
  • A released 3-D pair reports its last-active force forever — a known, deferred fork defect (reproduced at f_query = 1000.0 against f_true = 0.0). The 2-D lane carries the fix.
Source code in src/apeGmsh/opensees/apesees.py
def ladruno_contact_force(self, node: int) -> float:
    """Total normal contact-force **magnitude** on an NTS slave node.

    The sum over that node's active master-segment pairs of
    ``tn = kn·<−gap>₊`` (fork ``ladrunoContactForce``, ADR-39 B3). Works in
    2-D and 3-D. Requires a prior **live** :meth:`analyze`; fork-only.

    Four limits, none of which the return value can tell you about — read
    them before using this number:

    * **NTS lane only.** It is fed exclusively from the segment / end-cap
      branch, so a **mortar** or **rigid-plane** slave always reads
      ``0.0``. Those lanes have no force query at all; recover their forces
      from reactions or the penalty-depth identity instead.
    * **A magnitude, not a vector.** Near a corner or the 2-D D4 end-cap
      the pair normal is not axis-aligned, so this does **not** equal any
      single global force component. The fork's own guide says so.
    * **Zero is ambiguous.** ``0.0`` means "not in contact" *and* "no
      contact engine in this domain". Call :meth:`ladruno_contact_info`
      and check ``total_contacts`` to tell them apart — **not**
      ``n_contacts``, which counts the NTS lane only and reads ``0`` on a
      perfectly live mortar-only model.
    * **A released 3-D pair reports its last-active force forever** — a
      known, deferred fork defect (reproduced at ``f_query = 1000.0``
      against ``f_true = 0.0``). The 2-D lane carries the fix.
    """
    return self._require_live_for_contact_query(
        "ladruno_contact_force").ladruno_contact_force(node)

ladruno_contact_info

ladruno_contact_info() -> 'ContactInfo'

Engine counters — (n_contacts, n_commits, n_reverts, n_mortar_contacts) (fork ladrunoContactInfo).

Mostly useful as the disambiguator for the other three queries: they all return 0.0 both for "nothing happening here" and for "no contact engine at all". Use info.total_contacts, the sum of the two lane counters — n_contacts and n_mortar_contacts are disjoint lanes, not a total and a subset, so a mortar-only model reports n_contacts == 0 with a live engine (measured on fork b17e8bd82). Requires a prior live :meth:analyze; fork-only.

Source code in src/apeGmsh/opensees/apesees.py
def ladruno_contact_info(self) -> "ContactInfo":
    """Engine counters — ``(n_contacts, n_commits, n_reverts,
    n_mortar_contacts)`` (fork ``ladrunoContactInfo``).

    Mostly useful as the disambiguator for the other three queries: they
    all return ``0.0`` both for "nothing happening here" and for "no
    contact engine at all". Use ``info.total_contacts``, the sum of the two
    lane counters — ``n_contacts`` and ``n_mortar_contacts`` are **disjoint
    lanes**, not a total and a subset, so a mortar-only model reports
    ``n_contacts == 0`` with a live engine (measured on fork
    ``b17e8bd82``). Requires a prior live :meth:`analyze`; fork-only.
    """
    return self._require_live_for_contact_query(
        "ladruno_contact_info").ladruno_contact_info()

ladruno_mortar_penetration

ladruno_mortar_penetration() -> float

Max KKT-active normal penetration over all mortar slave nodes (fork ladrunoMortarPenetration, ADR-41 C2.2).

A length, not a force — dimension-blind, and unaffected by the mortar thickness=. It is the mortar lane's ALM convergence measure: the quantity a held-load augmentation loop watches to decide it has augmented enough. 0.0 with no mortar contact. Requires a prior live :meth:analyze; fork-only.

Source code in src/apeGmsh/opensees/apesees.py
def ladruno_mortar_penetration(self) -> float:
    """Max KKT-active normal penetration over all mortar slave nodes
    (fork ``ladrunoMortarPenetration``, ADR-41 C2.2).

    A **length**, not a force — dimension-blind, and unaffected by the
    mortar ``thickness=``. It is the mortar lane's ALM convergence measure:
    the quantity a held-load augmentation loop watches to decide it has
    augmented enough. ``0.0`` with no mortar contact. Requires a prior live
    :meth:`analyze`; fork-only.
    """
    return self._require_live_for_contact_query(
        "ladruno_mortar_penetration").ladruno_mortar_penetration()

ladruno_mortar_tie_residual

ladruno_mortar_tie_residual() -> float

Max weighted relative-displacement bond residual over all mortar tie slave nodes (fork ladrunoMortarTieResidual, ADR-41 C4).

The tie's ALM convergence measure, the counterpart of :meth:ladruno_mortar_penetration for tie=True. 0.0 with no tie declared. Requires a prior live :meth:analyze; fork-only.

Source code in src/apeGmsh/opensees/apesees.py
def ladruno_mortar_tie_residual(self) -> float:
    """Max weighted relative-displacement bond residual over all mortar
    **tie** slave nodes (fork ``ladrunoMortarTieResidual``, ADR-41 C4).

    The tie's ALM convergence measure, the counterpart of
    :meth:`ladruno_mortar_penetration` for ``tie=True``. ``0.0`` with no
    tie declared. Requires a prior live :meth:`analyze`; fork-only.
    """
    return self._require_live_for_contact_query(
        "ladruno_mortar_tie_residual").ladruno_mortar_tie_residual()

eigen

eigen(num_modes: int, *, solver: str = '-genBandArpack') -> 'EigenResult'

Build + emit + run a one-shot eigen solve via the live emitter.

Builds a :class:BuiltModel, drives a :class:~apeGmsh.opensees.emitter.live.LiveOpsEmitter end-to- end (model + nodes + elements + bcs + mass), then issues the single eigen call and returns an :class:EigenResult carrying the eigenvalues plus a back-reference to the live emitter for lazy mode-shape access.

Unlike :meth:analyze, eigen does NOT require an analysis chain (constraints / numberer / system / test / algorithm / integrator / analysis): it only needs the assembled stiffness and mass matrices.

Partitioned models — serial-gather stopgap (ADR 0077 Tier 0). On a partition-authored model this runs the eigensolve serially on the full, gathered model in one process (the live emitter has supports_partitions = False): the modes are exact, but the whole model is assembled on one rank, so it does not scale the eigensolve. There is no distributed modal path yet — never run a bare eigen under OpenSeesMP (it solves each rank's LOCAL subdomain → wrong modes; ADR 0077 refuted v1). Distributed FEAST (ADR 0077 Tier 1) is gated on the classic-Tcl -feast unlock.

Parameters

num_modes Number of modes to compute. Must be >= 1. solver OpenSees eigen-solver flag, one of -genBandArpack (default), -symmBandLapack, -fullGenLapack, -frequency, -standard. Passed through verbatim to ops.eigen(solver, num_modes).

Returns

EigenResult Carries eigenvalues (λ_i = ω_i²) plus derived omega / freq / periods and a :meth:EigenResult.mode_shape accessor.

Raises

ValueError If num_modes < 1. NotImplementedError If the model has any registered stages — live execution of staged models is unsupported (Phase SSI-2.A).

Source code in src/apeGmsh/opensees/apesees.py
def eigen(
    self,
    num_modes: int,
    *,
    solver: str = "-genBandArpack",
) -> "EigenResult":
    """Build + emit + run a one-shot ``eigen`` solve via the live emitter.

    Builds a :class:`BuiltModel`, drives a
    :class:`~apeGmsh.opensees.emitter.live.LiveOpsEmitter` end-to-
    end (model + nodes + elements + bcs + mass), then issues the
    single ``eigen`` call and returns an :class:`EigenResult`
    carrying the eigenvalues plus a back-reference to the live
    emitter for lazy mode-shape access.

    Unlike :meth:`analyze`, ``eigen`` does NOT require an analysis
    chain (constraints / numberer / system / test / algorithm /
    integrator / analysis): it only needs the assembled stiffness
    and mass matrices.

    **Partitioned models — serial-gather stopgap (ADR 0077 Tier 0).**
    On a partition-authored model this runs the eigensolve *serially
    on the full, gathered model* in one process (the live emitter has
    ``supports_partitions = False``): the modes are exact, but the
    whole model is assembled on one rank, so it does **not** scale the
    eigensolve. There is no *distributed* modal path yet — never run a
    bare ``eigen`` under ``OpenSeesMP`` (it solves each rank's LOCAL
    subdomain → wrong modes; ADR 0077 refuted v1). Distributed FEAST
    (ADR 0077 Tier 1) is gated on the classic-Tcl ``-feast`` unlock.

    Parameters
    ----------
    num_modes
        Number of modes to compute. Must be ``>= 1``.
    solver
        OpenSees eigen-solver flag, one of ``-genBandArpack``
        (default), ``-symmBandLapack``, ``-fullGenLapack``,
        ``-frequency``, ``-standard``. Passed through verbatim to
        ``ops.eigen(solver, num_modes)``.

    Returns
    -------
    EigenResult
        Carries ``eigenvalues`` (``λ_i = ω_i²``) plus derived
        ``omega`` / ``freq`` / ``periods`` and a
        :meth:`EigenResult.mode_shape` accessor.

    Raises
    ------
    ValueError
        If ``num_modes < 1``.
    NotImplementedError
        If the model has any registered stages — live execution
        of staged models is unsupported (Phase SSI-2.A).
    """
    if num_modes < 1:
        raise ValueError(
            f"apeSees.eigen: num_modes must be >= 1, got {num_modes}."
        )
    if self._stage_records:
        raise NotImplementedError(
            "apeSees.eigen: live execution does not support staged "
            "models (Phase SSI-2.A) "
            f"(got {len(self._stage_records)} stage(s)).  Eigen "
            "analyses are typically run against an unstaged build; "
            "either drop the stage blocks or emit Tcl/Py and run "
            "the eigen command there."
        )

    # Local imports — keep openseespy + numpy out of bridge import
    # time for Tcl/Py/H5-only users.
    from .analysis.eigen import EigenResult
    from .emitter.live import LiveOpsEmitter
    import numpy as np

    bm = self.build()
    self._assert_fork_if_required()
    live_emitter = LiveOpsEmitter(wipe=True)
    bm.emit(live_emitter)
    values = live_emitter.eigen(num_modes, solver=solver)
    return EigenResult(
        eigenvalues=np.asarray(values, dtype=np.float64),
        _live=live_emitter,
    )

modal_properties

modal_properties(num_modes: int, *, solver: str = '-genBandArpack', unorm: bool = False) -> 'ModalPropertiesResult'

Build + emit + run eigen + modalProperties live.

Like :meth:eigen, drives a :class:~apeGmsh.opensees.emitter.live.LiveOpsEmitter end-to-end and needs no analysis chain; after the eigen solve it issues modalProperties -return (upstream DomainModalProperties) and wraps the returned dict in a :class:~apeGmsh.opensees.analysis.modal.ModalPropertiesResult carrying participation factors, modal masses, and mass ratios per mode and per global component.

The properties are also stored on the OpenSees Domain, which is the prerequisite state for the Ladruno fork's modal-response commands (fork ADR 44).

Partitioned models — serial-gather stopgap (ADR 0077 Tier 0). Runs serially on the full, gathered model (see :meth:eigen), so participation factors / effective modal mass are correct here. This is the only correct way to get modal properties on a partition-authored model today: the distributed path (ADR 0077 Tier 1) has no participation surface — upstream modalProperties is MPI-blind — so a distributed run would return wrong effective mass. It does not scale the eigensolve (whole model on one rank).

Parameters

num_modes Number of modes to compute. Must be >= 1. solver OpenSees eigen-solver flag, passed through verbatim (see :meth:eigen). Use -fullGenLapack on tiny models — ARPACK needs num_modes < n_dof. unorm Request the displacement-normalized eigenvector scaling (modalProperties -unorm).

Raises

ValueError If num_modes < 1. NotImplementedError If the model has any registered stages — live execution of staged models is unsupported (Phase SSI-2.A).

Source code in src/apeGmsh/opensees/apesees.py
def modal_properties(
    self,
    num_modes: int,
    *,
    solver: str = "-genBandArpack",
    unorm: bool = False,
) -> "ModalPropertiesResult":
    """Build + emit + run ``eigen`` + ``modalProperties`` live.

    Like :meth:`eigen`, drives a
    :class:`~apeGmsh.opensees.emitter.live.LiveOpsEmitter` end-to-end
    and needs no analysis chain; after the eigen solve it issues
    ``modalProperties -return`` (upstream ``DomainModalProperties``)
    and wraps the returned dict in a
    :class:`~apeGmsh.opensees.analysis.modal.ModalPropertiesResult`
    carrying participation factors, modal masses, and mass ratios
    per mode and per global component.

    The properties are also stored on the OpenSees Domain, which is
    the prerequisite state for the Ladruno fork's modal-response
    commands (fork ADR 44).

    **Partitioned models — serial-gather stopgap (ADR 0077 Tier 0).**
    Runs serially on the full, gathered model (see :meth:`eigen`), so
    participation factors / effective modal mass are **correct** here.
    This is the *only* correct way to get modal properties on a
    partition-authored model today: the distributed path (ADR 0077
    Tier 1) has no participation surface — upstream
    ``modalProperties`` is MPI-blind — so a distributed run would
    return wrong effective mass. It does not scale the eigensolve
    (whole model on one rank).

    Parameters
    ----------
    num_modes
        Number of modes to compute. Must be ``>= 1``.
    solver
        OpenSees eigen-solver flag, passed through verbatim (see
        :meth:`eigen`). Use ``-fullGenLapack`` on tiny models —
        ARPACK needs ``num_modes < n_dof``.
    unorm
        Request the displacement-normalized eigenvector scaling
        (``modalProperties -unorm``).

    Raises
    ------
    ValueError
        If ``num_modes < 1``.
    NotImplementedError
        If the model has any registered stages — live execution
        of staged models is unsupported (Phase SSI-2.A).
    """
    if num_modes < 1:
        raise ValueError(
            "apeSees.modal_properties: num_modes must be >= 1, "
            f"got {num_modes}."
        )
    if self._stage_records:
        raise NotImplementedError(
            "apeSees.modal_properties: live execution does not "
            "support staged models (Phase SSI-2.A) "
            f"(got {len(self._stage_records)} stage(s)).  Either "
            "drop the stage blocks or emit Tcl/Py and run the "
            "modalProperties command there."
        )

    # Local imports — keep openseespy + numpy out of bridge import
    # time for Tcl/Py/H5-only users.
    from .analysis.modal import ModalPropertiesResult
    from .emitter.live import LiveOpsEmitter
    import numpy as np

    bm = self.build()
    self._assert_fork_if_required()
    live_emitter = LiveOpsEmitter(wipe=True)
    bm.emit(live_emitter)
    values = live_emitter.eigen(num_modes, solver=solver)
    properties = live_emitter.modal_properties(unorm=unorm)
    return ModalPropertiesResult(
        eigenvalues=np.asarray(values, dtype=np.float64),
        properties=properties,
        _live=live_emitter,
    )

eigen_feast

eigen_feast(f_min: float, f_max: float, *, certify: bool = False) -> 'EigenResult'

Band-targeted FEAST eigensolve via the live emitter.

Fork-only (Ladruno ADR-43): eigen -feast fmin fmax returns all modes whose natural frequency lies in [f_min, f_max] Hz — the mode count is an output (len(result.eigenvalues)), not an input, which is why this is a separate method and not an :meth:eigen solver flag.

certify=True adds the fork's Sturm/inertia completeness certificate: the band content is independently counted via LDLᵀ inertia at the two band edges and the solve REFUSES on a mismatch with FEAST's count.

Parameters

f_min, f_max Frequency band in Hz; needs 0 <= f_min < f_max. certify Emit -certify (the completeness certificate).

Returns

EigenResult The standard eigen result (possibly zero modes if the band is empty) with lazy mode_shape access.

Source code in src/apeGmsh/opensees/apesees.py
def eigen_feast(
    self,
    f_min: float,
    f_max: float,
    *,
    certify: bool = False,
) -> "EigenResult":
    """Band-targeted FEAST eigensolve via the live emitter.

    **Fork-only** (Ladruno ADR-43): ``eigen -feast fmin fmax``
    returns **all** modes whose natural frequency lies in
    ``[f_min, f_max]`` Hz — the mode count is an output
    (``len(result.eigenvalues)``), not an input, which is why this
    is a separate method and not an :meth:`eigen` solver flag.

    ``certify=True`` adds the fork's Sturm/inertia completeness
    certificate: the band content is independently counted via
    LDLᵀ inertia at the two band edges and the solve REFUSES on a
    mismatch with FEAST's count.

    Parameters
    ----------
    f_min, f_max
        Frequency band in Hz; needs ``0 <= f_min < f_max``.
    certify
        Emit ``-certify`` (the completeness certificate).

    Returns
    -------
    EigenResult
        The standard eigen result (possibly zero modes if the band
        is empty) with lazy ``mode_shape`` access.
    """
    if not (0.0 <= f_min < f_max):
        raise ValueError(
            "apeSees.eigen_feast: need 0 <= f_min < f_max, got "
            f"f_min={f_min}, f_max={f_max}."
        )
    if self._stage_records:
        raise NotImplementedError(
            "apeSees.eigen_feast: live execution does not support "
            "staged models (Phase SSI-2.A) "
            f"(got {len(self._stage_records)} stage(s))."
        )
    # The stock ``ops.eigen`` symbol exists on every build, so the
    # missing-attribute gate cannot fire — pre-check the fork probe
    # for the friendly message (a pre-ADR-43 fork build still fails
    # with the OpenSees '-feast' parse error).
    if not self.capabilities().has_fork:
        raise RuntimeError(
            "apeSees.eigen_feast requires the Ladruno fork build of "
            "OpenSees (fork ADR-43 band-targeted FEAST eigensolver); "
            "the in-process openseespy is not the fork."
        )

    from .analysis.eigen import EigenResult
    from .emitter.live import LiveOpsEmitter
    import numpy as np

    bm = self.build()
    self._assert_fork_if_required()
    live_emitter = LiveOpsEmitter(wipe=True)
    bm.emit(live_emitter)
    values = live_emitter.eigen_feast(
        float(f_min), float(f_max), certify=certify,
    )
    return EigenResult(
        eigenvalues=np.asarray(values, dtype=np.float64),
        _live=live_emitter,
    )

complex_eigen

complex_eigen(num_modes: int, *, solver: str = '-genBandArpack', tol: float | None = None, closed_form: bool = False) -> 'ComplexEigenResult'

Complex / state-space modal analysis via the live emitter.

Fork-only (Ladruno ADR-46, complexEigen): true per-mode damping ratios ζ_k, damped frequencies ω_d,k, and phased mode shapes for non-classically damped models (localized dashpots, bearings, radiation damping). Builds + emits a fresh live domain, runs the real eigen (the projection basis), then complexEigen and parses the flat 7-per-mode return into a :class:ComplexEigenResult.

The default route projects the model's actual M and C (element getDamp()/getMass() + nodal mass/alphaM) — exactly the C a transient analysis feels. closed_form=True uses the fast global-Rayleigh diagonal closed form instead (refuses betaKinit/betaKcomm; blind to scoped Rayleigh).

Contract traps (fork guide): damping that does not flow through getDamp() is invisible (modalDamping, HHT-α numerical damping, elements whose -doRayleigh defaults OFF — the Truss/zeroLength families); the projection spans only the retained num_modes real modes; complex mode shapes are recorded via Node-recorder raw=("complexEigenRe<k>",) / Im<k> tokens, not carried on this result.

Parameters

num_modes Real modes to extract as the projection basis (retain enough to cover the band of interest; -fullGenLapack on tiny models). tol Optional residual tolerance (fork default 1e-8). closed_form Use the closed-form Rayleigh route (Route A).

Source code in src/apeGmsh/opensees/apesees.py
def complex_eigen(
    self,
    num_modes: int,
    *,
    solver: str = "-genBandArpack",
    tol: float | None = None,
    closed_form: bool = False,
) -> "ComplexEigenResult":
    """Complex / state-space modal analysis via the live emitter.

    **Fork-only** (Ladruno ADR-46, ``complexEigen``): true per-mode
    damping ratios ζ_k, damped frequencies ω_d,k, and phased mode
    shapes for **non-classically damped** models (localized
    dashpots, bearings, radiation damping).  Builds + emits a fresh
    live domain, runs the real ``eigen`` (the projection basis),
    then ``complexEigen`` and parses the flat 7-per-mode return
    into a :class:`ComplexEigenResult`.

    The default route projects the model's **actual** M and C
    (element ``getDamp()``/``getMass()`` + nodal mass/``alphaM``) —
    exactly the C a transient analysis feels.  ``closed_form=True``
    uses the fast global-Rayleigh diagonal closed form instead
    (refuses ``betaKinit``/``betaKcomm``; blind to scoped
    Rayleigh).

    Contract traps (fork guide): damping that does not flow through
    ``getDamp()`` is invisible (``modalDamping``, HHT-α numerical
    damping, elements whose ``-doRayleigh`` defaults OFF — the
    ``Truss``/``zeroLength`` families); the projection spans only
    the retained ``num_modes`` real modes; complex mode shapes are
    recorded via Node-recorder ``raw=("complexEigenRe<k>",)`` /
    ``Im<k>`` tokens, not carried on this result.

    Parameters
    ----------
    num_modes
        Real modes to extract as the projection basis (retain
        enough to cover the band of interest;
        ``-fullGenLapack`` on tiny models).
    tol
        Optional residual tolerance (fork default 1e-8).
    closed_form
        Use the closed-form Rayleigh route (Route A).
    """
    context = "apeSees.complex_eigen"
    self._modal_prereqs_and_guards(num_modes, context=context)
    # Fork guide trap #4: the eigenvector distribution to
    # MP-constrained slave DOFs needs a distributing constraint
    # handler — ``constraints Plain`` + MP constraints yields wrong
    # complex shapes. The bridge-driven eigen path defaults to
    # Transformation when no handler is declared; warn when the
    # user declared Plain.
    from .analysis.constraint_handler import Plain as _PlainHandler

    if any(isinstance(p, _PlainHandler) for p in self._primitives):
        import warnings

        warnings.warn(
            f"{context}: 'constraints Plain' is declared — on a "
            "model with MP constraints (rigid links, equalDOF, "
            "embedded, ...) complexEigen mode shapes need a "
            "distributing handler (Transformation).",
            UserWarning,
            stacklevel=2,
        )

    from .analysis.complex_eigen import ComplexEigenResult
    from .emitter.live import LiveOpsEmitter

    args: list[int | float | str] = []
    if tol is not None:
        if tol <= 0.0:
            raise ValueError(
                f"{context}: tol must be > 0, got {tol}."
            )
        args.extend(("-tol", float(tol)))
    if closed_form:
        args.append("-closedForm")

    bm = self.build()
    self._assert_fork_if_required()
    live_emitter = LiveOpsEmitter(wipe=True)
    bm.emit(live_emitter)
    live_emitter.eigen(num_modes, solver=solver)
    values = live_emitter.complex_eigen(*args)
    return ComplexEigenResult.from_flat(values)

modal_response_history

modal_response_history(*, dt: float, n_steps: int, num_modes: int, base_accel: 'TimeSeries | str | None' = None, direction: int | None = None, load: 'Plain | str | None' = None, series: 'TimeSeries | str | None' = None, damp: float | None = None, rayleigh: tuple[float, float] | None = None, modal_damp: Sequence[float] | None = None, modes: Sequence[int] | None = None, t0: float = 0.0, solver: str = '-genBandArpack') -> 'ModalHistoryResult'

Run the fork's exact modal-superposition transient live.

Fork-only (Ladruno ADR-44 P1a, modalResponseHistory). Builds + emits a fresh live domain, issues eigen + modalProperties, then advances each retained mode by the closed-form piecewise-linear recurrence — no iteration, no factorization. One domain step is committed per station (t0 … t0 + n_steps·dt), so every recorder declared on the model captures the history exactly as in a direct run.

Linear models only — superposition is invalid under any material or geometric nonlinearity (use analyze then).

Parameters

dt, n_steps Time step and station count (n_steps + 1 commits). num_modes Modes to extract for the superposition basis (retain enough to cover the band of interest). base_accel, direction Ground-acceleration channel: a registered ops.timeSeries.* handle (or name) sampled at the stations, plus the global excitation direction (1-based). Response is relative to the moving base. Make the record extend at least one sample past t0 + n_steps·dt. load, series Nodal-force channel P(t) = s(t)·P: an ops.pattern.Plain handle (or name) whose plain nodal loads give the reference shape P (the pattern's own timeSeries is IGNORED by the fork), and the scalar s(t) timeSeries. Response is absolute. Mutually exclusive with the base-acceleration channel. damp, rayleigh, modal_damp Exactly one damping channel (ADR 0075): uniform ratio / Rayleigh (a0, a1) / per-mode ratios. modes Optional 1-based subset of the extracted modes. t0 Start time (base accel sampled at t0 + k·dt). solver Eigen-solver flag (-fullGenLapack on tiny models).

Source code in src/apeGmsh/opensees/apesees.py
def modal_response_history(
    self,
    *,
    dt: float,
    n_steps: int,
    num_modes: int,
    base_accel: "TimeSeries | str | None" = None,
    direction: int | None = None,
    load: "Plain | str | None" = None,
    series: "TimeSeries | str | None" = None,
    damp: float | None = None,
    rayleigh: tuple[float, float] | None = None,
    modal_damp: Sequence[float] | None = None,
    modes: Sequence[int] | None = None,
    t0: float = 0.0,
    solver: str = "-genBandArpack",
) -> "ModalHistoryResult":
    """Run the fork's exact modal-superposition transient live.

    **Fork-only** (Ladruno ADR-44 P1a, ``modalResponseHistory``).
    Builds + emits a fresh live domain, issues ``eigen`` +
    ``modalProperties``, then advances each retained mode by the
    closed-form piecewise-linear recurrence — no iteration, no
    factorization.  One domain step is **committed per station**
    (``t0 … t0 + n_steps·dt``), so every recorder declared on the
    model captures the history exactly as in a direct run.

    Linear models only — superposition is invalid under any
    material or geometric nonlinearity (use ``analyze`` then).

    Parameters
    ----------
    dt, n_steps
        Time step and station count (``n_steps + 1`` commits).
    num_modes
        Modes to extract for the superposition basis (retain
        enough to cover the band of interest).
    base_accel, direction
        Ground-acceleration channel: a registered
        ``ops.timeSeries.*`` handle (or name) sampled at the
        stations, plus the global excitation direction (1-based).
        Response is **relative** to the moving base.  Make the
        record extend at least one sample past ``t0 + n_steps·dt``.
    load, series
        Nodal-force channel ``P(t) = s(t)·P``: an
        ``ops.pattern.Plain`` handle (or name) whose plain nodal
        loads give the reference shape ``P`` (the pattern's own
        timeSeries is IGNORED by the fork), and the scalar
        ``s(t)`` timeSeries.  Response is **absolute**.  Mutually
        exclusive with the base-acceleration channel.
    damp, rayleigh, modal_damp
        Exactly one damping channel (ADR 0075): uniform ratio /
        Rayleigh ``(a0, a1)`` / per-mode ratios.
    modes
        Optional 1-based subset of the extracted modes.
    t0
        Start time (base accel sampled at ``t0 + k·dt``).
    solver
        Eigen-solver flag (``-fullGenLapack`` on tiny models).
    """
    from .analysis.modal import (
        ModalHistoryResult,
        _damping_channel_args,
    )
    from .emitter.live import LiveOpsEmitter
    from .pattern.pattern import Plain as _Plain
    import numpy as np

    context = "apeSees.modal_response_history"
    self._modal_prereqs_and_guards(num_modes, context=context)
    if dt <= 0.0 or n_steps < 1:
        raise ValueError(
            f"{context}: dt must be > 0 and n_steps >= 1, got "
            f"dt={dt}, n_steps={n_steps}."
        )
    damping_args = _damping_channel_args(
        damp=damp, rayleigh=rayleigh, modal_damp=modal_damp,
        context=context,
    )
    excitation = self._resolve_modal_excitation(
        base_accel=base_accel, direction=direction,
        load=load, series=series, context=context,
        plain_cls=_Plain, series_required=True,
    )

    args: list[int | float | str] = [
        "-dt", float(dt), "-nsteps", int(n_steps),
    ]
    if t0 != 0.0:
        args.extend(("-t0", float(t0)))
    args.extend(excitation)
    args.extend(damping_args)
    if modes is not None:
        mode_list = [int(m) for m in modes]
        if not mode_list or any(m < 1 for m in mode_list):
            raise ValueError(
                f"{context}: modes must be 1-based mode numbers, "
                f"got {modes!r}."
            )
        args.extend(("-modes", *mode_list))

    bm = self.build()
    self._assert_fork_if_required()
    live_emitter = LiveOpsEmitter(wipe=True)
    bm.emit(live_emitter)
    values = live_emitter.eigen(num_modes, solver=solver)
    live_emitter.modal_properties()
    live_emitter.modal_response_history(*args)
    return ModalHistoryResult(
        eigenvalues=np.asarray(values, dtype=np.float64),
        dt=float(dt),
        n_steps=int(n_steps),
        _live=live_emitter,
    )

response_spectrum_analysis

response_spectrum_analysis(direction: int, *, periods: Sequence[float], accels: Sequence[float], combine: str, num_modes: int, damp: float | None = None, modal_damp: Sequence[float] | None = None, solver: str = '-genBandArpack') -> 'ResponseSpectrumResult'

Run a response-spectrum analysis with native combination.

Fork-only (Ladruno ADR-44 P1b): the -combine stage on responseSpectrumAnalysis. Builds + emits a fresh live domain, issues eigen + modalProperties, computes the per-mode modal displacements against the (periods, accels) design spectrum, and commits the combined nodal design displacement field, read back via :meth:ResponseSpectrumResult.node_disp.

Combination is per-quantity and nonlinear — do NOT derive combined element forces / drifts from the combined displacements (combine those quantities' own per-mode peaks instead).

Parameters

direction Global excitation direction (1-based). periods, accels The design spectrum Sa(Tn) as parallel lists. periods must be non-negative and strictly increasing; a leading T = 0 PGA anchor is legal (the fork clamps T <= Tn[0] to Sa[0]). combine "SRSS" | "CQC" | "ABS" | "TenPercent". CQC and TenPercent weight closely-spaced modes; CQC requires a damping channel. num_modes Modes to extract; the combination spans all of them (-combine and -mode are mutually exclusive — the bridge never emits -mode). damp, modal_damp Optional damping channel (uniform ratio or per-mode). Required for CQC.

Source code in src/apeGmsh/opensees/apesees.py
def response_spectrum_analysis(
    self,
    direction: int,
    *,
    periods: Sequence[float],
    accels: Sequence[float],
    combine: str,
    num_modes: int,
    damp: float | None = None,
    modal_damp: Sequence[float] | None = None,
    solver: str = "-genBandArpack",
) -> "ResponseSpectrumResult":
    """Run a response-spectrum analysis with native combination.

    **Fork-only** (Ladruno ADR-44 P1b): the ``-combine`` stage on
    ``responseSpectrumAnalysis``.  Builds + emits a fresh live
    domain, issues ``eigen`` + ``modalProperties``, computes the
    per-mode modal displacements against the ``(periods, accels)``
    design spectrum, and commits the **combined** nodal design
    displacement field, read back via
    :meth:`ResponseSpectrumResult.node_disp`.

    Combination is per-quantity and nonlinear — do NOT derive
    combined element forces / drifts from the combined
    displacements (combine those quantities' own per-mode peaks
    instead).

    Parameters
    ----------
    direction
        Global excitation direction (1-based).
    periods, accels
        The design spectrum ``Sa(Tn)`` as parallel lists.
        ``periods`` must be non-negative and strictly increasing;
        a leading ``T = 0`` PGA anchor is legal (the fork clamps
        ``T <= Tn[0]`` to ``Sa[0]``).
    combine
        ``"SRSS"`` | ``"CQC"`` | ``"ABS"`` | ``"TenPercent"``.
        CQC and TenPercent weight closely-spaced modes; CQC
        requires a damping channel.
    num_modes
        Modes to extract; the combination spans all of them
        (``-combine`` and ``-mode`` are mutually exclusive — the
        bridge never emits ``-mode``).
    damp, modal_damp
        Optional damping channel (uniform ratio or per-mode).
        Required for ``CQC``.
    """
    from .analysis.modal import (
        ResponseSpectrumResult,
        _damping_channel_args,
    )
    from .emitter.live import LiveOpsEmitter
    import numpy as np

    context = "apeSees.response_spectrum_analysis"
    self._modal_prereqs_and_guards(num_modes, context=context)
    if int(direction) < 1:
        raise ValueError(
            f"{context}: direction is 1-based, got {direction}."
        )
    rules = ("SRSS", "CQC", "ABS", "TenPercent")
    if combine not in rules:
        raise ValueError(
            f"{context}: combine must be one of {rules}, got "
            f"{combine!r}."
        )
    tn = [float(t) for t in periods]
    sa = [float(a) for a in accels]
    if len(tn) != len(sa) or not tn:
        raise ValueError(
            f"{context}: periods and accels must be equal-length "
            f"non-empty lists, got {len(tn)} periods / {len(sa)} "
            "accels."
        )
    if any(t < 0.0 for t in tn) or any(
        b <= a for a, b in zip(tn, tn[1:])
    ):
        raise ValueError(
            f"{context}: periods must be non-negative and strictly "
            "increasing (the fork refuses negative Tn; a leading "
            "T=0 PGA anchor is legal)."
        )
    if combine == "CQC" and damp is None and modal_damp is None:
        raise ValueError(
            f"{context}: CQC needs a damping channel — pass damp= "
            "or modal_damp=."
        )
    damping_args: tuple[float | str, ...] = ()
    if damp is not None or modal_damp is not None:
        damping_args = _damping_channel_args(
            damp=damp, rayleigh=None, modal_damp=modal_damp,
            context=context,
        )

    args: list[int | float | str] = ["-Tn", *tn, "-Sa", *sa]
    args.extend(("-combine", combine))
    args.extend(damping_args)

    bm = self.build()
    self._assert_fork_if_required()
    live_emitter = LiveOpsEmitter(wipe=True)
    bm.emit(live_emitter)
    values = live_emitter.eigen(num_modes, solver=solver)
    live_emitter.modal_properties()
    live_emitter.response_spectrum_analysis(int(direction), *args)
    return ResponseSpectrumResult(
        eigenvalues=np.asarray(values, dtype=np.float64),
        combine=combine,
        _live=live_emitter,
    )

frequency_response

frequency_response(*, f_min: float, f_max: float, n_freq: int, node: 'int | Node', dof: int, num_modes: int, grid: str = 'lin', base_accel_dir: int | None = None, load: 'Plain | str | None' = None, amp: float = 1.0, damp: float | None = None, rayleigh: tuple[float, float] | None = None, modal_damp: Sequence[float] | None = None, resp: str = 'disp', modes: Sequence[int] | None = None, out: str | None = None, solver: str = '-genBandArpack') -> 'FrequencyResponseResult'

Compute the complex modal FRF of one response DOF, live.

Fork-only (Ladruno ADR-44 P2, frequencyResponse): for a harmonic excitation amp·e^{iΩt} the steady response is a dense post-processor on the mode basis — no time stepping. Returns a :class:FrequencyResponseResult (frequencies in Hz + complex FRF).

Excitation: base_accel_dir= for uniform harmonic base acceleration along a global direction (no timeSeries — the sweep is per amp; relative response) XOR load= for harmonic nodal forces amp·P·e^{iΩt} from a plain-nodal-load pattern (absolute response).

grid: "lin" / "log" / "biased" — biased adds a ±5 % cluster around each in-band modal frequency so sharp low-damping peaks are not stepped over.

resp: "disp" | "vel" (iΩ·û) | "accel" (−Ω²·û). out= additionally writes the table to an ASCII file.

Source code in src/apeGmsh/opensees/apesees.py
def frequency_response(
    self,
    *,
    f_min: float,
    f_max: float,
    n_freq: int,
    node: "int | Node",
    dof: int,
    num_modes: int,
    grid: str = "lin",
    base_accel_dir: int | None = None,
    load: "Plain | str | None" = None,
    amp: float = 1.0,
    damp: float | None = None,
    rayleigh: tuple[float, float] | None = None,
    modal_damp: Sequence[float] | None = None,
    resp: str = "disp",
    modes: Sequence[int] | None = None,
    out: str | None = None,
    solver: str = "-genBandArpack",
) -> "FrequencyResponseResult":
    """Compute the complex modal FRF of one response DOF, live.

    **Fork-only** (Ladruno ADR-44 P2, ``frequencyResponse``): for a
    harmonic excitation ``amp·e^{iΩt}`` the steady response is a
    dense post-processor on the mode basis — no time stepping.
    Returns a :class:`FrequencyResponseResult` (frequencies in Hz
    + complex FRF).

    Excitation: ``base_accel_dir=`` for uniform harmonic base
    acceleration along a global direction (no timeSeries — the
    sweep is per ``amp``; **relative** response) XOR ``load=`` for
    harmonic nodal forces ``amp·P·e^{iΩt}`` from a plain-nodal-load
    pattern (**absolute** response).

    ``grid``: ``"lin"`` / ``"log"`` / ``"biased"`` — biased adds a
    ±5 % cluster around each in-band modal frequency so sharp
    low-damping peaks are not stepped over.

    ``resp``: ``"disp"`` | ``"vel"`` (``iΩ·û``) | ``"accel"``
    (``−Ω²·û``).  ``out=`` additionally writes the table to an
    ASCII file.
    """
    rows = self._run_modal_sweep(
        command="frequency_response",
        context="apeSees.frequency_response",
        f_min=f_min, f_max=f_max, n_freq=n_freq,
        node=node, dof=dof, num_modes=num_modes, grid=grid,
        base_accel_dir=base_accel_dir, load=load, amp=amp,
        damp=damp, rayleigh=rayleigh, modal_damp=modal_damp,
        resp=resp, modes=modes, out=out, solver=solver,
    )
    from .analysis.modal import FrequencyResponseResult
    import numpy as np

    table = np.asarray(rows, dtype=np.float64)
    return FrequencyResponseResult(
        freq=table[:, 0],
        response=table[:, 1] + 1j * table[:, 2],
    )

steady_state_dynamics

steady_state_dynamics(*, f_min: float, f_max: float, n_freq: int, node: 'int | Node', dof: int, num_modes: int, grid: str = 'lin', base_accel_dir: int | None = None, load: 'Plain | str | None' = None, amp: float = 1.0, damp: float | None = None, rayleigh: tuple[float, float] | None = None, modal_damp: Sequence[float] | None = None, resp: str = 'disp', modes: Sequence[int] | None = None, out: str | None = None, solver: str = '-genBandArpack') -> 'SteadyStateResult'

Steady-state harmonic response amplitude |response| per sweep frequency — the magnitude companion of :meth:frequency_response (same flags, fork ADR-44 P2).

Source code in src/apeGmsh/opensees/apesees.py
def steady_state_dynamics(
    self,
    *,
    f_min: float,
    f_max: float,
    n_freq: int,
    node: "int | Node",
    dof: int,
    num_modes: int,
    grid: str = "lin",
    base_accel_dir: int | None = None,
    load: "Plain | str | None" = None,
    amp: float = 1.0,
    damp: float | None = None,
    rayleigh: tuple[float, float] | None = None,
    modal_damp: Sequence[float] | None = None,
    resp: str = "disp",
    modes: Sequence[int] | None = None,
    out: str | None = None,
    solver: str = "-genBandArpack",
) -> "SteadyStateResult":
    """Steady-state harmonic response amplitude ``|response|`` per
    sweep frequency — the magnitude companion of
    :meth:`frequency_response` (same flags, fork ADR-44 P2)."""
    rows = self._run_modal_sweep(
        command="steady_state_dynamics",
        context="apeSees.steady_state_dynamics",
        f_min=f_min, f_max=f_max, n_freq=n_freq,
        node=node, dof=dof, num_modes=num_modes, grid=grid,
        base_accel_dir=base_accel_dir, load=load, amp=amp,
        damp=damp, rayleigh=rayleigh, modal_damp=modal_damp,
        resp=resp, modes=modes, out=out, solver=solver,
    )
    from .analysis.modal import SteadyStateResult
    import numpy as np

    table = np.asarray(rows, dtype=np.float64)
    return SteadyStateResult(
        freq=table[:, 0], magnitude=table[:, 1],
    )

random_response

random_response(*, f_min: float, f_max: float, n_freq: int, node: 'int | Node', dof: int, num_modes: int, input_psd: 'TimeSeries | str', grid: str = 'biased', base_accel_dir: int | None = None, load: 'Plain | str | None' = None, damp: float | None = None, rayleigh: tuple[float, float] | None = None, modal_damp: Sequence[float] | None = None, resp: str = 'disp', modes: Sequence[int] | None = None, stats: bool = False, duration: float | None = None, out: str | None = None, solver: str = '-genBandArpack') -> 'RandomResponseResult'

Stationary random response RMS on the modal FRF, live.

Fork-only (Ladruno ADR-44 P3, randomResponse): input_psd is a one-sided PSD G(f) in Hz ((excitation)²/ Hz), supplied as a registered timeSeries sampled at f in Hz (Path with f→G breakpoints, Constant for white noise). With base_accel_dir= it is the base-acceleration PSD; with load= the PSD of the scalar multiplying the pattern's nodal-load shape (fully correlated).

grid defaults to "biased" — the RMS is a band integral and a linear grid mis-integrates sharp resonances (fork guide P3). The band [f_min, f_max] must cover the input's support and every resonance carrying response power; the fork refuses zero-damped in-band modes and a rigid-body mode with f_min = 0.

stats= adds ν₀ (mean zero-upcrossing rate, Hz) and the spectral moments m0 / m2; duration= additionally appends the Davenport expected peak over that exposure.

Source code in src/apeGmsh/opensees/apesees.py
def random_response(
    self,
    *,
    f_min: float,
    f_max: float,
    n_freq: int,
    node: "int | Node",
    dof: int,
    num_modes: int,
    input_psd: "TimeSeries | str",
    grid: str = "biased",
    base_accel_dir: int | None = None,
    load: "Plain | str | None" = None,
    damp: float | None = None,
    rayleigh: tuple[float, float] | None = None,
    modal_damp: Sequence[float] | None = None,
    resp: str = "disp",
    modes: Sequence[int] | None = None,
    stats: bool = False,
    duration: float | None = None,
    out: str | None = None,
    solver: str = "-genBandArpack",
) -> "RandomResponseResult":
    """Stationary random response RMS on the modal FRF, live.

    **Fork-only** (Ladruno ADR-44 P3, ``randomResponse``):
    ``input_psd`` is a **one-sided PSD G(f) in Hz** ((excitation)²/
    Hz), supplied as a registered timeSeries sampled at ``f`` in Hz
    (``Path`` with f→G breakpoints, ``Constant`` for white noise).
    With ``base_accel_dir=`` it is the base-acceleration PSD; with
    ``load=`` the PSD of the scalar multiplying the pattern's
    nodal-load shape (fully correlated).

    ``grid`` defaults to ``"biased"`` — the RMS is a band integral
    and a linear grid mis-integrates sharp resonances (fork guide
    P3).  The band ``[f_min, f_max]`` must cover the input's
    support and every resonance carrying response power; the fork
    refuses zero-damped in-band modes and a rigid-body mode with
    ``f_min = 0``.

    ``stats=`` adds ``ν₀`` (mean zero-upcrossing rate, Hz) and the
    spectral moments ``m0`` / ``m2``; ``duration=`` additionally
    appends the Davenport expected peak over that exposure.
    """
    from .analysis.modal import RandomResponseResult

    context = "apeSees.random_response"
    ts = self._resolve(input_psd, base=TimeSeries)
    if not isinstance(ts, TimeSeries):
        raise TypeError(
            f"{context}: input_psd= needs an ops.timeSeries.* "
            f"handle (or registered name), got {type(ts).__name__}."
        )
    psd_tag = self.tag_for(ts)
    if psd_tag is None:
        raise BridgeError(
            f"{context}: the input_psd timeSeries is not "
            "registered on this bridge — create it via "
            "ops.timeSeries.<Type>(...)."
        )
    if duration is not None and duration <= 0.0:
        raise ValueError(
            f"{context}: duration must be > 0, got {duration}."
        )
    extra: list[int | float | str] = ["-inputPSD", int(psd_tag)]
    if stats or duration is not None:
        extra.append("-stats")
    if duration is not None:
        extra.extend(("-duration", float(duration)))

    raw = self._run_modal_sweep(
        command="random_response",
        context=context,
        f_min=f_min, f_max=f_max, n_freq=n_freq,
        node=node, dof=dof, num_modes=num_modes, grid=grid,
        base_accel_dir=base_accel_dir, load=load, amp=None,
        damp=damp, rayleigh=rayleigh, modal_damp=modal_damp,
        resp=resp, modes=modes, out=out, solver=solver,
        extra_args=tuple(extra),
    )
    if isinstance(raw, (int, float)):
        return RandomResponseResult(rms=float(raw))
    values = [float(v) for v in raw]
    peak = values[4] if len(values) > 4 else None
    return RandomResponseResult(
        rms=values[0], nu0=values[1], m0=values[2], m2=values[3],
        peak=peak,
    )

critical_time_step

critical_time_step() -> float

Query the active explicit integrator's critical time step dt_cr.

Fork-only (Ladruno): builds + emits a throwaway live model (like :meth:eigen), primes one tiny step to trigger the integrator's dt_cr computation, then returns the usable (Noh-Bathe) limit.

Requires a complete analysis chain with an explicit integrator constructed with cfl=True (e.g. ops.integrator.ExplicitBathe(cfl=True)), a Transient analysis, and element mass density (-rho / -mass) — the dt_cr eigensolve uses element mass+stiffness, not ops.mass nodal mass.

Raises

BridgeError If the analysis chain is incomplete. NotImplementedError If the model has registered stages (live execution of staged models is unsupported — emit Tcl/Py instead). ValueError If dt_cr is not usable (no cfl flag, a non-explicit integrator, or a pure nodal-mass model).

Source code in src/apeGmsh/opensees/apesees.py
def critical_time_step(self) -> float:
    """Query the active explicit integrator's critical time step ``dt_cr``.

    **Fork-only** (Ladruno): builds + emits a throwaway live model
    (like :meth:`eigen`), primes one tiny step to trigger the
    integrator's ``dt_cr`` computation, then returns the usable
    (Noh-Bathe) limit.

    Requires a complete analysis chain with an **explicit**
    integrator constructed with ``cfl=True`` (e.g.
    ``ops.integrator.ExplicitBathe(cfl=True)``), a ``Transient``
    analysis, and **element mass density** (``-rho`` / ``-mass``) —
    the ``dt_cr`` eigensolve uses element mass+stiffness, not
    ``ops.mass`` nodal mass.

    Raises
    ------
    BridgeError
        If the analysis chain is incomplete.
    NotImplementedError
        If the model has registered stages (live execution of staged
        models is unsupported — emit Tcl/Py instead).
    ValueError
        If ``dt_cr`` is not usable (no ``cfl`` flag, a non-explicit
        integrator, or a pure nodal-mass model).
    """
    if self._stage_records:
        raise NotImplementedError(
            "apeSees.critical_time_step: live execution does not "
            "support staged models "
            f"(got {len(self._stage_records)} stage(s)). Emit Tcl/Py "
            "and query criticalTimeStep() there instead."
        )
    self._check_analysis_chain_for_analyze()
    self._check_explicit_solver_compat()

    from .emitter.live import LiveOpsEmitter

    bm = self.build()
    self._assert_fork_if_required()
    live_emitter = LiveOpsEmitter(wipe=True)
    bm.emit(live_emitter)
    # Prime one negligible step so the integrator computes dt_cr.
    live_emitter.analyze(steps=1, dt=_DTCR_PRIME_DT)
    return _dtcr_or_raise(live_emitter.critical_time_step())

analyze_explicit

analyze_explicit(*, duration: float, safety: float = 0.9, dt_max: float | None = None) -> 'ExplicitRunResult'

Run an explicit transient over duration, auto-sized to dt_cr.

Fork-only (Ladruno) driver implementing the explicit-dynamics sub-stepping recipe (ADR D5): build + emit, prime one tiny step, query the critical time step, then integrate duration in n = ceil(duration / (safety * dt_cr)) equal sub-steps via a single analyze(n, duration / n).

.. warning:: dt_cr is queried once, on the initial stiffness. For a model whose tangent stiffens mid-run (contact closing, geometric / material stiffening) the true critical step shrinks and a fixed dt can go supercritical and diverge. Guard such runs by constructing the integrator with cfl_abort=True (and recompute=N) so a recomputed CFL violation aborts the run — this method then re-raises that abort as an error rather than returning silently. A one-shot run with an unguarded integrator emits :class:OpenSeesExplicitSolverWarning.

Parameters

duration Total physical time to integrate (> 0). safety Fraction of dt_cr used as the step (0 < safety <= 1; default 0.9). Scales the value criticalTimeStep() returns — do not re-base it on any larger Noh-Bathe bound. dt_max Optional upper bound on the sub-step — use a step finer than stability requires (e.g. for output resolution). > 0.

Returns

ExplicitRunResult (n, dt, dt_cr) — the sub-step count, the step actually used, and the queried critical time step.

Raises

BridgeError / NotImplementedError / ValueError As for :meth:critical_time_step, plus ValueError for an out-of-range duration / safety / dt_max. RuntimeError If the explicit analyze returns non-zero (divergence, or a mid-run -cflAbort when the integrator is guarded).

Source code in src/apeGmsh/opensees/apesees.py
def analyze_explicit(
    self,
    *,
    duration: float,
    safety: float = 0.9,
    dt_max: float | None = None,
) -> "ExplicitRunResult":
    """Run an explicit transient over ``duration``, auto-sized to ``dt_cr``.

    **Fork-only** (Ladruno) driver implementing the explicit-dynamics
    sub-stepping recipe (ADR D5): build + emit, prime one tiny step,
    query the critical time step, then integrate ``duration`` in
    ``n = ceil(duration / (safety * dt_cr))`` equal sub-steps via a
    single ``analyze(n, duration / n)``.

    .. warning::
       ``dt_cr`` is queried **once**, on the initial stiffness. For a
       model whose tangent *stiffens* mid-run (contact closing,
       geometric / material stiffening) the true critical step shrinks
       and a fixed ``dt`` can go supercritical and diverge. Guard such
       runs by constructing the integrator with ``cfl_abort=True`` (and
       ``recompute=N``) so a recomputed CFL violation aborts the run —
       this method then re-raises that abort as an error rather than
       returning silently. A one-shot run with an unguarded integrator
       emits :class:`OpenSeesExplicitSolverWarning`.

    Parameters
    ----------
    duration
        Total physical time to integrate (``> 0``).
    safety
        Fraction of ``dt_cr`` used as the step (``0 < safety <= 1``;
        default ``0.9``). Scales the value ``criticalTimeStep()``
        returns — do not re-base it on any larger Noh-Bathe bound.
    dt_max
        Optional upper bound on the sub-step — use a step finer than
        stability requires (e.g. for output resolution). ``> 0``.

    Returns
    -------
    ExplicitRunResult
        ``(n, dt, dt_cr)`` — the sub-step count, the step actually used,
        and the queried critical time step.

    Raises
    ------
    BridgeError / NotImplementedError / ValueError
        As for :meth:`critical_time_step`, plus ``ValueError`` for an
        out-of-range ``duration`` / ``safety`` / ``dt_max``.
    RuntimeError
        If the explicit ``analyze`` returns non-zero (divergence, or a
        mid-run ``-cflAbort`` when the integrator is guarded).
    """
    if self._stage_records:
        raise NotImplementedError(
            "apeSees.analyze_explicit: live execution does not support "
            f"staged models (got {len(self._stage_records)} stage(s)). "
            "Emit Tcl/Py and drive the explicit run there instead."
        )
    self._check_analysis_chain_for_analyze()
    self._check_explicit_solver_compat()
    self._warn_if_unguarded_explicit_run()

    from .emitter.live import LiveOpsEmitter

    bm = self.build()
    self._assert_fork_if_required()
    live_emitter = LiveOpsEmitter(wipe=True)
    bm.emit(live_emitter)
    # Prime, query dt_cr, then size + run the sub-stepped analysis on
    # the SAME emitter (the prime step's tiny dt is stable).
    live_emitter.analyze(steps=1, dt=_DTCR_PRIME_DT)
    dtcr = _dtcr_or_raise(live_emitter.critical_time_step())
    n, dt = _explicit_substep_count(
        duration, dtcr, safety=safety, dt_max=dt_max,
    )
    ret = int(live_emitter.analyze(steps=n, dt=dt))
    if ret != 0:
        raise RuntimeError(
            f"apeSees.analyze_explicit: explicit run failed (analyze "
            f"returned {ret}) after sizing dt={dt:.3e} from "
            f"dt_cr={dtcr:.3e} over {n} sub-steps. The solution likely "
            "diverged — on a stiffening model the critical step can fall "
            "below dt mid-run. Lower safety=, pass a smaller dt_max=, or "
            "construct the integrator with cfl_abort=True / recompute=N."
        )
    return ExplicitRunResult(n=n, dt=dt, dt_cr=dtcr)

tcl

tcl(path: str, *, run: bool = False, bin: str | None = None, analyze_steps: int | None = None, analyze_dt: float | None = None, split: bool = False, per_rank: bool = False, flat: bool = False, stream: bool = False, verbose: bool = False, log: str | None = None, progress: bool = True) -> None

Emit a Tcl deck to path; optionally subprocess OpenSees.

When run=True the OpenSees subprocess output is always tee'd to a log file — log (a path) overrides, otherwise it is <path>.log next to the deck. Console output is opt-in via verbose: False (default) prints begin / op / end only; True adds a live step counter (parsed from the APEGMSH_PROGRESS markers progress=True injects into the analyze loop) plus streamed warning lines. A non-zero exit raises RuntimeError carrying the log tail + path, never the whole buffer. verbose / log / progress are inert when run=False.

When analyze_steps is supplied, an analyze line is appended to the deck after every other primitive — wrapped in a hook-dispatching for-loop if any :meth:initial_stress calls registered step hooks (Phase SSI-1). Without analyze_steps, the emitted deck declares the model but does not drive an analysis.

split=True (ADR 0043 slice 1.1, mode A) writes a driver deck at path plus one parts/<module>.tcl fragment per composed module (g.compose); the driver sources each fragment. The split is canonical — by compose module, no free-form carve — and changes only the on-disk layout: the default split=False writes the single self-contained deck, byte-identical to the pre-0043 output. Requires a composed model; partitioned / staged / initial_stress models are not supported under split.

per_rank=True (ADR 0061) writes a driver deck at path plus one ranks/rank<K>_<seq>.tcl fragment per if {[getPID] == K} { ... } block; the driver guards each fragment behind a one-line source so every MPI rank parses only the driver plus its own fragments — O(global + model/np) instead of O(model) per rank. Layout-only: the deck semantics (including the single-process rank-0 fallback) are unchanged. Requires a partitioned model (len(fem.partitions) > 1); mutually exclusive with split.

flat=True forces the single-domain (serial) emit even when the model carries partitions — e.g. a composed model, which is auto-partitioned one-rank-per-module (ADR 0038 §"Rank model") and would otherwise take the per-rank fan-out. The deck declares the whole model in one domain with no getPID brackets, exactly as the live in-process runner and modal decks emit it. This is the Tcl route for serial-only records (g.embed ties; fork contact before ADR 0092 S4 landed partitioned emit, and still the escape hatch for the contact cases the partitioned path refuses) on a composed model. Mutually exclusive with per_rank and split; a no-op on an already-unpartitioned model.

stream=True (ADR 0065 Tier 2 / plan_emit_memory_columnar.md A1–A3) writes the deck through a live file sink instead of accumulating the line buffer, so peak emit memory stops scaling with deck size. Output is byte-identical to the default list mode, including under per_rank=True, where the fragment files are live-routed (partition_open switches the sink) rather than sliced post-hoc. Everything goes to .tmp siblings promoted atomically on clean completion — a mid-emit exception never leaves a half-written deck. Not supported with split=True (v1).

Source code in src/apeGmsh/opensees/apesees.py
def tcl(
    self,
    path: str,
    *,
    run: bool = False,
    bin: str | None = None,
    analyze_steps: int | None = None,
    analyze_dt: float | None = None,
    split: bool = False,
    per_rank: bool = False,
    flat: bool = False,
    stream: bool = False,
    verbose: bool = False,
    log: str | None = None,
    progress: bool = True,
) -> None:
    """Emit a Tcl deck to ``path``; optionally subprocess OpenSees.

    When ``run=True`` the OpenSees subprocess output is **always**
    tee'd to a log file — ``log`` (a path) overrides, otherwise it
    is ``<path>.log`` next to the deck. Console output is opt-in via
    ``verbose``: ``False`` (default) prints begin / op / end only;
    ``True`` adds a live step counter (parsed from the
    ``APEGMSH_PROGRESS`` markers ``progress=True`` injects into the
    analyze loop) plus streamed warning lines. A non-zero exit
    raises ``RuntimeError`` carrying the log tail + path, never the
    whole buffer. ``verbose`` / ``log`` / ``progress`` are inert
    when ``run=False``.

    When ``analyze_steps`` is supplied, an ``analyze`` line is
    appended to the deck after every other primitive — wrapped in
    a hook-dispatching for-loop if any
    :meth:`initial_stress` calls registered step hooks (Phase
    SSI-1).  Without ``analyze_steps``, the emitted deck declares
    the model but does not drive an analysis.

    ``split=True`` (ADR 0043 slice 1.1, mode A) writes a driver
    deck at ``path`` plus one ``parts/<module>.tcl`` fragment per
    composed module (``g.compose``); the driver ``source``s each
    fragment.  The split is canonical — by compose module, no
    free-form carve — and changes only the on-disk layout: the
    default ``split=False`` writes the single self-contained deck,
    byte-identical to the pre-0043 output.  Requires a composed
    model; partitioned / staged / ``initial_stress`` models are not
    supported under ``split``.

    ``per_rank=True`` (ADR 0061) writes a driver deck at ``path``
    plus one ``ranks/rank<K>_<seq>.tcl`` fragment per
    ``if {[getPID] == K} { ... }`` block; the driver guards each
    fragment behind a one-line ``source`` so every MPI rank parses
    only the driver plus its own fragments — O(global + model/np)
    instead of O(model) per rank.  Layout-only: the deck semantics
    (including the single-process rank-0 fallback) are unchanged.
    Requires a partitioned model (``len(fem.partitions) > 1``);
    mutually exclusive with ``split``.

    ``flat=True`` forces the single-domain (serial) emit even when
    the model carries partitions — e.g. a composed model, which is
    auto-partitioned one-rank-per-module (ADR 0038 §"Rank model")
    and would otherwise take the per-rank fan-out.  The deck
    declares the whole model in one domain with no ``getPID``
    brackets, exactly as the live in-process runner and modal decks
    emit it.  This is the Tcl route for serial-only records
    (``g.embed`` ties; fork contact before ADR 0092 S4 landed
    partitioned emit, and still the escape hatch for the contact
    cases the partitioned path refuses) on a composed model.
    Mutually exclusive with ``per_rank`` and ``split``; a no-op on
    an already-unpartitioned model.

    ``stream=True`` (ADR 0065 Tier 2 / plan_emit_memory_columnar.md
    A1–A3) writes the deck through a live file sink instead of
    accumulating the line buffer, so peak emit memory stops scaling
    with deck size. Output is byte-identical to the default list
    mode, including under ``per_rank=True``, where the fragment
    files are live-routed (``partition_open`` switches the sink)
    rather than sliced post-hoc. Everything goes to ``.tmp``
    siblings promoted atomically on clean completion — a mid-emit
    exception never leaves a half-written deck. Not supported with
    ``split=True`` (v1).
    """
    from .emitter.tcl import TclEmitter

    if split and per_rank:
        raise ValueError(
            "apeSees.tcl: split=True and per_rank=True are mutually "
            "exclusive — split carves by compose module (ADR 0043), "
            "per_rank by partition rank (ADR 0061)."
        )
    if split and stream:
        raise ValueError(
            "apeSees.tcl: stream=True and split=True are not "
            "supported together (v1) — the split writer slices the "
            "accumulated module spans out of the line buffer, which "
            "stream mode never builds (ADR 0065 Tier 2). Drop one "
            "of the two flags."
        )
    if flat and per_rank:
        raise ValueError(
            "apeSees.tcl: flat=True and per_rank=True are mutually "
            "exclusive — flat forces the single-domain (serial) "
            "emit; per_rank splits the partitioned fan-out "
            "(ADR 0061). Drop one of the two flags."
        )
    if flat and split:
        raise ValueError(
            "apeSees.tcl: flat=True and split=True are not "
            "supported together — split drives the module-fragment "
            "path (ADR 0043), which bypasses the flat/partitioned "
            "branch. Drop one of the two flags."
        )
    bm = self.build()
    emitter = TclEmitter()
    emitter._emit_progress = bool(progress)
    # ADR 0099 S5: ``per_rank`` is applied around / after ``bm.emit``
    # (live fragment routing under ``stream``, post-hoc span slicing
    # otherwise), so the INV-4 gate inside emit cannot see it. Stamp
    # it on the emitter — the same seam ``supports_partitions`` uses.
    emitter.per_rank_fragments = bool(per_rank)  # type: ignore[attr-defined]
    if flat:
        # Force the single-domain emit for a partition-carrying fem
        # (e.g. a composed model auto-partitioned one-rank-per-module,
        # ADR 0038) — the same seam the live in-process runner and
        # modal decks use. Serial-only records (g.embed ties, plus
        # the contact cases ADR 0092 S4's partitioned fan-out
        # refuses — SOFT, staged, undecidable owner) emit on this
        # path.
        emitter.supports_partitions = False  # type: ignore[attr-defined]
    pre_prof, post_prof = self._split_profiler_records()
    if not split:
        if stream:
            # ADR 0065 Tier 2: write-through sink; per-rank
            # fragment files are live-routed by
            # partition_open/partition_close.
            emitter.stream_to(path, per_rank=per_rank)
        try:
            bm.emit(emitter)
            for _verb, _vargs in pre_prof:
                emitter.profiler(_verb, *_vargs)
            if analyze_steps is not None:
                emitter.analyze(steps=int(analyze_steps), dt=analyze_dt)
            for _verb, _vargs in post_prof:
                emitter.profiler(_verb, *_vargs)
            if stream and per_rank and (
                emitter.stream_fragment_count() == 0
            ):
                raise ValueError(
                    "apeSees.tcl: per_rank=True requires a "
                    "partitioned model (len(fem.partitions) > 1) — "
                    "the emitted deck has no per-rank blocks to "
                    "split out. Partition the mesh "
                    "(g.mesh.partitioning) or drop per_rank."
                )
            if stream:
                # Promotion runs INSIDE the guarded region (review
                # hardening): a failing os.replace mid-promotion
                # (Windows file lock / antivirus) routes to
                # stream_abort below, which removes every remaining
                # .tmp. Fragments already promoted by the partial
                # loop stay in place — the driver is promoted LAST,
                # so no deck entry point exists until everything it
                # sources does; a clean re-run overwrites the
                # leftovers via os.replace.
                emitter.stream_finish()
        except BaseException:
            if stream:
                # Leave no half-written deck: remove every .tmp
                # (final paths are only ever created by a COMPLETE
                # promotion pass, except fragments promoted before
                # a mid-promotion failure — see stream_finish;
                # ADR 0065 Tier 2 Decision §4).
                emitter.stream_abort()
            raise
        if not stream and per_rank:
            spans = emitter.partition_spans()
            if not spans:
                raise ValueError(
                    "apeSees.tcl: per_rank=True requires a "
                    "partitioned model (len(fem.partitions) > 1) — "
                    "the emitted deck has no per-rank blocks to "
                    "split out. Partition the mesh "
                    "(g.mesh.partitioning) or drop per_rank."
                )
            # line_buffer(): read-only, no deck-sized copy (ADR 0065 A0).
            _write_per_rank_tcl(path, emitter.line_buffer(), spans)
        elif not stream:
            with open(path, "w", encoding="utf-8") as f:
                emitter.write_to(f)
    else:
        layout = bm.emit(emitter, split=True)
        for _verb, _vargs in pre_prof:
            emitter.profiler(_verb, *_vargs)
        if analyze_steps is not None:
            emitter.analyze(steps=int(analyze_steps), dt=analyze_dt)
        for _verb, _vargs in post_prof:
            emitter.profiler(_verb, *_vargs)
        _write_split_tcl(path, emitter.line_buffer(), layout)  # type: ignore[arg-type]

    if not run:
        return

    binary = _resolve_opensees_binary(bin, self._opensees)
    stream_run(
        [binary, path],
        log_path=resolve_log_path(log, path),
        verbose=verbose,
        label=run_label(path, analyze_steps, analyze_dt),
        header="OpenSees",
        deck_path=path,
    )

modal_deck

modal_deck(path: str, *, solver: str = 'feast', band: 'tuple[float, float] | None' = None, num_modes: int | None = None, certify: bool = False, target: str = 'tcl', out: str = 'eigenvalues.out') -> None

Emit a distributed modal deck (ADR 0077 Tier 1) — two backends.

solver="feast" (default) emits the replicated FEAST deck described below; solver="arpack" emits the partitioned ARPACK deck (Tier 1B) — see :meth:_modal_deck_arpack for that half. They invert each other on the two facts that matter (flat vs partitioned emit; system inert vs load-bearing), so the docs are kept apart rather than merged.

Which to use. "arpack" when the model does not fit on one node: it is the only backend where both the storage and the factorization are distributed. "feast" when you want a frequency window rather than the lowest N, or -certify completeness. Neither for a model that fits on one node — Tier 0 (:meth:eigen / :meth:modal_properties on the unpartitioned build) is faster at every size measured and is the only route to correct participation factors and effective modal mass.

FEAST backend (solver="feast", needs band=)

Writes a flat Tcl deck — every MPI rank builds the FULL model — that runs band-targeted FEAST under OpenSeesMP: eigen -feast band[0] band[1] -rci routes each contour solve through the distributed dmumps kernel (fork ADR 43, L3-only: every rank holds the full (K, M) CSR and the kernel slices the 2n block system's triplets across ranks). Distribution lives inside the RCI kernel, not in domain decomposition — a partitioned if {[getPID]==K} deck fails FeastEigenSOE::setSize (P2 live finding), so partitions on the model are ignored here (the deck is emitted flat) and the deck's system line plays no part in the FEAST solve. RAM trade-off: the full model is assembled on every rank (the documented L3 regime, ~1e5–1e6 DOF).

The band (Hz) defines the mode count; there is no num_modes. The deck is the HPC entry point (ops.run_remote / Cluster.submit, ADR 0060) and also runs single-process under plain OpenSees (serial FEAST — the getPID shim makes the rank-0 write-out unconditional).

modalProperties is not emitted: it is MPI-blind upstream (wrong effective mass under any multi-rank run; ADR 0077 INV-2). For participation factors run the single-process :meth:modal_properties (Tier 0). Harvest with :meth:ParallelModalResult.from_job — eigenvalues from the rank-0 write-out plus mode shapes (ADR 0077 P3): the deck records one mode_shape_<k>.out per found mode from rank 0 (the replicated model puts ALL nodes on every rank) with a mode_shapes.json sidecar pinning the node→column map (sorted mesh node tags × ndf DOFs).

Parameters

path Deck output path. solver "feast" (default, replicated band solve) or "arpack" (partitioned lowest-N solve, Tier 1B). band (f_min, f_max) frequency band in Hz; needs 0 <= f_min < f_max. FEAST only — rejected for solver="arpack", whose selection axis is a mode count. num_modes Number of modes to extract. ARPACK only — rejected for solver="feast", where the contour is the band and the found count is dynamic. certify Emit -certify (fork Sturm/inertia completeness check). FEAST only. target "tcl" (the classic-Tcl deck). Each solver needs its own fork build: FEAST the classic-Tcl -feast parity build (fork PR #578), ARPACK the MP eigen wiring (fork PR #668, 5a522b03b). "pymp" (an OpenSeesMP-Python deck, ADR 0077 unlock 2a) raises for both solvers — for ARPACK because the modern interpreter still builds its ArpackSOE bare (the same latent F1 defect; #668 is classic-Tcl only). out Rank-0 eigenvalue write-out filename (read by :meth:ParallelModalResult.from_job).

Raises

ValueError If solver is unknown, if the band / mode-count arguments do not match the chosen solver, if band is invalid, if solver="arpack" is given an unpartitioned model, or if a non-Mumps system is declared on an ARPACK deck. NotImplementedError If target != "tcl" or the model has registered stages.

Source code in src/apeGmsh/opensees/apesees.py
def modal_deck(
    self,
    path: str,
    *,
    solver: str = "feast",
    band: "tuple[float, float] | None" = None,
    num_modes: int | None = None,
    certify: bool = False,
    target: str = "tcl",
    out: str = "eigenvalues.out",
) -> None:
    """Emit a distributed modal deck (ADR 0077 Tier 1) — two backends.

    ``solver="feast"`` (default) emits the **replicated** FEAST deck
    described below; ``solver="arpack"`` emits the **partitioned**
    ARPACK deck (Tier 1B) — see
    :meth:`_modal_deck_arpack` for that half. They invert each
    other on the two facts that matter (flat vs partitioned emit;
    ``system`` inert vs load-bearing), so the docs are kept apart
    rather than merged.

    **Which to use.** ``"arpack"`` when the model does not fit on one
    node: it is the only backend where both the storage *and* the
    factorization are distributed. ``"feast"`` when you want a
    frequency window rather than the lowest N, or ``-certify``
    completeness. **Neither** for a model that fits on one node —
    Tier 0 (:meth:`eigen` / :meth:`modal_properties` on the
    unpartitioned build) is faster at every size measured and is the
    only route to correct participation factors and effective modal
    mass.

    FEAST backend (``solver="feast"``, needs ``band=``)
    --------------------------------------------------
    Writes a **flat** Tcl deck — every MPI rank builds the FULL model —
    that runs band-targeted FEAST under ``OpenSeesMP``: ``eigen -feast
    band[0] band[1] -rci`` routes each contour solve through the
    distributed ``dmumps`` kernel (fork ADR 43, **L3-only**: every rank
    holds the full ``(K, M)`` CSR and the kernel slices the 2n block
    system's triplets across ranks). Distribution lives inside the RCI
    kernel, **not** in domain decomposition — a partitioned
    ``if {[getPID]==K}`` deck fails ``FeastEigenSOE::setSize`` (P2
    live finding), so partitions on the model are ignored here (the
    deck is emitted flat) and the deck's ``system`` line plays no part
    in the FEAST solve. RAM trade-off: the full model is assembled on
    every rank (the documented L3 regime, ~1e5–1e6 DOF).

    The band (Hz) defines the mode count; there is no ``num_modes``.
    The deck is the HPC entry point (``ops.run_remote`` /
    ``Cluster.submit``, ADR 0060) and also runs single-process under
    plain ``OpenSees`` (serial FEAST — the ``getPID`` shim makes the
    rank-0 write-out unconditional).

    ``modalProperties`` is **not** emitted: it is MPI-blind upstream
    (wrong effective mass under any multi-rank run; ADR 0077 INV-2).
    For participation factors run the single-process
    :meth:`modal_properties` (Tier 0). Harvest with
    :meth:`ParallelModalResult.from_job` — eigenvalues from the
    rank-0 write-out plus mode shapes (ADR 0077 P3): the deck
    records one ``mode_shape_<k>.out`` per found mode from rank 0
    (the replicated model puts ALL nodes on every rank) with a
    ``mode_shapes.json`` sidecar pinning the node→column map
    (sorted mesh node tags × ``ndf`` DOFs).

    Parameters
    ----------
    path
        Deck output path.
    solver
        ``"feast"`` (default, replicated band solve) or ``"arpack"``
        (partitioned lowest-N solve, Tier 1B).
    band
        ``(f_min, f_max)`` frequency band in Hz; needs
        ``0 <= f_min < f_max``. **FEAST only** — rejected for
        ``solver="arpack"``, whose selection axis is a mode count.
    num_modes
        Number of modes to extract. **ARPACK only** — rejected for
        ``solver="feast"``, where the contour *is* the band and the
        found count is dynamic.
    certify
        Emit ``-certify`` (fork Sturm/inertia completeness check).
        **FEAST only.**
    target
        ``"tcl"`` (the classic-Tcl deck). Each solver needs its own
        fork build: FEAST the classic-Tcl ``-feast`` parity build
        (fork PR #578), ARPACK the MP eigen wiring (fork PR #668,
        ``5a522b03b``). ``"pymp"`` (an
        OpenSeesMP-Python deck, ADR 0077 unlock 2a) raises for both
        solvers — for ARPACK because the modern interpreter still
        builds its ``ArpackSOE`` bare (the same latent F1 defect;
        #668 is classic-Tcl only).
    out
        Rank-0 eigenvalue write-out filename (read by
        :meth:`ParallelModalResult.from_job`).

    Raises
    ------
    ValueError
        If ``solver`` is unknown, if the band / mode-count arguments
        do not match the chosen solver, if ``band`` is invalid, if
        ``solver="arpack"`` is given an unpartitioned model, or if a
        non-``Mumps`` system is declared on an ARPACK deck.
    NotImplementedError
        If ``target != "tcl"`` or the model has registered stages.
    """
    from .emitter.tcl import TclEmitter

    if solver not in ("feast", "arpack"):
        raise ValueError(
            "apeSees.modal_deck: solver must be 'feast' (replicated "
            "band solve) or 'arpack' (partitioned lowest-N solve), "
            f"got {solver!r}."
        )
    if target != "tcl":
        raise NotImplementedError(
            "apeSees.modal_deck: target='pymp' (an OpenSeesMP-Python "
            "deck) is ADR 0077 unlock 2a and not implemented yet; use "
            "target='tcl'. For solver='arpack' this is not just "
            "unimplemented: the modern interpreter (openseespy / PyMP) "
            "still builds its ArpackSOE bare, so the distributed-eigen "
            "wiring of fork PR #668 is classic-Tcl only."
        )
    if self._stage_records:
        raise NotImplementedError(
            "apeSees.modal_deck: staged models are not supported "
            "(per-stage parallel modal is deferred, ADR 0077 / "
            f"SSI-2.A) (got {len(self._stage_records)} stage(s))."
        )
    self._guard_modal_deck_constraint_handler()
    if solver == "arpack":
        if band is not None or certify:
            raise ValueError(
                "apeSees.modal_deck: band= / certify= are FEAST-only "
                "(the contour is the band). solver='arpack' selects "
                "the lowest num_modes= modes; drop band=/certify= or "
                "use solver='feast'."
            )
        if num_modes is None or int(num_modes) < 1:
            raise ValueError(
                "apeSees.modal_deck: solver='arpack' needs "
                f"num_modes >= 1, got {num_modes!r}."
            )
        self._modal_deck_arpack(
            path, num_modes=int(num_modes), out=out,
        )
        return

    if num_modes is not None:
        raise ValueError(
            "apeSees.modal_deck: num_modes= is ARPACK-only — for "
            "solver='feast' the contour IS the band and the found "
            "mode count is dynamic. Pass band= only, or use "
            "solver='arpack'."
        )
    if band is None:
        raise ValueError(
            "apeSees.modal_deck: solver='feast' needs band="
            "(f_min, f_max) in Hz."
        )
    f_min, f_max = band
    if not (0.0 <= f_min < f_max):
        raise ValueError(
            "apeSees.modal_deck: need 0 <= band[0] < band[1], got "
            f"{band!r}."
        )

    bm = self.build()
    emitter = TclEmitter()
    # L3 FEAST needs the FULL model on every rank — force the flat
    # (replicated) emit even for a partition-authored fem, exactly as
    # the live emitter does (ADR 0077 P2 live finding).
    emitter.supports_partitions = False  # type: ignore[attr-defined]
    bm.emit(emitter)
    # Deterministic eigen preamble (every rank identical). The handler
    # matters (Penalty pollutes M, Lagrange injects zero-mass DOFs →
    # spurious modes); the numberer must number identically on every
    # rank (RCM); the system line is NOT in the FEAST solve path — a
    # serial UmfPack is correct even for the distributed run.
    emitter.constraints("Transformation")
    emitter.numberer("RCM")
    emitter.system("UmfPack")
    # P3 mode-shape harvest: pin the recorder column order to the
    # sorted mesh node tags (the sidecar the deck writes lets
    # ParallelModalResult.from_job map columns back without the deck).
    # Mesh nodes only — bridge-declared extra nodes (decoupled nodes)
    # are not harvested.
    shape_tags = tuple(sorted(int(t) for t in bm.fem.nodes.ids))
    emitter.eigen_feast_parallel(
        f_min, f_max, certify=certify, out=out,
        shape_nodes=shape_tags, shape_ndf=bm.ndf, shape_ndm=bm.ndm,
    )

    with open(path, "w", encoding="utf-8") as f:
        emitter.write_to(f)

py

py(path: str, *, run: bool = False, analyze_steps: int | None = None, analyze_dt: float | None = None, split: bool = False, python: str | None = None, stream: bool = False, verbose: bool = False, log: str | None = None, progress: bool = True) -> None

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

run=True streams the openseespy subprocess exactly like :meth:tcl — full output always tee'd to a log (log path override, else <path>.log), console opt-in via verbose, a live step counter from the progress markers, and a tail-only RuntimeError on a non-zero exit. verbose / log / progress are inert when run=False.

analyze_steps / analyze_dt semantics mirror :meth:tcl (Phase SSI-1).

split=True (ADR 0043 slice 1.1, mode A) writes a driver script at path plus one parts/<module>.py fragment per composed module; each fragment exposes def build(ops): ... and the driver loads + calls them. The default split=False writes the single self-contained script, byte-identical to the pre-0043 output. Same composed-model requirement as :meth:tcl.

stream=True is out of scope for the Python deck emitter (v1) and fails loud — the HPC path is Tcl (ADR 0065 Tier 2 / plan_emit_memory_columnar.md A1–A3); use ops.tcl(path, stream=True).

Source code in src/apeGmsh/opensees/apesees.py
def py(
    self,
    path: str,
    *,
    run: bool = False,
    analyze_steps: int | None = None,
    analyze_dt: float | None = None,
    split: bool = False,
    python: str | None = None,
    stream: bool = False,
    verbose: bool = False,
    log: str | None = None,
    progress: bool = True,
) -> None:
    """Emit an openseespy Python deck to ``path``; optionally run it.

    ``run=True`` streams the openseespy subprocess exactly like
    :meth:`tcl` — full output always tee'd to a log (``log`` path
    override, else ``<path>.log``), console opt-in via ``verbose``,
    a live step counter from the ``progress`` markers, and a
    tail-only ``RuntimeError`` on a non-zero exit. ``verbose`` /
    ``log`` / ``progress`` are inert when ``run=False``.

    ``analyze_steps`` / ``analyze_dt`` semantics mirror :meth:`tcl`
    (Phase SSI-1).

    ``split=True`` (ADR 0043 slice 1.1, mode A) writes a driver
    script at ``path`` plus one ``parts/<module>.py`` fragment per
    composed module; each fragment exposes ``def build(ops): ...``
    and the driver loads + calls them.  The default ``split=False``
    writes the single self-contained script, byte-identical to the
    pre-0043 output.  Same composed-model requirement as
    :meth:`tcl`.

    ``stream=True`` is out of scope for the Python deck emitter
    (v1) and fails loud — the HPC path is Tcl (ADR 0065 Tier 2 /
    plan_emit_memory_columnar.md A1–A3); use
    ``ops.tcl(path, stream=True)``.
    """
    from .emitter.py import PyEmitter

    if stream:
        raise ValueError(
            "apeSees.py: stream=True is not supported for the "
            "Python deck emitter (v1) — the HPC path is Tcl "
            "(ADR 0065 Tier 2 / plan_emit_memory_columnar.md "
            "A1–A3); use ops.tcl(path, stream=True) instead."
        )
    bm = self.build()
    emitter = PyEmitter()
    emitter._emit_progress = bool(progress)
    pre_prof, post_prof = self._split_profiler_records()
    if not split:
        bm.emit(emitter)
        for _verb, _vargs in pre_prof:
            emitter.profiler(_verb, *_vargs)
        if analyze_steps is not None:
            emitter.analyze(steps=int(analyze_steps), dt=analyze_dt)
        for _verb, _vargs in post_prof:
            emitter.profiler(_verb, *_vargs)
        with open(path, "w", encoding="utf-8") as f:
            emitter.write_to(f)
    else:
        layout = bm.emit(emitter, split=True)
        for _verb, _vargs in pre_prof:
            emitter.profiler(_verb, *_vargs)
        if analyze_steps is not None:
            emitter.analyze(steps=int(analyze_steps), dt=analyze_dt)
        for _verb, _vargs in post_prof:
            emitter.profiler(_verb, *_vargs)
        _write_split_py(path, emitter.line_buffer(), layout)  # type: ignore[arg-type]

    if not run:
        return

    python_bin = _resolve_python_binary(python, self._opensees)
    # PYTHONUNBUFFERED so the child's stdout streams live through the
    # pipe rather than block-buffering until exit (the tee + live
    # counter depend on it).
    child_env = {**os.environ, "PYTHONUNBUFFERED": "1"}
    stream_run(
        [python_bin, path],
        log_path=resolve_log_path(log, path),
        verbose=verbose,
        label=run_label(path, analyze_steps, analyze_dt),
        header="openseespy",
        env=child_env,
        deck_path=path,
    )

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)

run_remote

run_remote(job_dir: str, *, cluster: 'str | Cluster', np: int | None = None, name: str | None = None, deck: str = 'main.tcl', binary: str | None = None, walltime: str | None = None, analyze_steps: int | None = None, analyze_dt: float | None = None, wait: bool = True, poll: float = 15.0, timeout: float | None = None, overwrite: bool = False) -> 'Job'

Emit the Tcl deck and run it on a SLURM cluster (ADR 0060 sugar).

One call for the whole loop: emit into job_dir -> push -> sbatch -> poll to completion -> fetch results back into job_dir. Wraps :class:apeGmsh.hpc.Cluster / :class:apeGmsh.hpc.Job; use those directly for finer control (or pass wait=False to get the live :class:Job handle back right after submission).

Parameters

job_dir Local directory the deck is emitted into and results are fetched back into. Created if missing. cluster Cluster name in ~/.apegmsh/clusters.toml (e.g. "esmeralda") or a constructed Cluster. np MPI ranks. Defaults to the model's partition count (len(fem.partitions)), or 1 for a flat model. analyze_steps / analyze_dt Forwarded to :meth:tcl — appends the analyze drive line exactly as the local emit would. wait True (default) blocks until the job ends and fetches. False returns the submitted Job immediately; poll/fetch it yourself (it survives sessions via Job.load(job_dir)).

Raises

HPCError If the job ends in any state other than COMPLETED (results and logs are still fetched first; the message carries the stderr tail).

Source code in src/apeGmsh/opensees/apesees.py
def run_remote(
    self,
    job_dir: str,
    *,
    cluster: "str | Cluster",
    np: int | None = None,
    name: str | None = None,
    deck: str = "main.tcl",
    binary: str | None = None,
    walltime: str | None = None,
    analyze_steps: int | None = None,
    analyze_dt: float | None = None,
    wait: bool = True,
    poll: float = 15.0,
    timeout: float | None = None,
    overwrite: bool = False,
) -> "Job":
    """Emit the Tcl deck and run it on a SLURM cluster (ADR 0060 sugar).

    One call for the whole loop: emit into ``job_dir`` -> push ->
    ``sbatch`` -> poll to completion -> fetch results back into
    ``job_dir``. Wraps :class:`apeGmsh.hpc.Cluster` /
    :class:`apeGmsh.hpc.Job`; use those directly for finer control
    (or pass ``wait=False`` to get the live :class:`Job` handle back
    right after submission).

    Parameters
    ----------
    job_dir
        Local directory the deck is emitted into and results are
        fetched back into. Created if missing.
    cluster
        Cluster name in ``~/.apegmsh/clusters.toml`` (e.g.
        ``"esmeralda"``) or a constructed ``Cluster``.
    np
        MPI ranks. Defaults to the model's partition count
        (``len(fem.partitions)``), or 1 for a flat model.
    analyze_steps / analyze_dt
        Forwarded to :meth:`tcl` — appends the ``analyze`` drive
        line exactly as the local emit would.
    wait
        ``True`` (default) blocks until the job ends and fetches.
        ``False`` returns the submitted ``Job`` immediately;
        poll/fetch it yourself (it survives sessions via
        ``Job.load(job_dir)``).

    Raises
    ------
    HPCError
        If the job ends in any state other than ``COMPLETED``
        (results and logs are still fetched first; the message
        carries the stderr tail).
    """
    from pathlib import Path as _Path

    from ..hpc import Cluster as _Cluster
    from ..hpc import HPCError as _HPCError
    from ..hpc import JobStatus as _JobStatus

    resolved = (
        _Cluster.load(cluster) if isinstance(cluster, str) else cluster
    )
    ranks = np if np is not None else max(1, len(self._fem.partitions))
    path = _Path(job_dir)
    path.mkdir(parents=True, exist_ok=True)
    self.tcl(
        str(path / deck),
        analyze_steps=analyze_steps,
        analyze_dt=analyze_dt,
    )
    job = resolved.submit(
        path,
        np=ranks,
        name=name,
        deck=deck,
        binary=binary,
        walltime=walltime,
        overwrite=overwrite,
    )
    if not wait:
        return job
    status = job.wait(poll=poll, timeout=timeout)
    # Fetch BEFORE the verdict: on failure the logs are the evidence.
    job.fetch()
    if status is not _JobStatus.COMPLETED:
        raise _HPCError(
            f"remote job {job.name!r} (slurm {job.slurm_id}) ended "
            f"{status.value}; logs fetched into {job.local_dir}.\n"
            f"--- stderr tail ---\n{job.tail(30, stream='err')}"
        )
    return job

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 from the file the next time Results.viewer(...) is opened against a results.h5 carrying the same /opensees/ zone (Phase 8 / ADR 0020 Composed-file pattern). 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 from the file the next time ``Results.viewer(...)``
        is opened against a results.h5 carrying the same
        ``/opensees/`` zone (Phase 8 / ADR 0020 Composed-file
        pattern).
    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").

    """
    # ADR 0055 Phase 2 + Phase 5 (P5.1, schema 2.19.0): staged
    # builds archive — the H5 emitter captures the per-stage emit
    # stream into ``/opensees/stages`` (see ``set_stage_records``
    # below).  For PARTITIONED staged builds the capture is
    # rank-agnostic by construction: replicated per-rank emission
    # dedupes on record identity, per-rank pattern/region
    # fragments merge by tag, and foreign ghost-node declarations
    # are filtered out of the stage buckets via the
    # ``set_stage_owned_node_tags`` side-channel — the stage zone
    # carries the flat logical program (rank-major capture order),
    # while the per-rank shape stays derivable from the neutral
    # ``/partitions`` zone.  The one staged remainder is the
    # phantom-node degrade (stage-claimed ``node_to_surface``),
    # which ``set_stage_records`` keeps fail-loud for flat and
    # partitioned builds alike.
    # ADR 0055 Phase 1: GLOBAL ``ops.initial_stress(...)`` archival is
    # supported — the records persist declaratively to
    # ``/opensees/initial_stress`` and replay re-runs the emit helpers
    # (see ``set_initial_stress_records`` below).  Per-stage
    # initial-stress persists with its stage under
    # ``/opensees/stages`` (Phase 2).

    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)

    # ADR 0055 Phase 1: hand the declarative global initial-stress
    # records to the emitter via the side-channel (the Protocol
    # ``step_hook_ramp`` / ``addToParameter`` calls bm.emit just drove
    # were no-op'd on H5 — they carry the resolved form).  ``bm.emit``
    # only emits the GLOBAL bucket at 7d; per-stage records ride the
    # stage side-channel below (ADR 0055 Phase 2).
    emitter.set_initial_stress_records(self._initial_stress_records)

    # ADR 0055 Phase 2: attach the declarative per-stage complement
    # (activated_pgs, per-stage initial-stress, activate_absorbing)
    # to the stage buckets the emitter captured in-band during
    # ``bm.emit``, and fail loud on any capture/record drift.
    # Called UNCONDITIONALLY (gate-2): a zero-record build that
    # somehow captured brackets must trip the count cross-check,
    # not silently write orphan buckets.
    emitter.set_stage_records(bm.stage_records)

    # ADR 0048 / 0049 — recompute the EFFECTIVE per-node ndf map (the same
    # deterministic inputs bm.emit used: inferred ∪ the ops.ndf overlay)
    # so the persisted /opensees/nodes_ndf matches the emitted deck exactly
    # and model_hash stays stable across a from_h5 → to_h5 round-trip.  The
    # overlay must fold in here too, else a STATED decoupled-node ndf is
    # lost on the FIRST write (not just round-trip).
    _elements = [p for p in bm.primitives if isinstance(p, Element)]
    _inferred = infer_node_ndf(self._fem, _elements, bm.ndm)
    _overlay = resolve_ndf_overlay(
        self._fem, bm.ndf_records, _inferred, bm.ndm,
    )
    _nodes_ndf = {**_inferred, **_overlay}

    # 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,
        names=self._name_records(),
        computed_sections=self._computed_section_records(bm.primitives),
        nodes_ndf=_nodes_ndf,
    )

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()."
        )
    self._check_damping_attached()

    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),
        region_records=tuple(self._region_records),
        ndf_records=tuple(self._ndf_records),
        initial_stress_records=tuple(self._initial_stress_records),
        stage_records=tuple(self._stage_records),
        rayleigh_records=tuple(self._rayleigh_records),
        damping_attach_records=tuple(self._damping_attach_records),
        modal_damping_records=tuple(self._modal_damping_records),
        name_to_tag={
            nm: tag for nm, _kind, tag in self._name_records()
        },
        mass_from_model=self._mass_from_model,
    )

Staged analysis

Multi-stage workflows (in-situ stress install → excavate → lining install → dynamic shake, or any other sequence of analyze blocks with Domain mutations between them) use the ops.stage(name) context manager:

with ops.stage(name="excavate") as s:
    s.activate(pgs=["Lining"])               # bring new elements online
    s.fix(pg="LiningAnchor", dofs=(1, 1, 1)) # stage-bound BC
    s.embedded(name="lining_embed")          # claim MP constraint by name
    s.analysis(test=, algorithm=, integrator=,
               constraints=, numberer=, system=, analysis=)
    s.run(n_increments=20, dt=0.05)

Each stage emits its own analysis chain + analyze loop with an explicit inter-stage cleanup (loadConst -time 0.0 + wipeAnalysis). Between-stage Domain mutators (s.remove_sp / s.remove_element / s.set_time / s.set_creep / s.reset / s.mass(overwrite=True)) lift the append-only constraint from earlier phases and unlock the atomic-replace pattern (release prior support + re-fix the same DOF in one stage).

Five validators gate stage-bound BCs at build time (H1 / V1 / V2 / V3 / V4) and two more cover the SSI-2.E removal verbs (V5 / V6). Each raises BridgeError with a clear offender list when a stage references topology that doesn't yet exist or has already been removed.

Tcl + Py text emit are the supported execution paths for staged decks today. Live execution (ops.analyze / ops.eigen) refuses staged models with NotImplementedError — emit via ops.tcl(p, run=True) / ops.py(p, run=True) for the OpenSees subprocess. H5 archival of staged structure is also deferred (apeSees.h5(path) is fail-loud on a staged build per PR #313).

The full lifecycle table, builder verbs, validator surface, MP partitioned + staged emit (Phase SSI-2.C), and the SSI-1 initial-stress ramp live in architecture/api-design.md §"Staged analysis"; the internals (deck layout, ownership computation, hook dispatcher, per-emitter dialect divergence, cleanup contract) live in architecture/staged-analysis.md and architecture/emitter.md.

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 (typed primitives — Node / Element / MPCO / declarative fan-out via ops.recorder.declare(...)).

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 materialized at build time by :func:apeGmsh.opensees._internal.build.emit_recorder_spec, which resolves pg through the FEM snapshot, rewrites the spec to its explicit nodes= / elements= form via :func:dataclasses.replace, and then delegates to _emit. End users drive this through apeSees(fem).tcl(...) / .py(...) / .run() — never call _emit directly with a pg spec, which raises :class:NotImplementedError as a defense-in-depth guard.

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 bridge build pipeline materializes the pg= form against the FEM snapshot before driving _emit; direct _emit calls on a pg= spec raise :class:NotImplementedError as a defense-in-depth guard.

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. Resolved by the bridge build pipeline at emit time. 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 resolved against the FEM snapshot by the bridge build pipeline before driving _emit; direct _emit calls on a pg= spec raise :class:NotImplementedError as a defense-in-depth guard.

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. Resolved by the bridge build pipeline at emit time. 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.

FilterableRecorder dataclass

FilterableRecorder(*, file: str, nodal_responses: tuple[str, ...] = (), elem_responses: tuple[str, ...] = (), dT: float | None = None, nsteps: int | None = None, nodes: tuple[int, ...] | None = None, nodes_pg: str | None = None, elements: tuple[int, ...] | None = None, elements_pg: str | None = None, _region_tag: int | None = None)

Bases: Recorder

Base for HDF5 recorders that share MPCO's region-filter surface.

Carries the four mutually-paired selectors (nodes / nodes_pg / elements / elements_pg) and the region-emit machinery that turns them into an OpenSees region $tag -node ... -ele ... line plus a -R $tag on the recorder command. :class:MPCO and :class:Ladruno both inherit it; they differ only in the recorder kind token and (Ladruno) the trailing -G energy channel.

Carries the shared value channels too (file + nodal_responses / elem_responses -N/-E + dT/nsteps -T cadence), so :meth:_value_channel_args builds the common -N ... -E ... -T ... -R ... tail once for both subclasses — only the recorder kind token and (Ladruno) the trailing -G energy are subclass-specific.

Subclasses own only their required-response rule (MPCO needs nodal or element responses; Ladruno also accepts energy); the cadence mutex (:meth:_validate_cadence) and the four selector guards (:meth:_validate_filter) are shared. The partition-aware build pipeline (ADR 0027 INV-4) keys its per-rank region pass on isinstance(spec, FilterableRecorder) via :meth:has_filter / :meth:resolve_filter_ids.

has_filter

has_filter() -> bool

True iff any node/element selector was supplied.

Used by the partition-aware build pipeline (ADR 0027 INV-4) to decide whether the recorder needs a per-rank region pass — a whole-model recorder (no filter) emits one recorder line and nothing else.

Source code in src/apeGmsh/opensees/recorder.py
def has_filter(self) -> bool:
    """True iff any node/element selector was supplied.

    Used by the partition-aware build pipeline (ADR 0027 INV-4) to
    decide whether the recorder needs a per-rank region pass — a
    whole-model recorder (no filter) emits one ``recorder`` line
    and nothing else.
    """
    return (
        self.nodes is not None
        or self.nodes_pg is not None
        or self.elements is not None
        or self.elements_pg is not None
    )

resolve_filter_ids

resolve_filter_ids(fem: 'FEMData', fem_eid_to_ops_tag: 'FemToOpsTagMap | dict[int, int] | None' = None) -> tuple[tuple[int, ...], tuple[int, ...]]

Resolve nodes / nodes_pg / elements / elements_pg to explicit id tuples — no emission, no tag allocation.

Returns (node_ids, elem_ids). Either may be empty when its side was not requested; an empty result on a requested side (e.g. nodes_pg="X" resolving to zero nodes) raises :class:BridgeError to mirror the OpenSees runtime rejection of an empty region.

fem_eid_to_ops_tag is the bridge-built {fem_eid: ops_tag} map for element fan-out. When supplied AND elements_pg is set, the resolved FEM eids are translated to OpenSees element tags before they flow into the region's -ele arg list (same drift as the Element recorder, closed by :meth:Element.materialize). Lookup miss → :class:BridgeError. When None (legacy direct callers) the FEM eids are returned verbatim — the partition orchestrator uses this form so it can intersect per-rank in FEM-eid space, then translate at the final region-emit step.

This is the partition-aware split-point of the legacy single- pass :meth:materialize: the partition orchestrator calls resolve_filter_ids once globally to determine the full filter id set, then intersects per-rank before emitting the region. Whole-model recording (has_filter() is False) is a no-op pass-through — callers should not invoke this method in that case.

Source code in src/apeGmsh/opensees/recorder.py
def resolve_filter_ids(
    self,
    fem: "FEMData",
    fem_eid_to_ops_tag: "FemToOpsTagMap | dict[int, int] | None" = None,
) -> tuple[tuple[int, ...], tuple[int, ...]]:
    """Resolve ``nodes`` / ``nodes_pg`` / ``elements`` / ``elements_pg``
    to explicit id tuples — no emission, no tag allocation.

    Returns ``(node_ids, elem_ids)``. Either may be empty when its
    side was not requested; an empty *result* on a *requested* side
    (e.g. ``nodes_pg="X"`` resolving to zero nodes) raises
    :class:`BridgeError` to mirror the OpenSees runtime rejection of
    an empty region.

    ``fem_eid_to_ops_tag`` is the bridge-built ``{fem_eid: ops_tag}``
    map for element fan-out.  When supplied AND ``elements_pg`` is
    set, the resolved FEM eids are translated to OpenSees element
    tags before they flow into the region's ``-ele`` arg list
    (same drift as the Element recorder, closed by
    :meth:`Element.materialize`).  Lookup miss → :class:`BridgeError`.
    When ``None`` (legacy direct callers) the FEM eids are returned
    verbatim — the partition orchestrator uses this form so it can
    intersect per-rank in FEM-eid space, then translate at the
    final region-emit step.

    This is the partition-aware split-point of the legacy single-
    pass :meth:`materialize`: the partition orchestrator calls
    ``resolve_filter_ids`` once globally to determine the full
    filter id set, then intersects per-rank before emitting the
    region.  Whole-model recording (``has_filter() is False``) is
    a no-op pass-through — callers should not invoke this method
    in that case.
    """
    from ._internal.build import (
        BridgeError,
        expand_pg_to_elements,
        expand_pg_to_nodes,
    )

    kind = type(self).__name__

    # Resolve node-side selector.
    node_ids: tuple[int, ...] = ()
    if self.nodes_pg is not None:
        node_ids = expand_pg_to_nodes(fem, self.nodes_pg)
        if not node_ids:
            raise BridgeError(
                f"{kind} recorder filter: nodes_pg={self.nodes_pg!r} "
                "resolved to zero nodes against the FEM snapshot. "
                "An empty region is rejected by OpenSees at runtime; "
                "check the PG name spelling and that the PG was "
                "populated before get_fem_data."
            )
    elif self.nodes is not None:
        node_ids = tuple(int(n) for n in self.nodes)
        if not node_ids:
            raise BridgeError(
                f"{kind} recorder filter: nodes=() is empty.  An empty "
                "region is rejected by OpenSees at runtime; supply a "
                "non-empty tuple or drop the nodes= kwarg."
            )

    # Resolve element-side selector.
    elem_ids: tuple[int, ...] = ()
    if self.elements_pg is not None:
        fem_eids = tuple(
            eid for eid, _conn in expand_pg_to_elements(fem, self.elements_pg)
        )
        if not fem_eids:
            raise BridgeError(
                f"{kind} recorder filter: elements_pg={self.elements_pg!r} "
                "resolved to zero elements against the FEM snapshot. "
                "An empty region is rejected by OpenSees at runtime; "
                "check the PG name spelling and that elements were "
                "registered against it before get_fem_data."
            )
        if fem_eid_to_ops_tag is None:
            # Legacy / partition-orchestrator form: return FEM eids
            # verbatim so the caller can intersect per-rank in
            # FEM-eid space.  The bridge's flat path always supplies
            # the map; the partition path translates at the final
            # per-rank region-emit step.
            elem_ids = fem_eids
        else:
            ops_tags: list[int] = []
            for eid in fem_eids:
                ops_tag = fem_eid_to_ops_tag.get(int(eid))
                if ops_tag is None:
                    raise BridgeError(
                        f"{kind} recorder filter: elements_pg="
                        f"{self.elements_pg!r} resolves to FEM eid "
                        f"{eid} but no element was emitted at that "
                        "eid — declare an "
                        "``ops.element.X(pg=...)`` primitive whose "
                        f"pg includes {self.elements_pg!r}."
                    )
                ops_tags.append(int(ops_tag))
            elem_ids = tuple(ops_tags)
    elif self.elements is not None:
        elem_ids = tuple(int(e) for e in self.elements)
        if not elem_ids:
            raise BridgeError(
                f"{kind} recorder filter: elements=() is empty.  An "
                "empty region is rejected by OpenSees at runtime; "
                "supply a non-empty tuple or drop the elements= kwarg."
            )

    return node_ids, elem_ids

materialize

materialize(emitter: 'Emitter', fem: 'FEMData', tags: 'TagAllocator | None', fem_eid_to_ops_tag: 'FemToOpsTagMap | dict[int, int] | None' = None) -> 'FilterableRecorder'

Resolve filter selectors against the FEM and emit the region.

Whole-model recording (no filter selectors) is a no-op pass- through. When any of nodes / nodes_pg / elements / elements_pg is set, this method:

  1. Resolves *_pg to explicit id tuples via the bridge's PG-expansion helpers; refuses empty resolutions with :class:BridgeError (an empty OpenSees region is rejected at runtime).
  2. Allocates one fresh region tag from tags (must be supplied — the bridge build pipeline forwards the TagAllocator).
  3. Emits one region $tag -node ... -ele ... line on emitter.
  4. Returns a clone with the filter selectors cleared and _region_tag populated, so the subsequent _emit appends -R $tag to the recorder command.

Used by the flat / unpartitioned emit path. The partitioned emit path (ADR 0027 INV-4) invokes :meth:resolve_filter_ids once and emits the per-rank region line itself; it then injects _region_tag= onto the spec via :func:dataclasses.replace directly, bypassing this method.

Source code in src/apeGmsh/opensees/recorder.py
def materialize(
    self,
    emitter: "Emitter",
    fem: "FEMData",
    tags: "TagAllocator | None",
    fem_eid_to_ops_tag: "FemToOpsTagMap | dict[int, int] | None" = None,
) -> "FilterableRecorder":
    """Resolve filter selectors against the FEM and emit the region.

    Whole-model recording (no filter selectors) is a no-op pass-
    through.  When any of ``nodes`` / ``nodes_pg`` / ``elements`` /
    ``elements_pg`` is set, this method:

    1. Resolves ``*_pg`` to explicit id tuples via the bridge's
       PG-expansion helpers; refuses empty resolutions with
       :class:`BridgeError` (an empty OpenSees region is rejected
       at runtime).
    2. Allocates one fresh region tag from ``tags`` (must be
       supplied — the bridge build pipeline forwards the
       ``TagAllocator``).
    3. Emits one ``region $tag -node ... -ele ...`` line on
       ``emitter``.
    4. Returns a clone with the filter selectors cleared and
       ``_region_tag`` populated, so the subsequent ``_emit``
       appends ``-R $tag`` to the recorder command.

    Used by the flat / unpartitioned emit path.  The partitioned
    emit path (ADR 0027 INV-4) invokes :meth:`resolve_filter_ids`
    once and emits the per-rank region line itself; it then
    injects ``_region_tag=`` onto the spec via
    :func:`dataclasses.replace` directly, bypassing this method.
    """
    if not self.has_filter():
        return self

    from ._internal.build import BridgeError

    kind = type(self).__name__

    if tags is None:
        raise BridgeError(
            f"{kind} with nodes=/elements=/nodes_pg=/elements_pg= filter "
            "requires a TagAllocator on emit_recorder_spec(..., tags=); "
            "the bridge build pipeline supplies one — tests that "
            "bypass the bridge must pass it explicitly."
        )

    # ``elements_pg=`` resolution translates FEM eids → OpenSees
    # element tags via the bridge-built map (same drift as the
    # Element recorder, closed by ``Element.materialize`` above).
    # The partitioned emit path drives this method-bypass via
    # ``_plan_partitioned_mpco_recorders`` + ``_emit_mpco_filter_
    # regions_for_rank`` (which keeps the resolution in FEM-eid
    # space so the per-rank ``element_owner`` intersection works,
    # then translates at the final region-emit step).
    node_ids, elem_ids = self.resolve_filter_ids(
        fem, fem_eid_to_ops_tag=fem_eid_to_ops_tag,
    )

    # Allocate one region tag for this recorder and emit it.
    # One ``region`` command can carry both ``-node`` and ``-ele``
    # flags; the recorder's ``-R`` then filters both nodal and
    # element results.  At least one of node_ids / elem_ids is
    # guaranteed non-empty (empty-resolution branches in
    # resolve_filter_ids raise before we get here, and
    # __post_init__ already verified at least one selector was
    # supplied).
    region_tag = tags.allocate("region")
    region_args: list[int | float | str] = []
    if node_ids:
        region_args += ["-node", *node_ids]
    if elem_ids:
        region_args += ["-ele", *elem_ids]
    emitter.region(region_tag, *region_args)

    return replace(
        self,
        nodes_pg=None,
        elements_pg=None,
        nodes=node_ids if node_ids else None,
        elements=elem_ids if elem_ids else None,
        _region_tag=region_tag,
    )

MPCO dataclass

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

Bases: FilterableRecorder

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]
                         [-R $regTag]

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.

Filtering — MPCO records the whole model by default. To restrict output to a subset of nodes/elements, supply any of nodes= / nodes_pg= / elements= / elements_pg=: the build pipeline auto-emits an OpenSees region $tag -node ... -ele ... command before the recorder and passes -R $tag to MPCO. nodes= is mutually exclusive with nodes_pg=; the same applies to the element pair. When all four are None (the default) MPCO records the whole model and no region is emitted.

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. nodes Explicit tuple of node tags to include in the region filter. Mutually exclusive with nodes_pg. nodes_pg Physical-group label whose nodes the region filter targets. Mutually exclusive with nodes. Resolved by the bridge build pipeline at emit time. elements Explicit tuple of element tags to include in the region filter. Mutually exclusive with elements_pg. elements_pg Physical-group label whose elements the region filter targets. Mutually exclusive with elements. Resolved by the bridge build pipeline at emit time.

Note

The bridge does not interpret -R-bearing MPCO arg tails when _emit is called directly (outside the build pipeline); the pg= form is materialised by :func:apeGmsh.opensees._internal.build.emit_recorder_spec, which resolves selectors, allocates a region tag, emits the region, and replaces the spec via :func:dataclasses.replace with explicit nodes=/elements= before driving _emit.

Ladruno dataclass

Ladruno(*, file: str, nodal_responses: tuple[str, ...] = (), elem_responses: tuple[str, ...] = (), dT: float | None = None, nsteps: int | None = None, nodes: tuple[int, ...] | None = None, nodes_pg: str | None = None, elements: tuple[int, ...] | None = None, elements_pg: str | None = None, _region_tag: int | None = None, energy: bool = False, energy_pg: str | None = None, _energy_region_tags: tuple[int, ...] = ())

Bases: FilterableRecorder

recorder ladruno — write a single HDF5 .ladruno file.

Fork-only. The ladruno recorder exists only in the Ladruno fork build of OpenSees (nmorabowen/OpenSees@ladruno); stock openseespy does not have it. Per the opt-in contract, emission works on any build (the deck line is just recorder ladruno ...); the fork requirement bites only when the deck actually runs (ops.run() / the live emitter). Gate at the point of use, never at import.

OpenSees command (value channels + region filter + energy balance)::

recorder ladruno fname.ladruno [-N nodal_responses...]
                               [-E elem_responses...]
                               [-T dt $dt | -T nsteps $n]
                               [-R $regTag]
                               [-G energy]

The .ladruno recorder is forked from the frozen MPCORecorder and shares its value-channel command grammar (the -N/-E/ -T channels reproduce the frozen recorder to 1e-12), so this dataclass mirrors :class:MPCO for those channels and inherits the same region-filter machinery from :class:FilterableRecorder. It diverges only in the recorder kind token (ladruno vs mpco), the output extension (.ladruno), and the trailing -G energy channel.

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

Filtering — like MPCO, Ladruno records the whole model by default. Supply any of nodes= / nodes_pg= / elements= / elements_pg= to restrict output: the build pipeline auto-emits an OpenSees region $tag -node ... -ele ... command before the recorder and passes -R $tag to ladruno. nodes= is mutually exclusive with nodes_pg= (same for the element pair). A node-only filter cannot be combined with elem_responses (and vice versa) — the auto-region would carry no entries on the other side and produce an empty stream. The -R $tag is emitted before -G energy so the energy flag stays last.

energy=True adds the fork's whole-model energy-balance channel (-G energyRESULTS/ON_DOMAIN/energyBalance, components KE/IE/DW/ULW/RES/ERR), read back via Results.energy(). The flag is emitted last: the fork's -G parser eagerly consumes trailing region-tag integers and cannot rewind past a following flag, so -G energy -T nsteps 10 is a parse error while -T nsteps 10 -G energy runs (run-verified on the fork build).

Energy balance. Three forms, all run-verified on the fork build (which always writes the whole-model balance, RESULTS/ON_DOMAIN/energyBalance, and adds a per-region balance, RESULTS/ON_REGIONS/energyBalance, whenever a region tag is given):

  • energy=True alone → whole-model (-G energy, no tag).
  • energy=True + a value filter → energy over the same region the -R filter targets, reusing the filter's already-allocated tag (-G energy $filterTag). The coupled form.
  • energy_pg="X" → energy over an independent region X (-G energy $tagX), decoupled from the -R value filter. X may differ from (or exist without) the value-channel filter; it gets its own region tag. The decoupled form. energy_pg takes precedence over the coupled form, and implies energy recording (no need to also set energy=True).

All three ride the flat / staged / partitioned region plumbing — the decoupled region gets its own per-rank fan-out under partitioning, just like the value filter (ADR 0064 §4). Read any region's balance via Results.energy(region=<tag>); whole-model via Results.energy().

Parameters

file Output .ladruno (HDF5) file path. nodal_responses Tuple of -N tokens (e.g. ("displacement", "reactionForce")). Empty tuple means no nodal recording. elem_responses Tuple of -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. energy Record the energy balance (-G energy). Whole-model when no filter is set; per-region (over the -R filter region, plus whole-model) when combined with a nodes=/elements= filter. energy_pg Physical-group label for a decoupled energy region — records energy over that PG (-G energy $tag) independent of the value filter. Implies energy recording. Resolved by the bridge build pipeline; gets its own auto-emitted region (per-rank under partitioning). nodes Explicit tuple of node tags for the region filter. Mutually exclusive with nodes_pg. nodes_pg Physical-group label whose nodes the region filter targets. Mutually exclusive with nodes. Resolved by the bridge build pipeline at emit time. elements Explicit tuple of element tags for the region filter. Mutually exclusive with elements_pg. elements_pg Physical-group label whose elements the region filter targets. Mutually exclusive with elements. Resolved by the bridge build pipeline at emit time.

resolve_energy_ids

resolve_energy_ids(fem: 'FEMData', fem_eid_to_ops_tag: 'FemToOpsTagMap | dict[int, int] | None' = None) -> tuple[int, ...]

Resolve energy_pg to element ids for the energy region.

Mirrors the element side of :meth:resolve_filter_ids: returns FEM eids verbatim when fem_eid_to_ops_tag is None (the partition orchestrator intersects per-rank in FEM-eid space), else translates to OpenSees element tags. Empty resolution → :class:BridgeError. Energy is an element quantity, so the region carries -ele only (the fork auto-derives the nodes).

Source code in src/apeGmsh/opensees/recorder.py
def resolve_energy_ids(
    self,
    fem: "FEMData",
    fem_eid_to_ops_tag: "FemToOpsTagMap | dict[int, int] | None" = None,
) -> tuple[int, ...]:
    """Resolve ``energy_pg`` to element ids for the energy region.

    Mirrors the element side of :meth:`resolve_filter_ids`: returns
    FEM eids verbatim when ``fem_eid_to_ops_tag is None`` (the
    partition orchestrator intersects per-rank in FEM-eid space),
    else translates to OpenSees element tags. Empty resolution →
    :class:`BridgeError`. Energy is an element quantity, so the
    region carries ``-ele`` only (the fork auto-derives the nodes).
    """
    from ._internal.build import BridgeError, expand_pg_to_elements

    assert self.energy_pg is not None  # caller-guarded
    fem_eids = tuple(
        eid for eid, _conn in expand_pg_to_elements(fem, self.energy_pg)
    )
    if not fem_eids:
        raise BridgeError(
            f"Ladruno energy region: energy_pg={self.energy_pg!r} "
            "resolved to zero elements against the FEM snapshot. "
            "An empty region is rejected by OpenSees at runtime; check "
            "the PG name spelling and that elements were registered "
            "against it before get_fem_data."
        )
    if fem_eid_to_ops_tag is None:
        return fem_eids
    ops_tags: list[int] = []
    for eid in fem_eids:
        ops_tag = fem_eid_to_ops_tag.get(int(eid))
        if ops_tag is None:
            raise BridgeError(
                f"Ladruno energy region: energy_pg={self.energy_pg!r} "
                f"resolves to FEM eid {eid} but no element was emitted "
                "at that eid — declare an ``ops.element.X(pg=...)`` "
                f"primitive whose pg includes {self.energy_pg!r}."
            )
        ops_tags.append(int(ops_tag))
    return tuple(ops_tags)

materialize

materialize(emitter: 'Emitter', fem: 'FEMData', tags: 'TagAllocator | None', fem_eid_to_ops_tag: 'FemToOpsTagMap | dict[int, int] | None' = None) -> 'FilterableRecorder'

Emit the value-filter region (base) and the decoupled energy region (energy_pg), each as its own OpenSees region.

The energy region is independent of the -R value filter (the fork's -G energy <tag> list is orthogonal to -R); it gets its own tag, recorded in _energy_region_tags and referenced by _emit as -G energy $tag. Used by the flat path; the partitioned path builds the equivalent spec in :meth:BuiltModel._plan_partitioned_mpco_recorders.

Source code in src/apeGmsh/opensees/recorder.py
def materialize(
    self,
    emitter: "Emitter",
    fem: "FEMData",
    tags: "TagAllocator | None",
    fem_eid_to_ops_tag: "FemToOpsTagMap | dict[int, int] | None" = None,
) -> "FilterableRecorder":
    """Emit the value-filter region (base) **and** the decoupled
    energy region (``energy_pg``), each as its own OpenSees ``region``.

    The energy region is independent of the ``-R`` value filter (the
    fork's ``-G energy <tag>`` list is orthogonal to ``-R``); it gets
    its own tag, recorded in ``_energy_region_tags`` and referenced by
    ``_emit`` as ``-G energy $tag``. Used by the flat path; the
    partitioned path builds the equivalent spec in
    :meth:`BuiltModel._plan_partitioned_mpco_recorders`.
    """
    # NOTE: explicit base call, not zero-arg ``super()`` — these are
    # ``@dataclass(slots=True)`` classes, which the decorator rebuilds,
    # leaving the ``super()`` ``__class__`` cell stale (TypeError).
    spec = FilterableRecorder.materialize(
        self, emitter, fem, tags, fem_eid_to_ops_tag,
    )
    if self.energy_pg is None:
        return spec
    from ._internal.build import BridgeError

    if tags is None:
        raise BridgeError(
            "Ladruno energy_pg= requires a TagAllocator on "
            "emit_recorder_spec(..., tags=); the bridge build pipeline "
            "supplies one — tests bypassing the bridge must pass it."
        )
    assert isinstance(spec, Ladruno)
    elem_ids = spec.resolve_energy_ids(fem, fem_eid_to_ops_tag)
    energy_tag = tags.allocate("region")
    emitter.region(energy_tag, "-ele", *elem_ids)
    return replace(spec, energy_pg=None, _energy_region_tags=(energy_tag,))

Monitor dataclass

Monitor(*, sink: str, dofs: tuple[int, ...], nodes: tuple[int, ...] | None = None, pg: str | None = None, resp: str = 'disp', every: int | None = None, hz: float | None = None)

Bases: Recorder

recorder Monitor — live SWMR-HDF5 nodal-telemetry sink (fork-only).

Fork-only. The Monitor recorder exists only in the Ladruno fork build; stock openseespy does not have it. Emission works on any build (the deck line is just recorder Monitor ...); the fork requirement bites only when the deck runs. Gate at the point of use.

Unlike the canonical :class:Ladruno recorder, the Monitor is a lightweight live-telemetry sidecar: it streams a few selected nodal scalars to a small SWMR-HDF5 file a viewer process can tail while the analysis is still running. The same file is a valid at-rest result once the run finishes — read both via :func:apeGmsh.results.read_monitor / :func:apeGmsh.results.tail_monitor.

OpenSees command::

recorder Monitor (-node n1 n2 ... | -region tag) -dof d1 d2 ...
                 [-resp disp|vel|accel|reaction]
                 -sink fname.h5 [-every K] [-hz H]

Exactly one of nodes= (explicit tags) or pg= (physical-group label, resolved against the FEM snapshot by the bridge build pipeline) must be supplied. The recorded channels are the cartesian product of nodes × dofs, labelled node<N>.<resp>.dof<D> in node-major order.

Parameters

sink Output .h5 SWMR sink path. dofs DOF indices (1-based, OpenSees convention). At least one required. nodes Explicit tuple of node tags. Mutually exclusive with pg. pg Physical-group label whose nodes are monitored. Mutually exclusive with nodes; resolved at emit time. resp Nodal response — one of disp / vel / accel / reaction (the fork v1 set). Default disp. every Step decimation — emit a frame every K analysis steps. None records every step. hz Wall-clock throttle — emit at most H frames per second of real time (the first frame always passes). None means no throttle. Independent of every; both may bound the stream.

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).

build_recorder_declaration

build_recorder_declaration(*, ndm: int, ndf: int, nodes: 'Iterable[str] | str' = (), elements: 'Iterable[str] | str' = (), line_stations: 'Iterable[str] | str' = (), gauss: 'Iterable[str] | str' = (), raw_nodes: 'Iterable[str] | str | None' = None, raw_elements: 'Iterable[str] | str | None' = None, raw_line_stations: 'Iterable[str] | str | None' = None, raw_gauss: 'Iterable[str] | str | None' = None, pg: 'str | Iterable[str] | None' = None, label: 'str | Iterable[str] | None' = None, selection: 'str | Iterable[str] | None' = None, ids: 'Iterable[int] | None' = None, dt: float | None = None, n_steps: int | None = None, name: str = 'default', record_name: str | None = None, element_class_name: str | None = None, file_root: str = '.') -> RecorderDeclaration

Construct a :class:RecorderDeclaration from declarative kwargs.

Single source of truth for shorthand expansion ("displacement"displacement_x/y/z via the bound ndm/ndf) and per-category record construction. Shared by :meth:apeGmsh.opensees._internal.ns.recorder._RecorderNS.declare (bridge-owned models) and :meth:apeGmsh.opensees.ModelData.recorders (hand-written decks).

ndm / ndf are supplied by the caller (the bridge or ModelData binds them at declaration time, Phase 9 D8 — the user never repeats ndm=/ndf= here).

Source code in src/apeGmsh/opensees/recorder.py
def build_recorder_declaration(
    *,
    ndm: int,
    ndf: int,
    nodes: "Iterable[str] | str" = (),
    elements: "Iterable[str] | str" = (),
    line_stations: "Iterable[str] | str" = (),
    gauss: "Iterable[str] | str" = (),
    raw_nodes: "Iterable[str] | str | None" = None,
    raw_elements: "Iterable[str] | str | None" = None,
    raw_line_stations: "Iterable[str] | str | None" = None,
    raw_gauss: "Iterable[str] | str | None" = None,
    pg: "str | Iterable[str] | None" = None,
    label: "str | Iterable[str] | None" = None,
    selection: "str | Iterable[str] | None" = None,
    ids: "Iterable[int] | None" = None,
    dt: float | None = None,
    n_steps: int | None = None,
    name: str = "default",
    record_name: str | None = None,
    element_class_name: str | None = None,
    file_root: str = ".",
) -> RecorderDeclaration:
    """Construct a :class:`RecorderDeclaration` from declarative kwargs.

    Single source of truth for shorthand expansion (``"displacement"``
    → ``displacement_x/y/z`` via the bound ``ndm``/``ndf``) and
    per-category record construction.  Shared by
    :meth:`apeGmsh.opensees._internal.ns.recorder._RecorderNS.declare`
    (bridge-owned models) and
    :meth:`apeGmsh.opensees.ModelData.recorders` (hand-written decks).

    ``ndm`` / ``ndf`` are supplied by the caller (the bridge or
    ``ModelData`` binds them at declaration time, Phase 9 D8 — the user
    never repeats ``ndm=``/``ndf=`` here).
    """
    pg_tuple = _normalize_str_selector(pg)
    label_tuple = _normalize_str_selector(label)
    selection_tuple = _normalize_str_selector(selection)
    ids_tuple = tuple(int(i) for i in ids) if ids is not None else None

    records: list[RecorderRecord] = []

    # Per-category record construction. Each category produces at most
    # one record (canonical components + raw tokens combined). A record
    # is skipped only when both are empty for that category.
    category_inputs: tuple[
        tuple[str, "Iterable[str] | str", "Iterable[str] | str | None"], ...
    ] = (
        ("nodes",         nodes,         raw_nodes),
        ("elements",      elements,      raw_elements),
        ("line_stations", line_stations, raw_line_stations),
        ("gauss",         gauss,         raw_gauss),
    )
    for category, canonical_kw, raw_kw in category_inputs:
        canonical_seq = _normalize_str_selector(canonical_kw)
        raw_seq = _normalize_str_selector(raw_kw)
        if not canonical_seq and not raw_seq:
            continue
        components = (
            expand_many(canonical_seq, ndm=ndm, ndf=ndf)
            if canonical_seq else ()
        )
        records.append(
            RecorderRecord(
                category=category,
                components=components,
                raw=raw_seq,
                pg=pg_tuple,
                label=label_tuple,
                selection=selection_tuple,
                ids=ids_tuple,
                dt=dt,
                n_steps=n_steps,
                name=record_name,
                element_class_name=(
                    element_class_name if category != "nodes" else None
                ),
            )
        )

    return RecorderDeclaration(
        records=tuple(records),
        name=name,
        ndm=ndm,
        ndf=ndf,
        file_root=file_root,
    )

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._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()))

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}"
    )