Skip to content

FEM Broker — FEMData

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

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)

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,
) -> 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 {}

ids property

ids: ndarray

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

coords property

coords: ndarray

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

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)

Start a daisy-chainable node selection.

Returns a :class:~apeGmsh.mesh._mesh_selection.MeshSelection (point family) that composes fluently and terminates with .ids / .coords / .connectivity / .result() / .resolve()::

fem.nodes.select(pg="Base").in_box(lo, hi).on_plane(p, n, tol=1e-6)
fem.nodes.select(ids=a) | fem.nodes.select(ids=b)

Accepts target / pg / label / tag / partition / dim plus ids= (explicit id list); no-arg seeds every domain node. Name resolution is not re-implemented here: the target/pg/label/tag/dim seed is obtained by delegating verbatim to :meth:_resolve_nodes, preserving its documented node-path KeyError-only swallow asymmetry (FP-4) by reuse — and the optional partition filter reuses :meth:_intersect_partition, so the resolved id set is exactly what the locked resolution contract returns (no extra scoping or boundary walk).

MeshSelection is imported deferred (mirrors mesh/_mesh_structured.py): _mesh_selection imports only the package-root leaf apeGmsh._kernel.chain at load, so this adds no eager cross-package edge (tests/test_import_dag_polarity.py stays green with the baseline unchanged).

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,
):
    """Start a daisy-chainable node selection.

    Returns a :class:`~apeGmsh.mesh._mesh_selection.MeshSelection`
    (point family) that composes fluently and terminates with
    ``.ids`` / ``.coords`` / ``.connectivity`` / ``.result()`` /
    ``.resolve()``::

        fem.nodes.select(pg="Base").in_box(lo, hi).on_plane(p, n, tol=1e-6)
        fem.nodes.select(ids=a) | fem.nodes.select(ids=b)

    Accepts ``target`` / ``pg`` / ``label`` / ``tag`` /
    ``partition`` / ``dim`` plus ``ids=`` (explicit id list); no-arg
    seeds every domain node.  Name resolution is **not**
    re-implemented here: the
    ``target``/``pg``/``label``/``tag``/``dim`` seed is obtained by
    delegating verbatim to :meth:`_resolve_nodes`, preserving its
    documented node-path ``KeyError``-only swallow asymmetry (FP-4)
    by reuse — and the optional ``partition`` filter reuses
    :meth:`_intersect_partition`, so the resolved id set is exactly
    what the locked resolution contract returns (no extra scoping
    or boundary walk).

    ``MeshSelection`` is imported **deferred** (mirrors
    ``mesh/_mesh_structured.py``): ``_mesh_selection`` imports only
    the package-root leaf ``apeGmsh._kernel.chain`` at load, so this
    adds no eager cross-package edge
    (``tests/test_import_dag_polarity.py`` stays green with the
    baseline unchanged).
    """
    # 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

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)

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,
) -> None:
    self._groups: dict[int, ElementGroup] = dict(groups)
    self.physical = physical
    self.labels   = labels

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

    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 {}

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.

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.

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)

Start a daisy-chainable element selection.

Returns a :class:~apeGmsh.mesh._mesh_selection.MeshSelection (point family — atoms are element ids, spatial verbs operate on element centroids) that composes fluently and terminates with .ids / .coords / .connectivity / .groups() / .result() / .resolve()::

fem.elements.select(pg="Body").in_box(lo, hi).on_plane(p, n, tol=1e-6)
fem.elements.select(ids=a) | fem.elements.select(ids=b)

Accepts target / pg / label / tag / dim / element_type / partition plus ids= (explicit id list); no-arg seeds every element. Name resolution is not re-implemented here: the target/pg/label/tag seed is obtained by delegating verbatim to :meth:_resolve_elem_ids, preserving its documented element-path (KeyError, ValueError) swallow (FP-4) by reuse. The auxiliary dim/element_type/partition filters reuse the shared :meth:_filtered_groups helper (no filter logic re-implemented), so the resolved selection is exactly what the locked resolution contract returns.

MeshSelection is imported deferred (mirrors mesh/_mesh_structured.py): _mesh_selection imports only the package-root leaf apeGmsh._kernel.chain at load, so this adds no eager cross-package edge (tests/test_import_dag_polarity.py stays green with the baseline unchanged).

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,
):
    """Start a daisy-chainable element selection.

    Returns a :class:`~apeGmsh.mesh._mesh_selection.MeshSelection`
    (point family — atoms are element ids, spatial verbs operate on
    element centroids) that composes fluently and terminates with
    ``.ids`` / ``.coords`` / ``.connectivity`` / ``.groups()`` /
    ``.result()`` / ``.resolve()``::

        fem.elements.select(pg="Body").in_box(lo, hi).on_plane(p, n, tol=1e-6)
        fem.elements.select(ids=a) | fem.elements.select(ids=b)

    Accepts ``target`` / ``pg`` / ``label`` / ``tag`` / ``dim`` /
    ``element_type`` / ``partition`` plus ``ids=`` (explicit id
    list); no-arg seeds every element.  Name resolution is **not**
    re-implemented here: the ``target``/``pg``/``label``/``tag``
    seed is obtained by delegating verbatim to
    :meth:`_resolve_elem_ids`, preserving its documented
    element-path ``(KeyError, ValueError)`` swallow (FP-4) by
    reuse.  The auxiliary ``dim``/``element_type``/``partition``
    filters reuse the shared :meth:`_filtered_groups` helper (no
    filter logic re-implemented), so the resolved selection is
    exactly what the locked resolution contract returns.

    ``MeshSelection`` is imported **deferred** (mirrors
    ``mesh/_mesh_structured.py``): ``_mesh_selection`` imports only
    the package-root leaf ``apeGmsh._kernel.chain`` at load, so this
    adds no eager cross-package edge
    (``tests/test_import_dag_polarity.py`` stays green with the
    baseline unchanged).
    """
    # 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)

FEMData

FEMData(nodes: NodeComposite, elements: ElementComposite, info: MeshInfo, mesh_selection: 'MeshSelectionStore | 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,
) -> None:
    self.nodes    = nodes
    self.elements = elements
    self.info     = info
    self.mesh_selection = mesh_selection
    self.inspect  = InspectComposite(self)
    # 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 property

partitions: list[int]

Sorted list of partition IDs.

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

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

    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)

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

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()``.
    """
    from ._femdata_native_io import write_fem_to_h5
    write_fem_to_h5(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/...``).
    """
    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.

The reconstructed object carries nodes, elements (per type), physical groups, and labels. Loads/masses/constraints are not round-tripped (they don't affect snapshot_id and the viewer doesn't need them).

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

    The reconstructed object carries nodes, elements (per type),
    physical groups, and labels. Loads/masses/constraints are not
    round-tripped (they don't affect ``snapshot_id`` and the
    viewer doesn't need them).
    """
    from ._femdata_native_io import read_fem_from_h5
    return read_fem_from_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)

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. The new flow is being designed as part of the viewer rebuild project; see internal_docs/Results_architecture.md (Phase 9).

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. The new
    flow is being designed as part of the viewer rebuild project;
    see ``internal_docs/Results_architecture.md`` (Phase 9).
    """
    raise NotImplementedError(
        "fem.viewer() relied on the legacy Results class which has "
        "been rebuilt. The replacement is part of the viewer rebuild "
        "project (Phase 9 in Results_architecture.md). For mesh-only "
        "viewing in the meantime, use g.mesh.viewer() with no args."
    )