Skip to content

FEM Broker — FEMData

Solver-agnostic snapshot returned by g.mesh.queries.get_fem_data(dim).

Native persistence

FEMData round-trips to a native model.h5 (the neutral zone) without any solver in the loop:

fem = g.mesh.queries.get_fem_data(dim=3)
fem.to_h5("model.h5", model_name="Tower")

restored = FEMData.from_h5("model.h5")     # integrity-checked load

to_h5(path, *, model_name="", apegmsh_version="", ndf=0) writes the snapshot; from_h5(path, *, root="/") reads it back. root= selects a non-root group when the neutral zone lives nested inside a larger file (e.g. the two-zone canonical file written by the bridge). The read is integrity-checked: a snapshot_id mismatch between the written and re-derived content raises MalformedH5Error.

The neutral zone is written at schema NEUTRAL_SCHEMA_VERSION ("2.10.0", defined in mesh/_femdata_h5_io.py); the OpenSees zone written by the bridge carries its own SCHEMA_VERSION ("2.12.0"). Readers honour a two-version compatibility window (ADR 0023).

This is the same neutral zone the session writes via apeGmsh(save_to=...) / g.save() — see the Session page. FEMData.from_h5 is also the entry point apeGmsh.from_h5 and g.compose build on for chain-phase reassembly.

apeGmsh.mesh.FEMData

FEMData — Solver-ready FEM mesh broker.

The main output of apeGmsh's meshing pipeline. Organized by what the engineer needs: Nodes and Elements — with selections, BCs, loads, and masses as sub-composites.

Top-level composites::

fem.nodes       → NodeComposite   (IDs, coords, nodal loads, masses, node constraints)
fem.elements    → ElementComposite (per-type element groups, surface constraints, element loads)
fem.info        → MeshInfo        (mesh statistics)
fem.inspect     → InspectComposite (introspection and summaries)

Construction::

fem = FEMData.from_gmsh(dim=3, session=g, ndf=3)
fem = FEMData.from_gmsh(session=g)          # all dims
fem = FEMData.from_msh("bridge.msh", dim=2)
fem = FEMData(nodes=..., elements=..., info=...)   # direct

Usage::

# Domain nodes — MeshSelection iterates as (node_id, xyz) pairs
for nid, xyz in fem.nodes.select():
    ops.node(nid, *xyz)

# Supports
for nid in fem.nodes.select(pg="Base").ids:
    ops.fix(nid, 1, 1, 1)

# Elements (iterate by type)
for group in fem.elements:
    for eid, conn in group:
        ops.element(group.type_name, eid, *conn, mat_tag)

# Elements (resolve to flat arrays — single type; .resolve() on the
# GroupResult that .result() returns)
ids, conn = fem.elements.select(label="col.web").result().resolve()

# Constraints
K = fem.nodes.constraints.Kind
for c in fem.nodes.constraints.pairs():
    if c.kind == K.RIGID_BEAM:
        ops.rigidLink("beam", c.master_node, c.slave_node)

MeshInfo

MeshInfo(n_nodes: int, n_elems: int, bandwidth: int, types: list[ElementTypeInfo] | None = None)

Read-only summary of mesh statistics.

Accessed via fem.info.

Attributes

n_nodes : int n_elems : int bandwidth : int types : list[ElementTypeInfo] Element types present in the mesh.

Source code in src/apeGmsh/mesh/FEMData.py
def __init__(
    self,
    n_nodes: int,
    n_elems: int,
    bandwidth: int,
    types: list[ElementTypeInfo] | None = None,
) -> None:
    self.n_nodes = n_nodes
    self.n_elems = n_elems
    self.bandwidth = bandwidth
    self.types = types or []

nodes_per_elem property

nodes_per_elem: int

First type's npe, or 0 if empty.

elem_type_name property

elem_type_name: str

First type's name, or empty string.

summary

summary() -> str

One-line summary string.

Source code in src/apeGmsh/mesh/FEMData.py
def summary(self) -> str:
    """One-line summary string."""
    s = f"{self.n_nodes} nodes, {self.n_elems} elements"
    if self.types:
        type_parts = [f"{t.name}:{t.count}" for t in self.types]
        s += f" ({', '.join(type_parts)})"
    s += f", bandwidth={self.bandwidth}"
    return s

NodeComposite

NodeComposite(node_ids: ndarray, node_coords: ndarray, physical: PhysicalGroupSet, labels: LabelSet, constraints=None, loads=None, sp=None, masses=None, partitions: dict[int, dict] | None = None, part_node_map: dict | None = None, ndf: ndarray | None = None, module_label: ndarray | None = None, provenance: ndarray | None = None)

Access and query nodes from the FEM mesh.

Primary interface::

fem.nodes.select(pg="Base")   → MeshSelection (iterates (id, xyz));
                                .result() → NodeResult, .ids, .coords
fem.nodes.select()            → all domain nodes

Sub-composites::

fem.nodes.constraints       → NodeConstraintSet
fem.nodes.loads             → NodalLoadSet
fem.nodes.masses            → MassSet

Public properties for raw array access::

fem.nodes.ids               → ndarray(N,) object dtype
fem.nodes.coords            → ndarray(N, 3) float64
Source code in src/apeGmsh/mesh/FEMData.py
def __init__(
    self,
    node_ids: ndarray,
    node_coords: ndarray,
    physical: PhysicalGroupSet,
    labels: LabelSet,
    constraints=None,
    loads=None,
    sp=None,
    masses=None,
    partitions: dict[int, dict] | None = None,
    part_node_map: dict | None = None,
    ndf: ndarray | None = None,
    module_label: ndarray | None = None,
    provenance: ndarray | None = None,
) -> None:
    self._ids    = _to_object(node_ids)
    self._coords = np.asarray(node_coords, dtype=np.float64)
    self.physical = physical
    self.labels   = labels

    self.constraints = NodeConstraintSet(constraints)
    self.loads       = NodalLoadSet(loads)
    self.sp          = SPSet(sp)
    self.masses      = MassSet(masses)

    self._partitions: dict[int, dict] = partitions or {}
    self._id_to_idx: dict[int, int] | None = None
    # Snapshot of ``g.parts.build_node_map(...)`` at FEM-build
    # time — lets ``get(target=part_label)`` resolve without
    # needing a live Gmsh session (parts registry may be gone
    # by the time the user queries). Dict of ``str -> set[int]``.
    self._part_node_map: dict[str, set[int]] = part_node_map or {}

    # Per-node ``ndf`` (DOF count) — int8 array aligned 1:1 with
    # ``self._ids``.  Sentinel ``0`` means "undeclared".  In the
    # current model (ADR 0048/0049) per-node ndf is **inferred** by
    # the ``apeSees`` bridge from the incident element classes, with
    # ``ops.ndf(handle, ndf=K)`` the one explicit channel for an
    # element-less decoupled node; this array is only populated when
    # such metadata is supplied.  ``None`` means the broker carries
    # no per-node ndf metadata (e.g. a direct test fixture).
    self._ndf: ndarray | None
    if ndf is None:
        self._ndf = None
    else:
        arr = np.asarray(ndf, dtype=np.int8)
        if arr.shape != self._ids.shape:
            raise ValueError(
                f"NodeComposite: ndf array shape {arr.shape} does "
                f"not match node_ids shape {self._ids.shape}."
            )
        self._ndf = arr

    # Per-node ``module_label`` (Phase 3B.2c / ADR 0038) — object
    # ndarray of compose labels aligned 1:1 with ``self._ids``.
    # Empty string for host-owned rows; populated by the compose
    # merge engine for rows that came from a composed source
    # module.  ``None`` means the broker was constructed without
    # any module-label metadata (the uncomposed case + legacy
    # fixtures); the H5 writer falls back to an empty-string
    # parallel dataset in that case.
    self._module_label: ndarray | None
    if module_label is None:
        self._module_label = None
    else:
        mlbl = np.asarray(module_label, dtype=object)
        if mlbl.shape != self._ids.shape:
            raise ValueError(
                f"NodeComposite: module_label array shape "
                f"{mlbl.shape} does not match node_ids shape "
                f"{self._ids.shape}."
            )
        self._module_label = mlbl

    # Per-node ``provenance`` (decoupled nodes — ADR 0049) — int8
    # array aligned 1:1 with ``self._ids``.  ``PROVENANCE_MESH`` (0)
    # = an ordinary Gmsh-vertex node; ``PROVENANCE_DECOUPLED`` (1) =
    # an auxiliary node declared via ``g.decouple_node(...)`` and
    # appended by the FEM factory above every mesh tag (dedup-immune
    # by construction).  ``None`` means the broker carries no
    # decoupled nodes (the common case + every import path that has
    # no notion of them); the all-mesh case is encoded as ``None``
    # so the snapshot_id hash + H5 bytes stay identical to a model
    # with no decoupled nodes at all (see ``_femdata_hash``).
    self._provenance: ndarray | None
    if provenance is None:
        self._provenance = None
    else:
        prov = np.asarray(provenance, dtype=np.int8)
        if prov.shape != self._ids.shape:
            raise ValueError(
                f"NodeComposite: provenance array shape "
                f"{prov.shape} does not match node_ids shape "
                f"{self._ids.shape}."
            )
        self._provenance = prov

ids property

ids: ndarray

All domain node IDs. ndarray(N,) object dtype.

coords property

coords: ndarray

All domain node coordinates. ndarray(N, 3) float64.

module_label property

module_label: ndarray | None

Per-node compose labels aligned 1:1 with :attr:ids.

Object ndarray of source-module labels (empty string for host-owned rows), populated by the g.compose merge engine (ADR 0038 §"Schema"). None when the broker carries no module-label metadata (the uncomposed case). Read-only view of the underlying array; consumers must not mutate it.

provenance property

provenance: ndarray | None

Per-node provenance aligned 1:1 with :attr:ids (ADR 0049).

int8 array where PROVENANCE_MESH (0) marks an ordinary Gmsh-vertex node and PROVENANCE_DECOUPLED (1) marks an auxiliary node declared via g.decouple_node(...). None when the broker carries no decoupled nodes (the common case). Read-only view; consumers must not mutate it.

decoupled_ids property

decoupled_ids: ndarray

Node IDs whose provenance is decoupled (ADR 0049).

Empty ndarray when the broker carries no decoupled nodes.

partitions property

partitions: list[int]

Sorted list of partition IDs (empty if not partitioned).

select

select(target=None, *, pg=None, label=None, tag=None, partition: int | None = None, dim: int | None = None, ids=None)

Select a subset of nodes from this FEM snapshot.

Returns a :class:~apeGmsh.mesh._mesh_selection.MeshSelection (point family — .in_box tests node coordinates) that chains spatial-refinement verbs and terminates at .ids / .coords / .result()::

# seed by PG, read bulk arrays directly
base = fem.nodes.select(pg="Base")
base.ids      # list[int]
base.coords   # ndarray (N, 3)

# chain verbs, then drive a loop or feed ops
for nid, xyz in fem.nodes.select(pg="Base").result():
    ops.node(nid, *xyz)

# spatial narrowing
corner = (fem.nodes.select(pg="Body")
              .in_box((0, 0, 0), (1, 1, 1))
              .on_plane((0, 0, 0), (0, 0, 1), tol=1e-6))

# set algebra
all_bcs = (fem.nodes.select(pg="Base")
           | fem.nodes.select(pg="Wall"))

No arguments seeds every domain node.

.. note:: Point family.in_box tests node coordinates against a half-open box [lo, hi) (pass inclusive=True for the closed box). For geometry-level selection use :meth:g.model.select.

Parameters

target : Label name, physical group name, part name, (dim, tag) pair, raw int tag, or a list thereof. A string resolves through label → PG → part name in that order. pg : Physical group name or list of names. label : Geometry-time label name or list. Labels survive boolean operations. tag : Raw physical group tag (int or list). partition : Restrict to nodes that belong to this partition number. dim : Restrict to nodes on entities of this topological dimension (0=point, 1=curve, 2=surface, 3=volume). ids : Explicit node id list. When given, all other selectors are ignored.

Refining verbs

Each returns a new MeshSelection and composes freely:

  • .in_box(lo, hi, *, inclusive=False) — half-open [lo, hi) by default; inclusive=True for [lo, hi].
  • .in_sphere(center, radius)
  • .on_plane(point, normal, *, tol)tol= is required; raises TypeError if omitted.
  • .nearest_to(point, *, count=1)
  • .where(predicate) — callable xyz → bool.
  • | & - ^ (set algebra).
Terminals
  • .idslist[int] of selected node IDs.
  • .coordsndarray (N, 3) of coordinates.
  • .result() → :class:~apeGmsh._kernel.payloads.NodeResult; iterate as (nid, xyz) pairs, read .ids / .coords arrays, or call .to_dataframe().
Source code in src/apeGmsh/mesh/FEMData.py
def select(
    self,
    target=None,
    *,
    pg=None,
    label=None,
    tag=None,
    partition: int | None = None,
    dim: int | None = None,
    ids=None,
):
    """Select a subset of nodes from this FEM snapshot.

    Returns a :class:`~apeGmsh.mesh._mesh_selection.MeshSelection`
    (point family — ``.in_box`` tests node **coordinates**) that
    chains spatial-refinement verbs and terminates at ``.ids`` /
    ``.coords`` / ``.result()``::

        # seed by PG, read bulk arrays directly
        base = fem.nodes.select(pg="Base")
        base.ids      # list[int]
        base.coords   # ndarray (N, 3)

        # chain verbs, then drive a loop or feed ops
        for nid, xyz in fem.nodes.select(pg="Base").result():
            ops.node(nid, *xyz)

        # spatial narrowing
        corner = (fem.nodes.select(pg="Body")
                      .in_box((0, 0, 0), (1, 1, 1))
                      .on_plane((0, 0, 0), (0, 0, 1), tol=1e-6))

        # set algebra
        all_bcs = (fem.nodes.select(pg="Base")
                   | fem.nodes.select(pg="Wall"))

    No arguments seeds every domain node.

    .. note::
        **Point family** — ``.in_box`` tests node coordinates
        against a half-open box ``[lo, hi)`` (pass
        ``inclusive=True`` for the closed box).  For
        geometry-level selection use :meth:`g.model.select`.

    Parameters
    ----------
    target :
        Label name, physical group name, part name,
        ``(dim, tag)`` pair, raw int tag, or a list thereof.
        A string resolves through label → PG → part name in
        that order.
    pg :
        Physical group name or list of names.
    label :
        Geometry-time label name or list.  Labels survive
        boolean operations.
    tag :
        Raw physical group tag (int or list).
    partition :
        Restrict to nodes that belong to this partition number.
    dim :
        Restrict to nodes on entities of this topological
        dimension (0=point, 1=curve, 2=surface, 3=volume).
    ids :
        Explicit node id list.  When given, all other
        selectors are ignored.

    Refining verbs
    --------------
    Each returns a new ``MeshSelection`` and composes freely:

    - ``.in_box(lo, hi, *, inclusive=False)`` — half-open
      ``[lo, hi)`` by default; ``inclusive=True`` for
      ``[lo, hi]``.
    - ``.in_sphere(center, radius)``
    - ``.on_plane(point, normal, *, tol)`` — ``tol=`` is
      **required**; raises ``TypeError`` if omitted.
    - ``.nearest_to(point, *, count=1)``
    - ``.where(predicate)`` — callable ``xyz → bool``.
    - ``|`` ``&`` ``-`` ``^`` (set algebra).

    Terminals
    ---------
    - ``.ids`` — ``list[int]`` of selected node IDs.
    - ``.coords`` — ``ndarray (N, 3)`` of coordinates.
    - ``.result()`` → :class:`~apeGmsh._kernel.payloads.NodeResult`;
      iterate as ``(nid, xyz)`` pairs, read ``.ids`` / ``.coords``
      arrays, or call ``.to_dataframe()``.
    """
    # selection-unification-v2: the host hook returns the v2
    # terminal ``MeshSelection`` (the point-family chain==terminal).
    # Same deferred-import idiom — ``_mesh_selection`` imports only
    # the package-root leaf ``_kernel.chain`` at load, so no new
    # eager cross-package edge (one declared downward BASELINE
    # triple; tripwire stays green).
    from ._mesh_selection import MeshSelection  # deferred — plan §3

    if ids is not None:
        atoms = [int(n) for n in ids]
    elif (
        target is None
        and pg is None
        and label is None
        and tag is None
        and partition is None
        and dim is None
    ):
        atoms = [int(n) for n in self._ids]
    else:
        seed_ids, seed_coords = self._resolve_nodes(
            target, pg=pg, label=label, tag=tag, dim=dim
        )
        if partition is not None:
            seed_ids, seed_coords = self._intersect_partition(
                seed_ids, seed_coords, partition
            )
        atoms = [int(n) for n in seed_ids]
    return MeshSelection(atoms, _engine=self)

index

index(nid: int) -> int

Array index for a node ID. O(1) after first call.

Source code in src/apeGmsh/mesh/FEMData.py
def index(self, nid: int) -> int:
    """Array index for a node ID.  O(1) after first call."""
    if self._id_to_idx is None:
        self._id_to_idx = {
            int(n): i for i, n in enumerate(self._ids)}
    try:
        return self._id_to_idx[int(nid)]
    except KeyError:
        if len(self._ids) > 0:
            msg = (f"Node ID {nid} not found. "
                   f"Valid range: {int(self._ids.min())}-"
                   f"{int(self._ids.max())} "
                   f"({len(self._ids)} nodes)")
        else:
            msg = f"Node ID {nid} not found (no nodes)"
        raise KeyError(msg) from None

ndf_for

ndf_for(nid: int) -> int

Return the per-node ndf (DOF count) for nid, if stored.

Per-node ndf is inferred by the apeSees bridge from the incident element classes (ADR 0048); an element-less decoupled node states it explicitly with ops.ndf(handle, ndf=K) (ADR 0049). This accessor only returns a value when such metadata was attached to the snapshot; the broker does not infer ndf on its own.

Raises

KeyError If nid is not a known node ID. LookupError If nid exists but the snapshot carries no ndf for it — the bridge infers it from elements (or, for an element-less node, ops.ndf(handle, ndf=K)).

Source code in src/apeGmsh/mesh/FEMData.py
def ndf_for(self, nid: int) -> int:
    """Return the per-node ``ndf`` (DOF count) for *nid*, if stored.

    Per-node ``ndf`` is **inferred** by the ``apeSees`` bridge from
    the incident element classes (ADR 0048); an element-less
    decoupled node states it explicitly with ``ops.ndf(handle,
    ndf=K)`` (ADR 0049).  This accessor only returns a value when
    such metadata was attached to the snapshot; the broker does not
    infer ndf on its own.

    Raises
    ------
    KeyError
        If ``nid`` is not a known node ID.
    LookupError
        If ``nid`` exists but the snapshot carries no ndf for it —
        the bridge infers it from elements (or, for an element-less
        node, ``ops.ndf(handle, ndf=K)``).
    """
    idx = self.index(nid)
    if self._ndf is None:
        raise LookupError(
            f"node {nid}: ndf not present on this snapshot — the "
            f"apeSees bridge infers it from incident element classes; "
            f"for an element-less decoupled node use ops.ndf(handle, ndf=K)."
        )
    val = int(self._ndf[idx])
    if val == 0:
        raise LookupError(
            f"node {nid}: ndf not present on this snapshot — the "
            f"apeSees bridge infers it from incident element classes; "
            f"for an element-less decoupled node use ops.ndf(handle, ndf=K)."
        )
    return val

ElementComposite

ElementComposite(groups: dict[int, ElementGroup], physical: PhysicalGroupSet, labels: LabelSet, constraints=None, loads=None, partitions: dict[int, dict] | None = None, part_elem_map: dict | None = None, module_label: dict[int, ndarray] | None = None, reinforce_ties=None, embed_ties=None, contacts=None, contact_planes=None, rebar_elements=None, interfaces=None)

Access and query elements from the FEM mesh.

Iterable — yields ElementGroup objects::

for group in fem.elements:
    print(group.type_name, len(group))

Selection API::

result = fem.elements.select(label="col.web").result()
ids, conn = result.resolve()           # single-type
ids, conn = result.resolve(element_type='tet4')  # pick one

Sub-composites::

fem.elements.constraints     → SurfaceConstraintSet
fem.elements.loads           → ElementLoadSet
Source code in src/apeGmsh/mesh/FEMData.py
def __init__(
    self,
    groups: dict[int, ElementGroup],
    physical: PhysicalGroupSet,
    labels: LabelSet,
    constraints=None,
    loads=None,
    partitions: dict[int, dict] | None = None,
    part_elem_map: dict | None = None,
    module_label: dict[int, ndarray] | None = None,
    reinforce_ties=None,
    embed_ties=None,
    contacts=None,
    contact_planes=None,
    rebar_elements=None,
    interfaces=None,
) -> None:
    self._groups: dict[int, ElementGroup] = dict(groups)
    self.physical = physical
    self.labels   = labels

    self.constraints = SurfaceConstraintSet(constraints)
    self.loads       = ElementLoadSet(loads)

    # Embedded-reinforcement ties (g.reinforce, ADR 20 / R2b). A plain
    # list of ReinforceTieRecord — one LadrunoEmbeddedRebar coupling
    # per rebar node. The bridge build step consumes it
    # (opensees._internal.build.emit_reinforce_ties); it also round-trips
    # through the neutral model.h5 (/reinforce_ties group, neutral schema
    # 2.15.0, ADR 0067 P5.1). A model with no ties omits the group, so its
    # snapshot stays byte-identical (the snapshot_id hash excludes ties).
    self.reinforce_ties: list = list(reinforce_ties or [])

    # General node-to-host embedment ties (g.embed). A plain list of
    # EmbedTieRecord — one LadrunoEmbeddedNode coupling per node. The
    # bridge build step consumes it
    # (opensees._internal.build.emit_embed_ties); it also round-trips
    # through the neutral model.h5 (/embed_ties group, neutral schema
    # 2.22.0, ADR 0073). A model with no ties omits the group, so its
    # snapshot stays byte-identical (the snapshot_id hash excludes ties).
    self.embed_ties: list = list(embed_ties or [])

    # Face-to-face contact interactions (g.constraints.contact). A plain
    # list of ContactRecord. Consumed by
    # opensees._internal.build.emit_contacts; serial-only; it also
    # round-trips through the neutral model.h5 (/contacts group, neutral
    # schema 2.21.0, ADR 0073), mirroring /contact_planes. A model with no
    # contacts omits the group, so its snapshot stays byte-identical.
    self.contacts: list = list(contacts or [])

    # Rigid analytical-plane contacts (g.constraints.contact_plane). A plain
    # list of ContactPlaneRecord — one fork `contactPlane` per record.
    # Consumed by opensees._internal.build.emit_contact_planes; serial-only;
    # round-trips through the neutral model.h5 (/contact_planes group),
    # mirroring /contacts.
    self.contact_planes: list = list(contact_planes or [])
    # Oriented coincident-pair zeroLength interfaces
    # (g.constraints.interface, ADR 0093). A plain list of
    # InterfaceRecord — one zeroLength spring per coincident node
    # pair. Consumed by opensees._internal.build.emit_interfaces
    # (S5); serial-only until S8. ADR 0093 S3: the resolver/verb
    # that populates this list don't exist yet (S4), so it is
    # always empty for now — the slot exists so downstream code has
    # somewhere to look. h5 persistence is refused loudly
    # (write_neutral_zone) rather than silently dropped until S6
    # lands the payload dtype + compose support.
    self.interfaces: list = list(interfaces or [])
    # Structural rebar elements (ADR 0067 P5.2 / B1): the cage's
    # auto-emitted CorotTruss/dispBeamColumn intents from
    # g.rebar.place(emit_elements=True). A plain list of
    # RebarElementRecord — one per placed bar PG. The bridge build step
    # consumes it (opensees._internal.build.emit_rebar_elements), fanning
    # each across its PG's line cells. Empty unless emit_elements was set.
    self.rebar_elements: list = list(rebar_elements or [])

    self._partitions: dict[int, dict] = partitions or {}

    # Lazy caches
    self._cached_ids: ndarray | None = None
    self._id_to_idx: dict[int, int] | None = None

    # Snapshot of ``part_label -> set[element_id]`` built at
    # FEM-build time. Lets ``get(target=part_label)`` resolve
    # without a live Gmsh session.
    self._part_elem_map: dict[str, set[int]] = part_elem_map or {}

    # Per-element ``module_label`` (Phase 3B.2c / ADR 0038) —
    # dict keyed by element-type code, each value an object
    # ndarray of compose labels aligned 1:1 with the type's
    # ``ids`` / ``connectivity`` rows.  Empty string for
    # host-owned rows.  ``None`` (or missing key) means no
    # module-label metadata is carried for that type; the H5
    # writer falls back to empty strings.
    self._module_label: dict[int, ndarray] | None
    if module_label is None:
        self._module_label = None
    else:
        self._module_label = {}
        for code, arr in module_label.items():
            if arr is None:
                continue
            mlbl = np.asarray(arr, dtype=object)
            grp = self._groups.get(int(code))
            expected = grp.ids.shape if grp is not None else None
            if expected is not None and mlbl.shape != expected:
                raise ValueError(
                    f"ElementComposite: module_label[{code}] shape "
                    f"{mlbl.shape} does not match ids shape "
                    f"{expected}."
                )
            self._module_label[int(code)] = mlbl

ids property

ids: ndarray

All element IDs concatenated. ndarray(E,) int64.

connectivity property

connectivity: ndarray

Flat connectivity — only if all elements are the same type.

Raises

TypeError If multiple element types are present.

module_label property

module_label: 'dict[int, ndarray] | None'

Per-element compose labels keyed by element-type code.

Each value is an object ndarray aligned 1:1 with that type's group ids (empty string for host-owned rows), populated by the g.compose merge engine (ADR 0038 §"Schema"). None when the broker carries no module-label metadata (the uncomposed case). Read-only view; consumers must not mutate.

types property

types: list[ElementTypeInfo]

Element types present in the mesh.

partitions property

partitions: list[int]

Sorted list of partition IDs.

is_homogeneous property

is_homogeneous: bool

True if all elements are the same type.

module_label_by_id

module_label_by_id() -> 'dict[int, str] | None'

Flat element-id -> compose label map across all types.

Concatenates the per-type :attr:module_label arrays against each type's ids. None when no module-label metadata is carried (the uncomposed case). Used by the split-emit path to bucket each element into its owning module fragment.

Source code in src/apeGmsh/mesh/FEMData.py
def module_label_by_id(self) -> "dict[int, str] | None":
    """Flat ``element-id -> compose label`` map across all types.

    Concatenates the per-type :attr:`module_label` arrays against
    each type's ``ids``.  ``None`` when no module-label metadata is
    carried (the uncomposed case).  Used by the split-emit path to
    bucket each element into its owning module fragment.
    """
    if self._module_label is None:
        return None
    out: dict[int, str] = {}
    for code, grp in self._groups.items():
        labels = self._module_label.get(int(code))
        if labels is None:
            # No metadata for this type — host-owned by convention.
            for eid in grp.ids:
                out[int(eid)] = ""
            continue
        for eid, lbl in zip(grp.ids, labels):
            out[int(eid)] = str(lbl)
    return out

type_table

type_table() -> 'pd.DataFrame'

DataFrame of element types in the mesh.

Source code in src/apeGmsh/mesh/FEMData.py
def type_table(self) -> "pd.DataFrame":
    """DataFrame of element types in the mesh."""
    import pandas as pd
    rows = []
    for g in self._groups.values():
        t = g.element_type
        rows.append({
            'code': t.code,
            'name': t.name,
            'gmsh_name': t.gmsh_name,
            'dim': t.dim,
            'order': t.order,
            'npe': t.npe,
            'count': t.count,
        })
    return pd.DataFrame(rows)

select

select(target=None, *, pg=None, label=None, tag=None, dim: int | None = None, element_type: str | int | None = None, partition: int | None = None, ids=None)

Select a subset of elements from this FEM snapshot.

Returns a :class:~apeGmsh.mesh._mesh_selection.MeshSelection (point family — spatial verbs test element centroids) that chains spatial-refinement verbs and terminates at .ids / .connectivity / .result()::

# seed by PG, read element ids
body = fem.elements.select(pg="Body")
body.ids          # list[int]
body.connectivity # ndarray (N, npe) — homogeneous mesh only

# filter to one element type in a spatial region
tets = (fem.elements.select(pg="Body", element_type="tet4")
            .in_box((0, 0, 0), (5, 5, 3)))

# mixed mesh — call .resolve() on the GroupResult
gr = fem.elements.select(label="col.web").result()
ids, conn = gr.resolve()                      # single type
ids, conn = gr.resolve(element_type="hex8")   # pick from mixed

No arguments seeds every element.

.. note:: Point family.in_box tests element centroids against a half-open box [lo, hi) (pass inclusive=True for the closed box). For geometry-level selection use :meth:g.model.select.

Parameters

target : Label name, physical group name, part name, (dim, tag) pair, raw int tag, or a list thereof. A string resolves through label → PG → part name in that order. pg : Physical group name or list of names. label : Geometry-time label name or list. Labels survive boolean operations. tag : Raw physical group tag (int or list). dim : Restrict to elements of this topological dimension (1=line, 2=surface, 3=volume). element_type : Restrict to a specific element type by name (e.g. "tet4", "hex8") or Gmsh type code (int). partition : Restrict to elements belonging to this partition number. ids : Explicit element id list. When given, all other selectors are ignored.

Refining verbs

Each returns a new MeshSelection and composes freely (spatial verbs test element centroids):

  • .in_box(lo, hi, *, inclusive=False) — half-open [lo, hi) by default; inclusive=True for [lo, hi].
  • .in_sphere(center, radius)
  • .on_plane(point, normal, *, tol)tol= is required; raises TypeError if omitted.
  • .nearest_to(point, *, count=1)
  • .where(predicate) — callable centroid_xyz → bool.
  • | & - ^ (set algebra).
Terminals
  • .idslist[int] of selected element IDs.
  • .coordsndarray (N, 3) of element centroids.
  • .connectivityndarray connectivity; homogeneous selections only (raises TypeError on mixed element types).
  • .groups() / .result() → :class:~apeGmsh._kernel.payloads.GroupResult. Call .resolve() on the GroupResult to get (ids, connectivity); pass element_type= to pick one type from a mixed selection.
Source code in src/apeGmsh/mesh/FEMData.py
def select(
    self,
    target=None,
    *,
    pg=None,
    label=None,
    tag=None,
    dim: int | None = None,
    element_type: str | int | None = None,
    partition: int | None = None,
    ids=None,
):
    """Select a subset of elements from this FEM snapshot.

    Returns a :class:`~apeGmsh.mesh._mesh_selection.MeshSelection`
    (point family — spatial verbs test element **centroids**) that
    chains spatial-refinement verbs and terminates at ``.ids`` /
    ``.connectivity`` / ``.result()``::

        # seed by PG, read element ids
        body = fem.elements.select(pg="Body")
        body.ids          # list[int]
        body.connectivity # ndarray (N, npe) — homogeneous mesh only

        # filter to one element type in a spatial region
        tets = (fem.elements.select(pg="Body", element_type="tet4")
                    .in_box((0, 0, 0), (5, 5, 3)))

        # mixed mesh — call .resolve() on the GroupResult
        gr = fem.elements.select(label="col.web").result()
        ids, conn = gr.resolve()                      # single type
        ids, conn = gr.resolve(element_type="hex8")   # pick from mixed

    No arguments seeds every element.

    .. note::
        **Point family** — ``.in_box`` tests element centroids
        against a half-open box ``[lo, hi)`` (pass
        ``inclusive=True`` for the closed box).  For
        geometry-level selection use :meth:`g.model.select`.

    Parameters
    ----------
    target :
        Label name, physical group name, part name,
        ``(dim, tag)`` pair, raw int tag, or a list thereof.
        A string resolves through label → PG → part name in
        that order.
    pg :
        Physical group name or list of names.
    label :
        Geometry-time label name or list.  Labels survive
        boolean operations.
    tag :
        Raw physical group tag (int or list).
    dim :
        Restrict to elements of this topological dimension
        (1=line, 2=surface, 3=volume).
    element_type :
        Restrict to a specific element type by name (e.g.
        ``"tet4"``, ``"hex8"``) or Gmsh type code (int).
    partition :
        Restrict to elements belonging to this partition
        number.
    ids :
        Explicit element id list.  When given, all other
        selectors are ignored.

    Refining verbs
    --------------
    Each returns a new ``MeshSelection`` and composes freely
    (spatial verbs test element **centroids**):

    - ``.in_box(lo, hi, *, inclusive=False)`` — half-open
      ``[lo, hi)`` by default; ``inclusive=True`` for
      ``[lo, hi]``.
    - ``.in_sphere(center, radius)``
    - ``.on_plane(point, normal, *, tol)`` — ``tol=`` is
      **required**; raises ``TypeError`` if omitted.
    - ``.nearest_to(point, *, count=1)``
    - ``.where(predicate)`` — callable ``centroid_xyz → bool``.
    - ``|`` ``&`` ``-`` ``^`` (set algebra).

    Terminals
    ---------
    - ``.ids`` — ``list[int]`` of selected element IDs.
    - ``.coords`` — ``ndarray (N, 3)`` of element centroids.
    - ``.connectivity`` — ``ndarray`` connectivity; **homogeneous
      selections only** (raises ``TypeError`` on mixed element
      types).
    - ``.groups()`` / ``.result()`` →
      :class:`~apeGmsh._kernel.payloads.GroupResult`.
      Call ``.resolve()`` **on the** ``GroupResult`` to get
      ``(ids, connectivity)``; pass ``element_type=`` to pick
      one type from a mixed selection.
    """
    # selection-unification-v2: the host hook returns the v2
    # terminal ``MeshSelection`` (the point-family chain==terminal).
    # Same deferred-import idiom; no new eager cross-package edge.
    from ._mesh_selection import MeshSelection  # deferred — plan §3

    if ids is not None:
        atoms = [int(e) for e in ids]
    elif (
        target is None
        and pg is None
        and label is None
        and tag is None
        and dim is None
        and element_type is None
        and partition is None
    ):
        atoms = [int(e) for e in self.ids]
    elif dim is None and element_type is None and partition is None:
        # Pure name/target seed — delegate to the exact resolver
        # `.get()` uses (FP-4 element-path swallow preserved by
        # reuse).  `None` means "all" (no PG/label/tag/target).
        id_set = self._resolve_elem_ids(
            target, pg=pg, label=label, tag=tag
        )
        atoms = (
            [int(e) for e in self.ids]
            if id_set is None
            else [int(e) for e in id_set]
        )
    else:
        # Auxiliary dim/element_type/partition filter present —
        # reuse the verbatim filter helper `_filtered_groups`
        # (selection-unification v2 P3-R / §6.3 M-STOP-1: the exact
        # body the now-removed public `get` used), so select(...)
        # stays byte-identical to the pre-P3-R get(...) path.
        atoms = [
            int(e)
            for e in self._filtered_groups(
                target, pg=pg, label=label, tag=tag, dim=dim,
                element_type=element_type, partition=partition,
            ).ids
        ]
    return MeshSelection(atoms, _engine=self)

index

index(eid: int) -> int

Array index for an element ID. O(1) after first call.

Source code in src/apeGmsh/mesh/FEMData.py
def index(self, eid: int) -> int:
    """Array index for an element ID.  O(1) after first call."""
    if self._id_to_idx is None:
        self._id_to_idx = {
            int(e): i for i, e in enumerate(self.ids)}
    try:
        return self._id_to_idx[int(eid)]
    except KeyError:
        ids = self.ids
        if len(ids) > 0:
            msg = (f"Element ID {eid} not found. "
                   f"Valid range: {int(ids.min())}-"
                   f"{int(ids.max())} "
                   f"({len(ids)} elements)")
        else:
            msg = f"Element ID {eid} not found (no elements)"
        raise KeyError(msg) from None

InspectComposite

InspectComposite(fem: 'FEMData')

Introspection and summary methods.

Accessed via fem.inspect.

Source code in src/apeGmsh/mesh/FEMData.py
def __init__(self, fem: "FEMData") -> None:
    self._fem = fem

summary

summary() -> str

One-line mesh summary plus sub-composite counts.

Source code in src/apeGmsh/mesh/FEMData.py
def summary(self) -> str:
    """One-line mesh summary plus sub-composite counts."""
    f = self._fem
    lines = [f.info.summary()]

    # Physical groups
    pg = f.nodes.physical
    if pg:
        lines.append(f"  Physical groups ({len(pg)}):")
        for (d, t), info in sorted(pg._groups.items()):
            name = info.get('name', '')
            n_n = len(info['node_ids'])
            eids = info.get('element_ids')
            n_e = len(eids) if eids is not None else 0
            lbl = f'"{name}"' if name else f"tag={t}"
            parts = f"{n_n} nodes"
            if n_e:
                parts += f", {n_e} elems"
            lines.append(f"    ({d}) {lbl:24s} {parts}")

    # Labels
    lb = f.nodes.labels
    if lb:
        lines.append(f"  Labels ({len(lb)}):")
        for (d, t), info in sorted(lb._groups.items()):
            name = info.get('name', '')
            n_n = len(info['node_ids'])
            eids = info.get('element_ids')
            n_e = len(eids) if eids is not None else 0
            parts = f"{n_n} nodes"
            if n_e:
                parts += f", {n_e} elems"
            lines.append(f"    ({d}) {name!r:24s} {parts}")

    # Element types
    if f.info.types:
        lines.append(f"  Element types ({len(f.info.types)}):")
        for etype in f.info.types:
            lines.append(
                f"    {etype.name:12s} dim={etype.dim}, "
                f"order={etype.order}, npe={etype.npe}, "
                f"count={etype.count}")

    # Constraints
    nc = f.nodes.constraints
    sc = f.elements.constraints
    if nc:
        lines.append(f"  Node constraints: {nc!r}")
    if sc:
        lines.append(f"  Surface constraints: {sc!r}")
    if f.nodes.loads:
        lines.append(f"  Nodal loads: {f.nodes.loads!r}")
    if f.elements.loads:
        lines.append(f"  Element loads: {f.elements.loads!r}")
    if f.nodes.masses:
        lines.append(f"  {f.nodes.masses!r}")

    return "\n".join(lines)

node_table

node_table() -> 'pd.DataFrame'

DataFrame of all nodes.

Source code in src/apeGmsh/mesh/FEMData.py
def node_table(self) -> "pd.DataFrame":
    """DataFrame of all nodes."""
    import pandas as pd
    f = self._fem
    return pd.DataFrame(
        f.nodes.coords,
        index=pd.Index(
            [int(x) for x in f.nodes.ids], name='node_id'),
        columns=['x', 'y', 'z'],
    )

element_table

element_table() -> 'pd.DataFrame'

DataFrame of all elements with a type column.

Source code in src/apeGmsh/mesh/FEMData.py
def element_table(self) -> "pd.DataFrame":
    """DataFrame of all elements with a ``type`` column."""
    import pandas as pd
    rows = []
    for group in self._fem.elements:
        for eid, conn_row in group:
            row: dict = {'elem_id': eid, 'type': group.type_name}
            for j, nid in enumerate(conn_row):
                row[f'n{j}'] = int(nid)
            rows.append(row)
    return pd.DataFrame(rows).set_index('elem_id')

constraint_summary

constraint_summary() -> str

Human-readable breakdown of all constraints.

Source code in src/apeGmsh/mesh/FEMData.py
def constraint_summary(self) -> str:
    """Human-readable breakdown of all constraints."""
    f = self._fem
    lines = []

    def _kind_summary(record_set, header):
        if not record_set:
            return
        lines.append(f"{header} ({len(record_set)} records):")
        counts: dict[str, int] = {}
        names: dict[str, str] = {}
        for r in record_set:
            k = r.kind
            counts[k] = counts.get(k, 0) + 1
            if k not in names and getattr(r, 'name', None):
                names[k] = r.name
        for k, count in sorted(counts.items()):
            hint = f"  (source: {names[k]!r})" if k in names else ""
            lines.append(f"  {k:24s} {count:>4d}{hint}")

    _kind_summary(f.nodes.constraints, "Node constraints")
    nc = f.nodes.constraints
    if nc:
        n_phantom = len(nc.phantom_nodes())
        if n_phantom:
            lines.append(
                f"  {'phantom nodes':24s} {n_phantom:>4d}"
                f"  (created by node_to_surface)")
    _kind_summary(f.elements.constraints, "Surface constraints")

    if not lines:
        return "No constraints."
    return "\n".join(lines)

load_summary

load_summary() -> str

Human-readable breakdown of all loads.

Source code in src/apeGmsh/mesh/FEMData.py
def load_summary(self) -> str:
    """Human-readable breakdown of all loads."""
    f = self._fem
    lines = []

    nl = f.nodes.loads
    if nl:
        lines.append(f"Nodal loads ({len(nl)} records):")
        for pat in nl.patterns():
            recs = nl.by_pattern(pat)
            name_hint = ""
            for r in recs:
                if getattr(r, 'name', None):
                    name_hint = f"  (source: {r.name!r})"
                    break
            lines.append(
                f"  Pattern {pat!r:16s} {len(recs):>4d} "
                f"nodal{name_hint}")

    el = f.elements.loads
    if el:
        lines.append(f"Element loads ({len(el)} records):")
        for pat in el.patterns():
            erecs = el.by_pattern(pat)
            name_hint = ""
            for er in erecs:
                if getattr(er, 'name', None):
                    name_hint = f"  (source: {er.name!r})"
                    break
            ltype = getattr(erecs[0], 'load_type', 'element') if erecs else 'element'
            lines.append(
                f"  Pattern {pat!r:16s} {len(erecs):>4d} "
                f"{ltype}{name_hint}")

    if not lines:
        return "No loads."
    return "\n".join(lines)

mass_summary

mass_summary() -> str

Human-readable breakdown of masses.

Source code in src/apeGmsh/mesh/FEMData.py
def mass_summary(self) -> str:
    """Human-readable breakdown of masses."""
    f = self._fem
    ms = f.nodes.masses
    if not ms:
        return "No masses."
    lines = [f"Nodal masses ({len(ms)} nodes):"]
    lines.append(f"  Total mass: {ms.total_mass():.6g}")
    for r in ms:
        if getattr(r, 'name', None):
            lines.append(f"  Source: {r.name!r}")
            break
    return "\n".join(lines)

find_coincident_node_pairs

find_coincident_node_pairs(*, tol: float = 1e-06, pg: str | None = None) -> dict[tuple[int, int], list[str]]

Find distinct nodes that share an XYZ within tolerance.

Opt-in diagnostic for suspect topology — most commonly the arc-line junction case, where OCC builds an arc-bounded wire without welding the arc endpoints onto the joining line's point tags. The mesh then carries two distinct nodes at every junction with no element or constraint bridging them.

Returns a dict mapping each coincident pair (tag_a, tag_b) — sorted so tag_a < tag_b — to a list of references that touch the pair:

  • "element <type>#<eid>" — both nodes appear in the same element's connectivity (legitimate for zeroLength, tied interfaces, etc.)
  • "constraint <kind>" — equalDOF / rigidLink / diaphragm / kinematic / node_to_surface bridges the pair

An empty list is the smoking gun: the pair is coincident but nothing references them together — i.e. an unbridged duplicate (the cimbra arc-line corner). An entry with only constraint refs means the user has explicitly tied the pair; an entry with an element zeroLength* ref is the canonical legitimate case.

Parameters

tol : float Maximum Euclidean distance between two nodes to consider them coincident. Default 1e-6. Single value — if you need per-constraint-type tolerances (each constraint kind has its own physical tol), drive the resolver's preflight directly instead. pg : str | None If given, restrict the scan to nodes belonging to this physical group. Otherwise scan all domain nodes.

Returns

dict[tuple[int, int], list[str]] {(tag_a, tag_b): [ref, ...]} for every coincident pair. Empty dict if no coincident pairs are found.

Warnings

Builds a full-model KDTree on each call (SciPy cKDTree, with a NumPy O(N²) fallback if SciPy is unavailable). Avoid invoking inside tight loops on million-node models — cache the result yourself if you need it repeatedly.

Examples

::

pairs = fem.inspect.find_coincident_node_pairs(tol=1e-6)
for (a, b), refs in pairs.items():
    if not refs:
        print(f"UNBRIDGED coincident pair: {a}, {b}")
    else:
        print(f"Pair {a},{b} bridged by: {refs}")
Source code in src/apeGmsh/mesh/FEMData.py
def find_coincident_node_pairs(
    self,
    *,
    tol: float = 1e-6,
    pg: str | None = None,
) -> dict[tuple[int, int], list[str]]:
    """Find distinct nodes that share an XYZ within tolerance.

    Opt-in diagnostic for suspect topology — most commonly the
    arc-line junction case, where OCC builds an arc-bounded wire
    without welding the arc endpoints onto the joining line's point
    tags. The mesh then carries two distinct nodes at every
    junction with no element or constraint bridging them.

    Returns a dict mapping each coincident pair
    ``(tag_a, tag_b)`` — sorted so ``tag_a < tag_b`` — to a list
    of references that touch the pair:

    * ``"element <type>#<eid>"`` — both nodes appear in the same
      element's connectivity (legitimate for ``zeroLength``,
      tied interfaces, etc.)
    * ``"constraint <kind>"`` — equalDOF / rigidLink / diaphragm /
      kinematic / node_to_surface bridges the pair

    An **empty list** is the smoking gun: the pair is coincident
    but nothing references them together — i.e. an unbridged
    duplicate (the cimbra arc-line corner). An entry with only
    ``constraint`` refs means the user has explicitly tied the
    pair; an entry with an ``element zeroLength*`` ref is the
    canonical legitimate case.

    Parameters
    ----------
    tol : float
        Maximum Euclidean distance between two nodes to consider
        them coincident. Default ``1e-6``.  Single value — if
        you need per-constraint-type tolerances (each constraint
        kind has its own physical tol), drive the resolver's
        preflight directly instead.
    pg : str | None
        If given, restrict the scan to nodes belonging to this
        physical group. Otherwise scan all domain nodes.

    Returns
    -------
    dict[tuple[int, int], list[str]]
        ``{(tag_a, tag_b): [ref, ...]}`` for every coincident
        pair.  Empty dict if no coincident pairs are found.

    Warnings
    --------
    Builds a full-model KDTree on each call (SciPy ``cKDTree``,
    with a NumPy O(N²) fallback if SciPy is unavailable). Avoid
    invoking inside tight loops on million-node models — cache
    the result yourself if you need it repeatedly.

    Examples
    --------
    ::

        pairs = fem.inspect.find_coincident_node_pairs(tol=1e-6)
        for (a, b), refs in pairs.items():
            if not refs:
                print(f"UNBRIDGED coincident pair: {a}, {b}")
            else:
                print(f"Pair {a},{b} bridged by: {refs}")
    """
    from apeGmsh._kernel.resolvers._constraint_resolver._geom import (
        _SpatialIndex,
    )

    f = self._fem
    all_ids: ndarray = f.nodes.ids
    all_coords: ndarray = f.nodes.coords

    # Optional PG restriction.
    if pg is not None:
        sel_ids = f.nodes.select(pg=pg).ids
        keep: set[int] = {int(n) for n in sel_ids}
        mask = np.array([int(t) in keep for t in all_ids], dtype=bool)
        ids = all_ids[mask]
        coords = all_coords[mask]
    else:
        ids = all_ids
        coords = all_coords

    n = len(ids)
    if n < 2:
        return {}

    index = _SpatialIndex(np.asarray(coords, dtype=float))

    # Walk every node, query the ball, register every distinct
    # neighbour as a coincident pair (sorted to dedupe).
    pairs: dict[tuple[int, int], list[str]] = {}
    for i in range(n):
        hits = index.query_ball_point(coords[i], float(tol))
        for j in hits:
            if int(j) == i:
                continue
            ta = int(ids[i])
            tb = int(ids[j])
            key = (ta, tb) if ta < tb else (tb, ta)
            if key not in pairs:
                pairs[key] = []

    if not pairs:
        return pairs

    # Cross-reference: which elements / constraints touch each pair.
    # Build a node -> set[pairs] inverted index for O(1) lookup.
    node_to_pairs: dict[int, list[tuple[int, int]]] = {}
    for key in pairs:
        node_to_pairs.setdefault(key[0], []).append(key)
        node_to_pairs.setdefault(key[1], []).append(key)

    # Element scan — credit a ref when BOTH endpoints of a pair
    # appear in the same connectivity row.
    for group in f.elements:
        type_name = group.type_name
        for eid, conn in group:
            conn_set = {int(n) for n in conn}
            seen: set[tuple[int, int]] = set()
            for nid in conn_set:
                for key in node_to_pairs.get(nid, ()):
                    if key in seen:
                        continue
                    if key[0] in conn_set and key[1] in conn_set:
                        pairs[key].append(f"element {type_name}#{int(eid)}")
                        seen.add(key)

    # Constraint scan — flat pairs() expands every constraint kind
    # (equal_dof, rigid_*, diaphragm, kinematic, node_to_surface)
    # into NodePairRecords. A ref counts when the constraint's
    # (master, slave) hits our coincident pair (either order).
    try:
        constraint_pairs = list(f.nodes.constraints.pairs())
    except Exception:
        constraint_pairs = []
    for cp in constraint_pairs:
        a = int(getattr(cp, "master_node"))
        b = int(getattr(cp, "slave_node"))
        key = (a, b) if a < b else (b, a)
        if key in pairs:
            kind = getattr(cp, "kind", "constraint")
            pairs[key].append(f"constraint {kind}")

    return pairs

FEMData

FEMData(nodes: NodeComposite, elements: ElementComposite, info: MeshInfo, mesh_selection: 'MeshSelectionStore | None' = None, composed_from: 'ComposeSet | tuple[ComposeRecord, ...] | None' = None)

Solver-ready FEM mesh broker.

Organized by what the user needs::

fem.nodes       → NodeComposite
fem.elements    → ElementComposite
fem.info        → MeshInfo
fem.inspect     → InspectComposite
Source code in src/apeGmsh/mesh/FEMData.py
def __init__(
    self,
    nodes: NodeComposite,
    elements: ElementComposite,
    info: MeshInfo,
    mesh_selection: "MeshSelectionStore | None" = None,
    composed_from: "ComposeSet | tuple[ComposeRecord, ...] | None" = None,
) -> None:
    self.nodes    = nodes
    self.elements = elements
    self.info     = info
    self.mesh_selection = mesh_selection
    self.inspect  = InspectComposite(self)
    # ── Compose provenance (Phase 3A.1 / ADR 0038) ───────────
    # ``fem.composed_from`` is a :class:`ComposeSet` exposing one
    # record per composed source module (label-keyed, sorted).
    # Default is an empty set — the canonical "uncomposed" signal.
    # Phase 3B's ``Compose`` facade is the producer; the H5 reader
    # rehydrates it from the optional ``/composed_from/`` sub-group
    # when present.
    if composed_from is None:
        self.composed_from: ComposeSet = ComposeSet(())
    elif isinstance(composed_from, ComposeSet):
        self.composed_from = composed_from
    else:
        self.composed_from = ComposeSet(tuple(composed_from))
    # Wire the sibling NodeComposite onto the ElementComposite so
    # fem.elements.select(...) can compute element centroids
    # in-memory (no live Gmsh session needed — works for
    # import-origin FEMData too). Every construction path funnels
    # through this __init__, so one wiring line covers from_gmsh /
    # from_msh / from_h5 / from_native / from_mpco / direct. The
    # attribute name is the contract shared with
    # mesh/_mesh_selection.NODES_REF_ATTR.
    elements._apegmsh_nodes_ref = nodes

    # ── Partitions composite ─────────────────────────────
    # ``fem.partitions`` is a :class:`PartitionSet` (P2 broker
    # composite) built once from the same dicts the extractor
    # already populated on ``nodes._partitions`` /
    # ``elements._partitions``.  Those private back-stores are
    # left untouched — they still power ``select(partition=N)``;
    # this set is a Python-side ergonomic layer only.
    node_parts: dict[int, dict] = getattr(nodes, "_partitions", {}) or {}
    elem_parts: dict[int, dict] = (
        getattr(elements, "_partitions", {}) or {})
    pids = sorted(set(node_parts.keys()) | set(elem_parts.keys()))
    records: dict[int, PartitionRecord] = {}
    empty_i64 = np.array([], dtype=np.int64)
    for pid in pids:
        n_ids = np.asarray(
            node_parts.get(pid, {}).get("node_ids", empty_i64),
            dtype=np.int64,
        )
        e_ids = np.asarray(
            elem_parts.get(pid, {}).get("element_ids", empty_i64),
            dtype=np.int64,
        )
        records[int(pid)] = PartitionRecord(
            id=int(pid), node_ids=n_ids, element_ids=e_ids,
        )
    self.partitions: PartitionSet = PartitionSet(records)

snapshot_id property

snapshot_id: str

Deterministic content hash identifying this FEMData snapshot.

Computed once and cached. Used by the Results module to bind result files to their producing geometry — see internal_docs/Results_architecture.md § "FEMData embedding & binding".

from_gmsh classmethod

from_gmsh(dim: int | None = None, *, session=None, ndf: int = 6, remove_orphans: bool = False)

Extract FEMData from a live Gmsh session.

Parameters

dim : int or None Element dimension to extract. None = all dims. session : apeGmsh session, optional When provided, auto-resolves constraints, loads, masses. ndf : int DOFs per node for load/mass vector padding. remove_orphans : bool If True, remove mesh nodes not connected to any element.

Source code in src/apeGmsh/mesh/FEMData.py
@classmethod
def from_gmsh(
    cls,
    dim: int | None = None,
    *,
    session=None,
    ndf: int = 6,
    remove_orphans: bool = False,
):
    """Extract FEMData from a live Gmsh session.

    Parameters
    ----------
    dim : int or None
        Element dimension to extract.  ``None`` = all dims.
    session : apeGmsh session, optional
        When provided, auto-resolves constraints, loads, masses.
    ndf : int
        DOFs per node for load/mass vector padding.
    remove_orphans : bool
        If True, remove mesh nodes not connected to any element.
    """
    from ._fem_factory import _from_gmsh
    return _from_gmsh(
        cls, dim=dim, session=session, ndf=ndf,
        remove_orphans=remove_orphans)

from_msh classmethod

from_msh(path: str, dim: int | None = 2, *, remove_orphans: bool = False)

Load FEMData from an external .msh file.

Source code in src/apeGmsh/mesh/FEMData.py
@classmethod
def from_msh(
    cls,
    path: str,
    dim: int | None = 2,
    *,
    remove_orphans: bool = False,
):
    """Load FEMData from an external ``.msh`` file."""
    from ._fem_factory import _from_msh
    return _from_msh(cls, path=path, dim=dim,
                     remove_orphans=remove_orphans)

from_h5 classmethod

from_h5(path: str, *, root: str = '/') -> 'FEMData'

Load a :class:FEMData snapshot from a root-layout model.h5.

Inverse of :meth:to_h5. Reads the seven neutral-zone groups plus /meta and rebuilds nodes, elements (per type), physical groups, labels, mesh selections, constraints, loads, and masses — everything the writer round-trips.

Parameters

path : str Path to a model.h5 written by :meth:to_h5, g.save(), or apeSees(fem).h5(path). root : str, default "/" Sub-group root inside path to read from. Default rehydrates from the file root (standalone model.h5 shape). Per ADR 0020 (Phase 4 cleanup), composed results.h5 files carry the same rich layout under /model/; pass root="/model" to rehydrate from a composed file. Backcompat: root="/" produces byte-identical behaviour to the pre-refactor reader.

Use this to resume a session-saved model in a later script::

# script 1 — build & save
with apeGmsh(model_name="m", save_to="m.h5") as g:
    ...

# script 2 — analyse
fem = FEMData.from_h5("m.h5")
apeSees(fem).h5("m.h5")     # enrich with /opensees/...
Source code in src/apeGmsh/mesh/FEMData.py
@classmethod
def from_h5(cls, path: str, *, root: str = "/") -> "FEMData":
    """Load a :class:`FEMData` snapshot from a root-layout ``model.h5``.

    Inverse of :meth:`to_h5`.  Reads the seven neutral-zone groups
    plus ``/meta`` and rebuilds nodes, elements (per type),
    physical groups, labels, mesh selections, constraints, loads,
    and masses — everything the writer round-trips.

    Parameters
    ----------
    path : str
        Path to a model.h5 written by :meth:`to_h5`, ``g.save()``,
        or ``apeSees(fem).h5(path)``.
    root : str, default ``"/"``
        Sub-group root inside ``path`` to read from.  Default
        rehydrates from the file root (standalone ``model.h5``
        shape).  Per ADR 0020 (Phase 4 cleanup), composed
        ``results.h5`` files carry the same rich layout under
        ``/model/``; pass ``root="/model"`` to rehydrate from a
        composed file.  Backcompat: ``root="/"`` produces
        byte-identical behaviour to the pre-refactor reader.

    Use this to resume a session-saved model in a later script::

        # script 1 — build & save
        with apeGmsh(model_name="m", save_to="m.h5") as g:
            ...

        # script 2 — analyse
        fem = FEMData.from_h5("m.h5")
        apeSees(fem).h5("m.h5")     # enrich with /opensees/...
    """
    from ._femdata_h5_io import read_fem_h5
    return read_fem_h5(path, root=root)

to_native_h5

to_native_h5(group) -> None

Embed this FEMData into an open HDF5 group (/model/).

Used by NativeWriter to snapshot the geometry alongside results. The reconstructed FEMData (via from_native_h5) will produce the same snapshot_id — this is the linking contract for Results.bind().

Phase 4 cleanup (ADR 0020): writes the rich neutral-zone layout :func:write_fem_h5 produces at the file root, but under group instead. The composed results.h5 thus carries /model/meta, /model/nodes, /model/elements, etc. — the same layout :func:read_fem_h5(path, root="/model") rehydrates from. This eliminates the /opensees_archive/ zone that the previous lean embedding required to round-trip the full :class:OpenSeesModel.

Source code in src/apeGmsh/mesh/FEMData.py
def to_native_h5(self, group) -> None:
    """Embed this FEMData into an open HDF5 group (``/model/``).

    Used by ``NativeWriter`` to snapshot the geometry alongside
    results.  The reconstructed FEMData (via ``from_native_h5``)
    will produce the same ``snapshot_id`` — this is the linking
    contract for ``Results.bind()``.

    Phase 4 cleanup (ADR 0020): writes the rich neutral-zone
    layout :func:`write_fem_h5` produces at the file root, but
    under ``group`` instead.  The composed ``results.h5`` thus
    carries ``/model/meta``, ``/model/nodes``, ``/model/elements``,
    etc. — the same layout :func:`read_fem_h5(path, root="/model")`
    rehydrates from.  This eliminates the ``/opensees_archive/``
    zone that the previous lean embedding required to round-trip
    the full :class:`OpenSeesModel`.
    """
    from ._femdata_h5_io import write_neutral_zone_into_group
    write_neutral_zone_into_group(self, group)

to_h5

to_h5(path: str, *, model_name: str = '', apegmsh_version: str = '', ndf: int = 0) -> None

Write a fresh model.h5 containing the neutral zone.

Phase 8.5 entry point: dumps everything the broker knows about the model (nodes, elements per type, physical groups, labels, constraints, loads, masses) into a root-level model.h5. No /opensees/ content is emitted — absent enrichment is the right "no solver loaded" signal.

Use apeSees(fem).h5(path) instead to get a fully enriched file (neutral zone + /opensees/...).

Source code in src/apeGmsh/mesh/FEMData.py
def to_h5(
    self,
    path: str,
    *,
    model_name: str = "",
    apegmsh_version: str = "",
    ndf: int = 0,
) -> None:
    """Write a fresh ``model.h5`` containing the neutral zone.

    Phase 8.5 entry point: dumps everything the broker knows about
    the model (nodes, elements per type, physical groups, labels,
    constraints, loads, masses) into a root-level
    ``model.h5``.  No ``/opensees/`` content is emitted — absent
    enrichment is the right "no solver loaded" signal.

    Use ``apeSees(fem).h5(path)`` instead to get a fully enriched
    file (neutral zone + ``/opensees/...``).
    """
    # g.reinforce (ADR 20 / R2b → ADR 0067 P5.1): LadrunoEmbeddedRebar
    # ties now round-trip through the neutral model.h5 (persisted into the
    # /reinforce_ties group, neutral schema 2.15.0). No deferral warning.
    #
    # ADR 0067 P5.2 / B1a.2: the cage's auto-emitted structural rebar
    # elements (g.rebar.place(emit_elements=True)) now round-trip through
    # the neutral model.h5 (persisted into the /rebar_elements group,
    # neutral schema 2.16.0). No deferral warning.
    from ._femdata_h5_io import write_fem_h5
    write_fem_h5(
        self, path,
        model_name=model_name,
        apegmsh_version=apegmsh_version,
        ndf=ndf,
    )

from_native_h5 classmethod

from_native_h5(group) -> 'FEMData'

Reconstruct a FEMData from its embedded /model/ group.

Phase 4 cleanup (ADR 0020): production writers (:meth:to_native_h5 via :class:NativeWriter) embed the rich neutral zone — full constraints, loads, masses, mesh selections and partitions round-trip alongside nodes/elements/PGs. snapshot_id of the rebuilt FEM matches the source's /meta/snapshot_id attribute (the linking contract :class:Results.bind relies on).

Source code in src/apeGmsh/mesh/FEMData.py
@classmethod
def from_native_h5(cls, group) -> "FEMData":
    """Reconstruct a FEMData from its embedded ``/model/`` group.

    Phase 4 cleanup (ADR 0020): production writers (:meth:`to_native_h5`
    via :class:`NativeWriter`) embed the rich neutral zone — full
    constraints, loads, masses, mesh selections and partitions
    round-trip alongside nodes/elements/PGs.  ``snapshot_id`` of
    the rebuilt FEM matches the source's ``/meta/snapshot_id``
    attribute (the linking contract :class:`Results.bind` relies
    on).
    """
    from ._femdata_h5_io import read_neutral_zone_from_group

    return read_neutral_zone_from_group(
        group, label=getattr(group, "name", "<h5 group>"),
    )

from_mpco_model classmethod

from_mpco_model(group) -> 'FEMData'

Synthesize a partial FEMData from an MPCO MODEL/ group.

Carries: nodes, elements (per OpenSees class tag), physical groups derived from MPCO Regions (MODEL/SETS).

Missing vs. native: - apeGmsh-specific labels - Pre-mesh declarations (loads / masses / constraints) - STKO named selection sets (those live in .cdata sidecars) - Gmsh-style element type codes (uses negated class_tag instead)

snapshot_id will not match a native FEMData of the same mesh — that's expected. Results.bind() will refuse such mismatches.

Source code in src/apeGmsh/mesh/FEMData.py
@classmethod
def from_mpco_model(cls, group) -> "FEMData":
    """Synthesize a partial FEMData from an MPCO ``MODEL/`` group.

    Carries: nodes, elements (per OpenSees class tag), physical
    groups derived from MPCO Regions (``MODEL/SETS``).

    Missing vs. native:
    - apeGmsh-specific ``labels``
    - Pre-mesh declarations (loads / masses / constraints)
    - STKO named selection sets (those live in ``.cdata`` sidecars)
    - Gmsh-style element type codes (uses negated class_tag instead)

    ``snapshot_id`` will not match a native FEMData of the same
    mesh — that's expected. ``Results.bind()`` will refuse such
    mismatches.
    """
    from ._femdata_mpco_io import read_fem_from_mpco
    return read_fem_from_mpco(group)

from_ladruno_model classmethod

from_ladruno_model(group) -> 'FEMData'

Synthesize a partial FEMData from a .ladruno MODEL/ group.

Sibling of :meth:from_mpco_model for the self-describing .ladruno layout (element groups carry a CONNECTIVITY dataset + BASIS attrs). Carries nodes, elements (per OpenSees class tag), and physical groups from MODEL/SETS. Missing vs. native: apeGmsh labels, pre-mesh declarations, selection-set names. snapshot_id will not match a native FEMData of the same mesh — expected.

Source code in src/apeGmsh/mesh/FEMData.py
@classmethod
def from_ladruno_model(cls, group) -> "FEMData":
    """Synthesize a partial FEMData from a ``.ladruno`` ``MODEL/`` group.

    Sibling of :meth:`from_mpco_model` for the self-describing
    ``.ladruno`` layout (element groups carry a ``CONNECTIVITY``
    dataset + BASIS attrs). Carries nodes, elements (per OpenSees
    class tag), and physical groups from ``MODEL/SETS``. Missing vs.
    native: apeGmsh ``labels``, pre-mesh declarations, selection-set
    names. ``snapshot_id`` will not match a native FEMData of the
    same mesh — expected.
    """
    from ._femdata_ladruno_io import read_fem_from_ladruno
    return read_fem_from_ladruno(group)

with_constraint

with_constraint(record) -> 'FEMData'

Return a new :class:FEMData with record appended.

Pure transform. self is unchanged. Dispatch is by record type:

===================================== ================================= Record subclass Appended to ===================================== ================================= NodePairRecord nodes.constraints NodeGroupRecord nodes.constraints NodeToSurfaceRecord nodes.constraints InterpolationRecord elements.constraints SurfaceCouplingRecord elements.constraints SPRecord nodes.sp ===================================== =================================

Routing an unknown record subclass raises TypeError — this is a fail-loud contract because the compose engine needs every record to land in a known broker bucket.

Source code in src/apeGmsh/mesh/FEMData.py
def with_constraint(self, record) -> "FEMData":
    """Return a new :class:`FEMData` with ``record`` appended.

    Pure transform.  ``self`` is unchanged.  Dispatch is by record
    type:

    =====================================  =================================
    Record subclass                        Appended to
    =====================================  =================================
    ``NodePairRecord``                     ``nodes.constraints``
    ``NodeGroupRecord``                    ``nodes.constraints``
    ``NodeToSurfaceRecord``                ``nodes.constraints``
    ``InterpolationRecord``                ``elements.constraints``
    ``SurfaceCouplingRecord``              ``elements.constraints``
    ``SPRecord``                           ``nodes.sp``
    =====================================  =================================

    Routing an unknown record subclass raises ``TypeError`` — this
    is a fail-loud contract because the compose engine needs every
    record to land in a known broker bucket.
    """
    # Imports are deferred so callers using only ``with_load`` /
    # ``with_mass`` don't pay the cost.
    from .._kernel.records._constraints import (
        NodePairRecord, NodeGroupRecord, NodeToSurfaceRecord,
        InterpolationRecord, SurfaceCouplingRecord,
    )
    from .._kernel.records._loads import SPRecord

    if isinstance(record, (NodePairRecord, NodeGroupRecord,
                           NodeToSurfaceRecord)):
        new_set = self.nodes.constraints._with_record(record)
        return self._replaced(
            nodes=self._replaced_nodes(constraints=new_set))
    if isinstance(record, (InterpolationRecord, SurfaceCouplingRecord)):
        new_set = self.elements.constraints._with_record(record)
        return self._replaced(
            elements=self._replaced_elements(constraints=new_set))
    if isinstance(record, SPRecord):
        new_set = self.nodes.sp._with_record(record)
        return self._replaced(
            nodes=self._replaced_nodes(sp=new_set))
    raise TypeError(
        f"FEMData.with_constraint: unsupported record type "
        f"{type(record).__name__!r} — expected a ConstraintRecord "
        f"subclass or SPRecord."
    )

with_load

with_load(record) -> 'FEMData'

Return a new :class:FEMData with record appended.

Pure transform. self is unchanged. Dispatch is by record type:

===================================== ================================= Record subclass Appended to ===================================== ================================= NodalLoadRecord nodes.loads ElementLoadRecord elements.loads SPRecord nodes.sp ===================================== =================================

Source code in src/apeGmsh/mesh/FEMData.py
def with_load(self, record) -> "FEMData":
    """Return a new :class:`FEMData` with ``record`` appended.

    Pure transform.  ``self`` is unchanged.  Dispatch is by record
    type:

    =====================================  =================================
    Record subclass                        Appended to
    =====================================  =================================
    ``NodalLoadRecord``                    ``nodes.loads``
    ``ElementLoadRecord``                  ``elements.loads``
    ``SPRecord``                           ``nodes.sp``
    =====================================  =================================
    """
    from .._kernel.records._loads import (
        NodalLoadRecord, ElementLoadRecord, SPRecord,
    )

    if isinstance(record, NodalLoadRecord):
        new_set = self.nodes.loads._with_record(record)
        return self._replaced(
            nodes=self._replaced_nodes(loads=new_set))
    if isinstance(record, ElementLoadRecord):
        new_set = self.elements.loads._with_record(record)
        return self._replaced(
            elements=self._replaced_elements(loads=new_set))
    if isinstance(record, SPRecord):
        new_set = self.nodes.sp._with_record(record)
        return self._replaced(
            nodes=self._replaced_nodes(sp=new_set))
    raise TypeError(
        f"FEMData.with_load: unsupported record type "
        f"{type(record).__name__!r} — expected NodalLoadRecord, "
        f"ElementLoadRecord, or SPRecord."
    )

compose

compose(source: 'str | Path', *, label: str, translate: tuple[float, float, float] = (0.0, 0.0, 0.0), rotate: tuple[float, float, float, float] | None = None, anchor: str | None = None, partition_rank: int | None = None, properties: 'dict | None' = None, compose_size_per_module: int | None = None, max_compose_depth: int | None = None) -> 'FEMData'

Return a new :class:FEMData extending this chain with a composed module.

Pure transformation. self is unchanged. The returned FEMData's composed_from chain is self.composed_from + (new_record,) and every IMPORT-verdict record from the source H5 surfaces on the result, namespaced with label and offset into a non-overlapping tag window per ADR 0038 §"Tag-offset scheme".

Geometry / mesh build-phase operations are not part of this primitive — the merge runs entirely against the FEMData broker; no live Gmsh state is touched. The compose API is the canonical entry point for cross-session composition: FEMData.from_h5(path).compose("module.h5", label="A").

Drift hazard: the returned FEMData is decoupled from any live gmsh state on the producing session. Mutating the session's mesh/PG/label/parts AFTER calling compose without also re-extracting + re-applying the bundle drops the composed module's records on the floor. The :meth:apeGmsh.compose shim handles this via session-level bundle-replay; if you call this primitive directly, replay is your responsibility.

See :func:apeGmsh.mesh._compose.Compose.compose for the full parameter contract; max_compose_depth is the only Compose.compose parameter intentionally absent here (depth checks come in Phase 3E.1).

Source code in src/apeGmsh/mesh/FEMData.py
def compose(
    self,
    source: "str | Path",
    *,
    label: str,
    translate: tuple[float, float, float] = (0.0, 0.0, 0.0),
    rotate: tuple[float, float, float, float] | None = None,
    anchor: str | None = None,
    partition_rank: int | None = None,
    properties: "dict | None" = None,
    compose_size_per_module: int | None = None,
    max_compose_depth: int | None = None,
) -> "FEMData":
    """Return a new :class:`FEMData` extending this chain with a
    composed module.

    Pure transformation. ``self`` is unchanged.  The returned
    FEMData's ``composed_from`` chain is
    ``self.composed_from + (new_record,)`` and every IMPORT-verdict
    record from the source H5 surfaces on the result, namespaced
    with ``label`` and offset into a non-overlapping tag window
    per ADR 0038 §"Tag-offset scheme".

    Geometry / mesh build-phase operations are not part of this
    primitive — the merge runs entirely against the FEMData
    broker; no live Gmsh state is touched.  The compose API is
    the canonical entry point for cross-session composition:
    ``FEMData.from_h5(path).compose("module.h5", label="A")``.

    Drift hazard: the returned FEMData is decoupled from any
    live gmsh state on the producing session.  Mutating the
    session's mesh/PG/label/parts AFTER calling compose without
    also re-extracting + re-applying the bundle drops the
    composed module's records on the floor.  The
    :meth:`apeGmsh.compose` shim handles this via session-level
    bundle-replay; if you call this primitive directly, replay
    is your responsibility.

    See :func:`apeGmsh.mesh._compose.Compose.compose` for the full
    parameter contract; ``max_compose_depth`` is the only
    ``Compose.compose`` parameter intentionally absent here
    (depth checks come in Phase 3E.1).
    """
    from ._compose import (
        Compose, ComposeAnchorError,
        _compute_source_span, _compute_reservation,
        _rewrite_source_for_compose, _merge_bundle_into_fem,
        _emit_filter_warnings, _host_max_tag,
    )

    # 1. Validate inputs — reuse the Compose facade's static
    #    validators so the contract stays single-sourced.
    Compose._validate_label(label)
    Compose._validate_translate_rotate_anchor(translate, anchor)
    Compose._validate_partition_rank(partition_rank)
    Compose._validate_compose_size(compose_size_per_module)

    # 2. Resolve anchor → translate when anchor is given.  Anchor
    #    + non-zero translate already raised above; an unknown
    #    PG raises ComposeAnchorError with an actionable message.
    if anchor is not None:
        translate = _resolve_anchor_to_translate(self, anchor)

    # 3. Compute reservation against host's max_tag.  The merge
    #    engine doesn't track explicit (base, size) pairs on
    #    composed_from in this slice (deferred to 3D); each
    #    new compose simply rounds host_max_tag up to the next
    #    granularity boundary, which advances naturally as each
    #    bundle's tags fold into the host's id ranges.
    host_max_tag = _host_max_tag(self)
    source_span, source_min_tag, _source_max_tag = (
        _compute_source_span(source)
    )
    base, size = _compute_reservation(
        source_span=source_span,
        host_max_tag=host_max_tag,
        previous_reservations=(),
        granularity=Compose.RESERVATION_GRANULARITY,
        compose_size_per_module=compose_size_per_module,
    )

    # Capacity check (ADR 0038 §"Tag-collision verifier" check 5).
    if compose_size_per_module is not None and source_span > size:
        from ._compose import ComposeCapacityError
        raise ComposeCapacityError(
            f"compose(compose_size_per_module="
            f"{compose_size_per_module}) is smaller than the "
            f"source's tag span ({source_span}); reservation size "
            f"{size} would not fit the imported tags."
        )

    # 4. Rewrite the source into an offset/namespaced bundle.
    #    Phase 3E.1: forward max_compose_depth so the rewriter's
    #    depth gate fires before any rewrite work runs.  ``None``
    #    means "use the default" (DEFAULT_MAX_COMPOSE_DEPTH = 3).
    from ._compose import DEFAULT_MAX_COMPOSE_DEPTH as _DEFAULT_MAX_DEPTH
    depth_cap = (
        max_compose_depth
        if max_compose_depth is not None
        else _DEFAULT_MAX_DEPTH
    )
    bundle = _rewrite_source_for_compose(
        source_path=source,
        label=label,
        translate=translate,
        rotate=rotate,
        partition_rank=partition_rank,
        properties=dict(properties or {}),
        base=base,
        size=size,
        source_span=source_span,
        source_min_tag=source_min_tag,
        max_compose_depth=depth_cap,
    )

    # 5. Emit FILTER warnings (stages / time-series / patterns).
    _emit_filter_warnings(source, label)

    # 5b. Interface-size advisory (Phase 3F.1 / ADR 0038
    #     §"v1 scope gate").  Emits one ComposeInterfaceSizeWarning
    #     when the bundle's MP-style constraint count exceeds
    #     WARN_INTERFACE_SIZE = 50_000 — silenceable per-call via
    #     ``warnings.simplefilter("ignore", ComposeInterfaceSizeWarning)``.
    from ._compose import WARN_INTERFACE_SIZE, _warn_interface_size
    _warn_interface_size(bundle, threshold=WARN_INTERFACE_SIZE)

    # 6. Merge bundle into self → new FEMData.  The merge engine
    #    runs the Phase 2.2 tag-collision verifier first
    #    (ADR 0038 §"Tag-collision verifier") and raises a typed
    #    error on violation.  Cache the bundle on the result for
    #    the session shim's replay machinery.
    new_fem = _merge_bundle_into_fem(
        self, bundle, compose_size_per_module=compose_size_per_module,
    )

    # 7. Phase 3B.2d / ADR 0038 §"Rank model" — eager populator.
    #    Assign one partition per composed module (Layer 1) +
    #    honour partition_rank hints (Layer 2).  Existing
    #    METIS-driven partitions trigger a UserWarning (Layer 3).
    from ._compose import _rebuild_partitions_from_modules
    new_fem = _rebuild_partitions_from_modules(new_fem)

    new_fem._last_compose_bundle = bundle  # type: ignore[attr-defined]
    return new_fem

compose_tree

compose_tree() -> 'tuple'

Derived nested-compose tree view of self.composed_from.

Reconstructs the nested-compose hierarchy from this FEMData's flat composed_from chain (per PR #369's flat-graft storage). Returns a tuple of root :class:~apeGmsh.mesh._compose.ComposeTreeNode instances; each root carries its :class:ComposeRecord plus any direct children parsed from joined labels via the separator- alternation rule (depth-1 ., depth-2 /, depth-3 ., ...).

Empty tuple when self.composed_from is empty (an uncomposed FEMData).

Source code in src/apeGmsh/mesh/FEMData.py
def compose_tree(self) -> "tuple":
    """Derived nested-compose tree view of ``self.composed_from``.

    Reconstructs the nested-compose hierarchy from this FEMData's
    flat ``composed_from`` chain (per PR #369's flat-graft
    storage).  Returns a tuple of root
    :class:`~apeGmsh.mesh._compose.ComposeTreeNode` instances;
    each root carries its :class:`ComposeRecord` plus any direct
    children parsed from joined labels via the separator-
    alternation rule (depth-1 ``.``, depth-2 ``/``, depth-3
    ``.``, ...).

    Empty tuple when ``self.composed_from`` is empty (an
    uncomposed FEMData).
    """
    from ._compose import _build_compose_tree
    return _build_compose_tree(tuple(self.composed_from))

with_mass

with_mass(record) -> 'FEMData'

Return a new :class:FEMData with record appended to nodes.masses.

Pure transform. self is unchanged. Only :class:~apeGmsh._kernel.records._masses.MassRecord is accepted; anything else raises TypeError.

Source code in src/apeGmsh/mesh/FEMData.py
def with_mass(self, record) -> "FEMData":
    """Return a new :class:`FEMData` with ``record`` appended to
    ``nodes.masses``.

    Pure transform.  ``self`` is unchanged.  Only
    :class:`~apeGmsh._kernel.records._masses.MassRecord` is
    accepted; anything else raises ``TypeError``.
    """
    from .._kernel.records._masses import MassRecord

    if isinstance(record, MassRecord):
        new_set = self.nodes.masses._with_record(record)
        return self._replaced(
            nodes=self._replaced_nodes(masses=new_set))
    raise TypeError(
        f"FEMData.with_mass: unsupported record type "
        f"{type(record).__name__!r} — expected MassRecord."
    )

assess

assess(*, figures: bool = False, out_dir: 'str | Path | None' = None) -> 'AssessmentReport'

Compile a v1 :class:~apeGmsh.assess.AssessmentReport (ADR 0094).

figures=True writes one undeformed mesh still via :meth:render. Default is False.

Source code in src/apeGmsh/mesh/FEMData.py
def assess(
    self,
    *,
    figures: bool = False,
    out_dir: "str | Path | None" = None,
) -> "AssessmentReport":
    """Compile a v1 :class:`~apeGmsh.assess.AssessmentReport` (ADR 0094).

    ``figures=True`` writes one undeformed mesh still via
    :meth:`render`. Default is ``False``.
    """
    from apeGmsh.assess import assess_fem
    return assess_fem(self, figures=figures, out_dir=out_dir)

render

render(path: 'str | Path', *, camera: 'str | None' = None, window_size: tuple[int, int] = (1280, 720)) -> 'Path | None'

Write one undeformed mesh still (ADR 0094 S1).

VTK offscreen — no Qt window, no event loop. Returns the written :class:~pathlib.Path, or None (and prints the [skip viewer] notice) under APEGMSH_SKIP_VIEWER=1 or with no GL.

camera= defaults to xy for a planar model, iso otherwise (ADR 0094 Amendment 3); pass it explicitly to override.

Source code in src/apeGmsh/mesh/FEMData.py
def render(
    self,
    path: "str | Path",
    *,
    camera: "str | None" = None,
    window_size: tuple[int, int] = (1280, 720),
) -> "Path | None":
    """Write one undeformed mesh still (ADR 0094 S1).

    VTK offscreen — no Qt window, no event loop. Returns the
    written :class:`~pathlib.Path`, or ``None`` (and prints the
    ``[skip viewer]`` notice) under ``APEGMSH_SKIP_VIEWER=1`` or
    with no GL.

    ``camera=`` defaults to ``xy`` for a planar model, ``iso``
    otherwise (ADR 0094 Amendment 3); pass it explicitly to
    override.
    """
    from apeGmsh.viewers.render import render_fem
    return render_fem(
        self, path, camera=camera, window_size=window_size,
    )

viewer

viewer(*, blocking: bool = False) -> None

Open a non-interactive mesh viewer from this snapshot.

Currently disabled — the legacy Results.from_fem(...).viewer() path was removed when the Results module was rebuilt. For a headless mesh still use :meth:render. For an interactive mesh window, use g.mesh.viewer().

Source code in src/apeGmsh/mesh/FEMData.py
def viewer(self, *, blocking: bool = False) -> None:
    """Open a non-interactive mesh viewer from this snapshot.

    Currently disabled — the legacy ``Results.from_fem(...).viewer()``
    path was removed when the Results module was rebuilt. For a
    headless mesh still use :meth:`render`. For an interactive
    mesh window, use ``g.mesh.viewer()``.
    """
    raise NotImplementedError(
        "fem.viewer() relied on the legacy Results class which has "
        "been rebuilt. For a headless mesh still use fem.render(); "
        "for an interactive mesh window use g.mesh.viewer()."
    )