Skip to content

Results

Post-processing container.

Fluent selection — .nodes.select() / .elements.select()

results.nodes.select(...) and results.elements.select(...) are the results entries of the unified, daisy-chainable selection idiom. .select() returns a MeshSelection (point family); the terminal is .values(component=...), which forwards to the retained results.<level>.get(...) reader and returns the same slab (NodeSlab / ElementSlab) with id/value parity.

slab = (results.nodes.select(pg="Base")
    .in_box(lo, hi)                                # half-open [lo, hi)
    .on_plane((0, 0, 0), (0, 0, 1), tol=1e-6)
    .values(component="displacement_x"))           # -> NodeSlab

A bare results selection needs a component — .result() raises RuntimeError; use .values(component=...). Element spatial verbs operate on element centroids.

S5 — formerly-silent results paths now raise

results with selection= on an import-origin (from_msh/MPCO/native) FEMData now raises RuntimeError instead of resolving to an empty set; results element-centroid computation raises KeyError on an unknown connectivity node, which also makes the legacy results.elements.in_box/nearest_to/on_plane helpers fail loud. See the changelog.

See Selection for the full idiom; results sub-composite .select() (gauss/fibers/layers/line_stations/ springs) is a tracked, not-yet-shipped follow-up.

apeGmsh.results.Results.Results

Results(reader: ResultsReader, *, fem: 'Optional[FEMData]' = None, stage_id: Optional[str] = None, path: Optional[Path] = None)

Top-level results object. Returned by Results.from_* constructors.

Stage scoping

Instances may be unscoped (top-level — accesses any stage) or scoped to one stage (returned by .stage(name), .modes[i]). Scoped instances expose stage metadata as properties (.kind, .time, .n_steps); mode-scoped instances additionally expose .eigenvalue, .frequency_hz, .period_s, .mode_index.

Source code in src/apeGmsh/results/Results.py
def __init__(
    self,
    reader: ResultsReader,
    *,
    fem: "Optional[FEMData]" = None,
    stage_id: Optional[str] = None,
    path: Optional[Path] = None,
) -> None:
    self._reader = reader
    self._fem = fem
    self._stage_id = stage_id
    self._path = path
    self._stages_cache: Optional[list[StageInfo]] = None

    # Composites
    self.nodes = NodeResultsComposite(self)
    self.elements = ElementResultsComposite(self)
    self.inspect = ResultsInspect(self)
    self._plot: Optional["ResultsPlot"] = None

fem property

fem: 'Optional[FEMData]'

The bound FEMData snapshot, or None if not bound.

stages property

stages: list[StageInfo]

All stages in the file (scoped instances also list them).

modes property

modes: list['Results']

Stages with kind='mode' as a list of mode-scoped Results.

Order is the order the modes were written (typically by ascending mode_index). For a stable lookup by index, sort: sorted(results.modes, key=lambda m: m.mode_index).

plot property

plot: 'ResultsPlot'

results.plot — static matplotlib renderer.

Mirrors the interactive viewer's diagram catalog as headless, publication-ready matplotlib figures::

results.plot.contour("displacement_z", step=-1)
results.plot.deformed(step=-1, scale=50, component="stress_xx")
results.plot.history(node=412, component="displacement_x")

Requires the [plot] extra (matplotlib).

from_native classmethod

from_native(path: str | Path, *, fem: 'Optional[FEMData]' = None) -> 'Results'

Open an apeGmsh native HDF5 results file.

If fem is omitted, the embedded /model/ snapshot is used as the bound FEMData. If fem is provided and the file embeds a snapshot, the two snapshot_id hashes must match.

Source code in src/apeGmsh/results/Results.py
@classmethod
def from_native(
    cls,
    path: str | Path,
    *,
    fem: "Optional[FEMData]" = None,
) -> "Results":
    """Open an apeGmsh native HDF5 results file.

    If ``fem`` is omitted, the embedded ``/model/`` snapshot is
    used as the bound FEMData. If ``fem`` is provided and the file
    embeds a snapshot, the two ``snapshot_id`` hashes must match.
    """
    from .readers._native import NativeReader
    reader = NativeReader(path)
    bound_fem = resolve_bound_fem(reader, fem)
    return cls(reader, fem=bound_fem, path=Path(path))

from_recorders classmethod

from_recorders(spec, output_dir: str | Path, *, fem: 'FEMData', cache_root: str | Path | None = None, stage_name: str = 'analysis', stage_kind: str = 'transient', file_format: str = 'out', stage_id: str | None = None) -> 'Results'

Open the result of an OpenSees run driven by Tcl/Py recorders.

Parses the .out / .xml files emitted at output_dir (matching what spec.emit_recorders(...) or the apeGmsh OpenSees bridge's Tcl/Py emit produced) into an apeGmsh native HDF5, caches the result at cache_root, and opens it through NativeReader.

Caching: subsequent calls with unchanged input files return the cached HDF5 directly (file mtime + size + spec snapshot_id form the cache key). See writers/_cache.py.

stage_id matches the per-stage filename prefix used by :meth:ResolvedRecorderSpec.emit_recorders together with begin_stage(stage_id, ...). When set, only files prefixed with <stage_id>__ are read; stage_name defaults to stage_id if not overridden. None (default) keeps the legacy flat-naming used by Tcl/Py exports.

Phase 6 v1 supports nodal records only; element-level records in the spec are skipped with a note. The capture flow (Phase 7) handles modal recorders.

Source code in src/apeGmsh/results/Results.py
@classmethod
def from_recorders(
    cls,
    spec,
    output_dir: str | Path,
    *,
    fem: "FEMData",
    cache_root: str | Path | None = None,
    stage_name: str = "analysis",
    stage_kind: str = "transient",
    file_format: str = "out",
    stage_id: str | None = None,
) -> "Results":
    """Open the result of an OpenSees run driven by Tcl/Py recorders.

    Parses the ``.out`` / ``.xml`` files emitted at
    ``output_dir`` (matching what ``spec.emit_recorders(...)`` or
    the apeGmsh OpenSees bridge's Tcl/Py emit produced) into an
    apeGmsh native HDF5, caches the result at
    ``cache_root``, and opens it through ``NativeReader``.

    Caching: subsequent calls with unchanged input files return
    the cached HDF5 directly (file mtime + size + spec
    ``snapshot_id`` form the cache key). See
    ``writers/_cache.py``.

    ``stage_id`` matches the per-stage filename prefix used by
    :meth:`ResolvedRecorderSpec.emit_recorders` together with
    ``begin_stage(stage_id, ...)``. When set, only files prefixed
    with ``<stage_id>__`` are read; ``stage_name`` defaults to
    ``stage_id`` if not overridden. ``None`` (default) keeps the
    legacy flat-naming used by Tcl/Py exports.

    Phase 6 v1 supports nodal records only; element-level records
    in the spec are skipped with a note. The capture flow
    (Phase 7) handles modal recorders.
    """
    from .schema._versions import PARSER_VERSION
    from .transcoders import RecorderTranscoder
    from .writers import _cache

    if fem is None:
        raise TypeError(
            "Results.from_recorders(...) requires fem= "
            "(the spec's snapshot_id must match)."
        )

    # When stage_id is provided and stage_name was left at its
    # default, mirror stage_id so the resulting Results stage is
    # named meaningfully (otherwise everything ends up as
    # "analysis" regardless of which stage the user loaded).
    if stage_id is not None and stage_name == "analysis":
        stage_name = stage_id

    out_dir = Path(output_dir)
    cache_dir = _cache.resolve_cache_root(cache_root)

    source_files = _cache.list_source_files(
        spec, out_dir, file_format=file_format, stage_id=stage_id,
    )
    key = _cache.compute_cache_key(
        source_files,
        parser_version=PARSER_VERSION,
        fem_snapshot_id=fem.snapshot_id,
    )
    cached_h5, _ = _cache.cache_paths(cache_dir, key)

    if not cached_h5.exists():
        transcoder = RecorderTranscoder(
            spec, out_dir, cached_h5, fem,
            stage_name=stage_name,
            stage_kind=stage_kind,
            file_format=file_format,
            stage_id=stage_id,
        )
        transcoder.run()

    return cls.from_native(cached_h5, fem=fem)

from_mpco classmethod

from_mpco(path: 'str | Path | list[str | Path]', *, fem: 'Optional[FEMData]' = None, merge_partitions: bool = True) -> 'Results'

Open a STKO .mpco HDF5 results file.

Single-file mode (default for non-partitioned analyses): pass the path of one .mpco file. Synthesizes a partial FEMData from the MPCO MODEL/ group if fem is omitted.

Multi-partition mode (parallel OpenSees runs): pass either

  • a single <stem>.part-<N>.mpco path — siblings are discovered automatically by globbing <stem>.part-*.mpco in the same directory and merged into one virtual reader;
  • an explicit list of partition paths.

Boundary nodes deduplicate by ID (first-occurrence wins); elements concatenate (disjoint by partition); slabs stitch across partitions transparently. Stage and time vectors must match across partitions or construction raises.

Pass merge_partitions=False to opt out of auto-discovery and read only the file at path even if it follows the .part-N naming convention.

Source code in src/apeGmsh/results/Results.py
@classmethod
def from_mpco(
    cls,
    path: "str | Path | list[str | Path]",
    *,
    fem: "Optional[FEMData]" = None,
    merge_partitions: bool = True,
) -> "Results":
    """Open a STKO ``.mpco`` HDF5 results file.

    Single-file mode (default for non-partitioned analyses): pass
    the path of one ``.mpco`` file. Synthesizes a partial FEMData
    from the MPCO ``MODEL/`` group if ``fem`` is omitted.

    Multi-partition mode (parallel OpenSees runs): pass either

    - a single ``<stem>.part-<N>.mpco`` path — siblings are
      discovered automatically by globbing ``<stem>.part-*.mpco``
      in the same directory and merged into one virtual reader;
    - an explicit list of partition paths.

    Boundary nodes deduplicate by ID (first-occurrence wins);
    elements concatenate (disjoint by partition); slabs stitch
    across partitions transparently. Stage and time vectors must
    match across partitions or construction raises.

    Pass ``merge_partitions=False`` to opt out of auto-discovery
    and read only the file at ``path`` even if it follows the
    ``.part-N`` naming convention.
    """
    from .readers._mpco import MPCOReader
    from .readers._mpco_multi import (
        MPCOMultiPartitionReader, discover_partition_files,
    )

    if isinstance(path, (list, tuple)):
        paths = [Path(p) for p in path]
        reader = (
            MPCOMultiPartitionReader(paths)
            if len(paths) > 1
            else MPCOReader(paths[0])
        )
        anchor = paths[0]
    else:
        anchor = Path(path)
        if merge_partitions:
            discovered = discover_partition_files(anchor)
        else:
            discovered = [anchor]
        if len(discovered) > 1:
            reader = MPCOMultiPartitionReader(discovered)
        else:
            reader = MPCOReader(discovered[0])
    bound_fem = resolve_bound_fem(reader, fem)
    return cls(reader, fem=bound_fem, path=anchor)

bind

bind(fem: 'FEMData') -> 'Results'

Re-bind to fem.

Useful when you've re-built the same mesh in a fresh session and want labels / Parts that the embedded snapshot doesn't carry. No hash validation is performed — pairing the FEMData with a results file from the same run is the user's responsibility.

Source code in src/apeGmsh/results/Results.py
def bind(self, fem: "FEMData") -> "Results":
    """Re-bind to ``fem``.

    Useful when you've re-built the same mesh in a fresh session
    and want labels / Parts that the embedded snapshot doesn't
    carry. No hash validation is performed — pairing the FEMData
    with a results file from the same run is the user's
    responsibility.
    """
    bound = resolve_bound_fem(self._reader, fem)
    return self._derive(fem=bound)

stage

stage(name_or_id: str) -> 'Results'

Return a Results scoped to a stage (matched by id or name).

Source code in src/apeGmsh/results/Results.py
def stage(self, name_or_id: str) -> "Results":
    """Return a Results scoped to a stage (matched by id or name)."""
    info = self._lookup_stage(name_or_id)
    return self._derive(stage_id=info.id)

close

close() -> None

Close the underlying reader (releases the HDF5 file handle).

Source code in src/apeGmsh/results/Results.py
def close(self) -> None:
    """Close the underlying reader (releases the HDF5 file handle)."""
    if hasattr(self._reader, "close"):
        self._reader.close()

viewer

viewer(*, blocking: bool = True, title: Optional[str] = None, restore_session: 'bool | str' = 'prompt', save_session: bool = True, cuts: 'Optional[Any]' = None, model_h5: 'Optional[Any]' = None)

Open the post-solve results viewer.

Parameters

blocking True (default) — open the viewer in-process and block the calling thread until the window closes. Matches the signature of :meth:g.mesh.viewer and :meth:g.model.viewer. False — spawn a subprocess via python -m apeGmsh.viewers <path> so the notebook / kernel can keep running. Requires that the Results was opened from disk (self._path is set); raises :class:RuntimeError for in-memory Results. title Optional window title; defaults to "Results — <filename>". restore_session How to handle a previously-saved session JSON next to the results file. True restores silently, False ignores, "prompt" (default) opens a yes/no dialog if a matching session exists. No effect for in-memory Results. save_session If True (default), the active set of diagrams + scrubber position is saved to <results>.viewer-session.json when the window closes. False disables auto-save. cuts Optional sequence of :class:apeGmsh.cuts.SectionCutDef instances to render as Layers at boot. Each cut becomes a new SectionCutDiagram in the active geometry's "Section cuts" composition (created if absent). Requires model_h5 so OpenSees tags can be mapped back to FEM eids — pass it alongside cuts. Subprocess launches (blocking=False) currently ignore this argument; build cuts programmatically via director.add_section_cut on the spawned viewer once it exposes IPC. model_h5 Path to a model.h5 that carries OpenSees enrichment: /opensees/cuts (for section-cut tag mapping) and / or /opensees/transforms + /opensees/element_meta (for per-element beam orientation, ADR 0018). When omitted and self was opened from disk via :meth:from_native, the viewer auto-resolves the results file itself as the orientation source if it carries those zones; cuts auto-load still requires an explicit model_h5=. Forwarded into the subprocess on blocking=False via --model-h5 so the auto-resolve also fires there for non-default layouts.

Returns

ResultsViewer The viewer instance after the window closes (blocking). subprocess.Popen The spawned process handle (non-blocking). None If APEGMSH_SKIP_VIEWER is set in the environment. This lets the same cell run under jupyter nbconvert --execute or in CI without spawning a GUI window.

Source code in src/apeGmsh/results/Results.py
def viewer(
    self,
    *,
    blocking: bool = True,
    title: Optional[str] = None,
    restore_session: "bool | str" = "prompt",
    save_session: bool = True,
    cuts: "Optional[Any]" = None,
    model_h5: "Optional[Any]" = None,
):
    """Open the post-solve results viewer.

    Parameters
    ----------
    blocking
        ``True`` (default) — open the viewer in-process and block
        the calling thread until the window closes. Matches the
        signature of :meth:`g.mesh.viewer` and :meth:`g.model.viewer`.
        ``False`` — spawn a subprocess via
        ``python -m apeGmsh.viewers <path>`` so the notebook /
        kernel can keep running. Requires that the Results was
        opened from disk (``self._path`` is set); raises
        :class:`RuntimeError` for in-memory Results.
    title
        Optional window title; defaults to ``"Results — <filename>"``.
    restore_session
        How to handle a previously-saved session JSON next to the
        results file. ``True`` restores silently, ``False`` ignores,
        ``"prompt"`` (default) opens a yes/no dialog if a matching
        session exists. No effect for in-memory Results.
    save_session
        If ``True`` (default), the active set of diagrams + scrubber
        position is saved to ``<results>.viewer-session.json`` when
        the window closes. ``False`` disables auto-save.
    cuts
        Optional sequence of :class:`apeGmsh.cuts.SectionCutDef`
        instances to render as Layers at boot. Each cut becomes a
        new ``SectionCutDiagram`` in the active geometry's
        ``"Section cuts"`` composition (created if absent). Requires
        ``model_h5`` so OpenSees tags can be mapped back to FEM
        eids — pass it alongside ``cuts``. Subprocess launches
        (``blocking=False``) currently ignore this argument; build
        cuts programmatically via ``director.add_section_cut`` on
        the spawned viewer once it exposes IPC.
    model_h5
        Path to a ``model.h5`` that carries OpenSees enrichment:
        ``/opensees/cuts`` (for section-cut tag mapping) and / or
        ``/opensees/transforms`` + ``/opensees/element_meta`` (for
        per-element beam orientation, ADR 0018). When omitted and
        ``self`` was opened from disk via :meth:`from_native`, the
        viewer auto-resolves the results file itself as the
        orientation source if it carries those zones; cuts
        auto-load still requires an explicit ``model_h5=``.
        Forwarded into the subprocess on ``blocking=False`` via
        ``--model-h5`` so the auto-resolve also fires there for
        non-default layouts.

    Returns
    -------
    ResultsViewer
        The viewer instance after the window closes (blocking).
    subprocess.Popen
        The spawned process handle (non-blocking).
    None
        If ``APEGMSH_SKIP_VIEWER`` is set in the environment. This
        lets the same cell run under ``jupyter nbconvert --execute``
        or in CI without spawning a GUI window.
    """
    import os
    if os.environ.get("APEGMSH_SKIP_VIEWER"):
        print("[skip viewer] APEGMSH_SKIP_VIEWER set")
        return None
    if not blocking:
        handle = self._spawn_viewer_subprocess(
            title=title, model_h5=model_h5,
        )
        # The subprocess opens its own NativeReader against the
        # path; the parent kernel's reader is no longer needed for
        # rendering. Close it here so the user can re-run a capture
        # script (which deletes / recreates the same .h5) without
        # hitting ``PermissionError: file is being used by another
        # process`` — Windows refuses to unlink a file that any
        # process has open, even read-only.
        #
        # If the user wants to keep querying ``results`` after the
        # spawn, they can re-bind via ``Results.from_native(path)``.
        try:
            self.close()
        except Exception:
            pass
        return handle
    from ..viewers.results_viewer import ResultsViewer
    return ResultsViewer(
        self,
        title=title,
        restore_session=restore_session,
        save_session=save_session,
        cuts=cuts,
        model_h5=model_h5,
    ).show()

Slabs

Tabular dataclasses returned by reader queries.

apeGmsh.results._slabs

Slab dataclasses returned by ResultsReader implementations.

A slab carries one component's values plus enough location metadata that the caller can interpret each row without re-deriving it. They are numpy-native and immutable; the viewer wraps them in xarray when it wants labeled axes.

Shape conventions (single-stage, post-stitching across partitions):

================ ======================== ================================================= Slab values shape Location index fields ================ ======================== ================================================= NodeSlab (T, N) node_ids: (N,) ElementSlab (T, E, npe) element_ids: (E,) LineStationSlab (T, sum_S) element_index, station_natural_coord: (sum_S,) GaussSlab (T, sum_GP) element_index: (sum_GP,), natural_coords: (sum_GP, dim) FiberSlab (T, sum_F) element_index, gp_index, y, z, area, material_tag: (sum_F,) LayerSlab (T, sum_L) element_index, gp_index, layer_index, sub_gp_index, thickness: (sum_L,) ================ ======================== =================================================

For a single time step (time_slice was a scalar), T is 1 and the leading axis is preserved — the caller can squeeze if desired.

NodeSlab dataclass

NodeSlab(component: str, values: ndarray, node_ids: ndarray, time: ndarray)

Node-level result values.

ElementSlab dataclass

ElementSlab(component: str, values: ndarray, element_ids: ndarray, time: ndarray)

Per-element-node values (e.g. globalForce / localForce).

LineStationSlab dataclass

LineStationSlab(component: str, values: ndarray, element_index: ndarray, station_natural_coord: ndarray, time: ndarray)

Beam line-diagram values per integration station.

GaussSlab dataclass

GaussSlab(component: str, values: ndarray, element_index: ndarray, natural_coords: ndarray, local_axes_quaternion: Optional[ndarray], time: ndarray)

Continuum Gauss-point values.

natural_coords are in parent space [-1, +1]. To get global coordinates, call slab.global_coords(fem) — interpolates through the bound FEMData's element shape functions for hex8 / quad4, falling back to a centroid + bbox-scaled approximation for element types that don't yet have explicit shape-fn support.

global_coords

global_coords(fem) -> ndarray

Map per-GP natural coords to (sum_GP, 3) world coords.

Uses element shape functions for supported types (hex8, quad4); falls back to centroid + 0.5 * bbox_span * natural for others — visualization-faithful for axis-aligned elements.

Source code in src/apeGmsh/results/_slabs.py
def global_coords(self, fem) -> ndarray:
    """Map per-GP natural coords to ``(sum_GP, 3)`` world coords.

    Uses element shape functions for supported types (hex8, quad4);
    falls back to ``centroid + 0.5 * bbox_span * natural`` for
    others — visualization-faithful for axis-aligned elements.
    """
    from ._gauss_world_coords import compute_global_coords
    return compute_global_coords(self, fem)

FiberSlab dataclass

FiberSlab(component: str, values: ndarray, element_index: ndarray, gp_index: ndarray, y: ndarray, z: ndarray, area: ndarray, material_tag: ndarray, time: ndarray)

Fiber-level values within fiber-section GPs.

LayerSlab dataclass

LayerSlab(component: str, values: ndarray, element_index: ndarray, gp_index: ndarray, layer_index: ndarray, sub_gp_index: ndarray, thickness: ndarray, local_axes_quaternion: ndarray, time: ndarray)

Layered shell layer values (one row per (elem, surf_gp, layer, sub_gp)).

SpringSlab dataclass

SpringSlab(component: str, values: ndarray, element_index: ndarray, time: ndarray)

Zero-length spring values (one column per element, one spring index).

component encodes which spring is represented (e.g. "spring_force_0" for the force in the first configured spring direction). Each column in values corresponds to one element; element_index carries the raw OpenSees element tag so the caller can correlate columns with elements without needing a separate ID array.

================ ======================== values (T, E) element_index (E,) ================ ========================

Readers

Reader protocol and supporting types shared by every backend.

apeGmsh.results.readers._protocol

Reader protocol — backend-agnostic contract for the composite layer.

Two implementations:

  • NativeReader reads apeGmsh native HDF5 (Phase 1).
  • MPCOReader reads STKO MPCO HDF5 (Phase 3).

The composite layer above (Results.nodes.get(...) etc.) talks only to this protocol — it never branches on backend type.

ResultLevel

Bases: Enum

The topology level a component lives at.

StageInfo dataclass

StageInfo(id: str, name: str, kind: str, n_steps: int, eigenvalue: Optional[float] = None, frequency_hz: Optional[float] = None, period_s: Optional[float] = None, mode_index: Optional[int] = None)

Stage metadata returned by ResultsReader.stages().

For kind="mode", the eigenvalue/frequency/period/index fields are populated and n_steps is 1. For other kinds, the mode-only fields are None.

ResultsReader

Bases: Protocol

Backend-agnostic reader protocol.

Implementations must support all six topology levels even if a given file has no data at some of them; they should return empty component lists from available_components() in that case rather than raising.

stages

stages() -> list[StageInfo]

All stages in this file, in write order.

Source code in src/apeGmsh/results/readers/_protocol.py
def stages(self) -> list[StageInfo]:
    """All stages in this file, in write order."""
    ...

time_vector

time_vector(stage_id: str) -> ndarray

Time vector for a stage. Shape (n_steps,).

Source code in src/apeGmsh/results/readers/_protocol.py
def time_vector(self, stage_id: str) -> ndarray:
    """Time vector for a stage. Shape ``(n_steps,)``."""
    ...

partitions

partitions(stage_id: str) -> list[str]

Partition IDs for a stage (always at least one).

Source code in src/apeGmsh/results/readers/_protocol.py
def partitions(self, stage_id: str) -> list[str]:
    """Partition IDs for a stage (always at least one)."""
    ...

fem

fem() -> 'Optional[FEMData]'

Embedded / synthesized FEMData snapshot.

  • NativeReader: reconstructs from /model/ (always available).
  • MPCOReader: synthesizes a partial FEMData from /MODEL/ (no apeGmsh labels, no Part provenance).
Source code in src/apeGmsh/results/readers/_protocol.py
def fem(self) -> "Optional[FEMData]":
    """Embedded / synthesized FEMData snapshot.

    - ``NativeReader``: reconstructs from ``/model/`` (always available).
    - ``MPCOReader``: synthesizes a partial FEMData from ``/MODEL/``
      (no apeGmsh labels, no Part provenance).
    """
    ...

available_components

available_components(stage_id: str, level: ResultLevel) -> list[str]

Canonical component names available at the given level.

Source code in src/apeGmsh/results/readers/_protocol.py
def available_components(
    self, stage_id: str, level: ResultLevel,
) -> list[str]:
    """Canonical component names available at the given level."""
    ...

Live capture

Recorder wiring used during a live OpenSees analysis.

LiveRecorders

apeGmsh.results.live._recorders.LiveRecorders

LiveRecorders(spec: 'ResolvedRecorderSpec', output_dir: 'str | Path', *, file_format: str = 'out', ops=None)

Context manager that owns stage-scoped OpenSees recorders.

Parameters

spec The :class:ResolvedRecorderSpec whose records to emit. output_dir Directory the recorder .out / .xml files land in. Created on __enter__ if missing. file_format "out" (text) or "xml". Defaults to "out". ops The openseespy module (or a stand-in for testing). Defaults to openseespy.opensees resolved lazily on __enter__.

Raises

RuntimeError On __enter__ if the spec contains any modal records.

Source code in src/apeGmsh/results/live/_recorders.py
def __init__(
    self,
    spec: "ResolvedRecorderSpec",
    output_dir: "str | Path",
    *,
    file_format: str = "out",
    ops=None,
) -> None:
    self._spec = spec
    self._output_dir = str(output_dir) if output_dir else ""
    self._file_format = file_format
    self._ops = ops

    self._opened = False
    self._exited = False
    self._current_stage: Optional[StageRecord] = None
    self._current_tags: list[int] = []
    self._stages: list[StageRecord] = []

stages property

stages: tuple[StageRecord, ...]

All completed stages, in the order they ran.

tags property

tags: tuple[int, ...]

Recorder tags issued so far across all stages (read-only).

begin_stage

begin_stage(name: str, kind: str = 'transient') -> None

Issue recorders for a new stage. Files are prefixed <name>__.

kind is forwarded to Results.from_recorders(..., stage_kind=kind) when the stage is read back; valid values are "transient" or "static".

Source code in src/apeGmsh/results/live/_recorders.py
def begin_stage(self, name: str, kind: str = "transient") -> None:
    """Issue recorders for a new stage. Files are prefixed ``<name>__``.

    ``kind`` is forwarded to ``Results.from_recorders(...,
    stage_kind=kind)`` when the stage is read back; valid values
    are ``"transient"`` or ``"static"``.
    """
    self._require_opened()
    if self._current_stage is not None:
        raise RuntimeError(
            f"begin_stage({name!r}) called while stage "
            f"{self._current_stage.name!r} is still open. Call "
            f"end_stage() first."
        )
    if not name:
        raise ValueError("Stage name must be a non-empty string.")
    if "__" in name:
        raise ValueError(
            f"Stage name {name!r} contains '__', which collides "
            f"with the stage/record filename separator."
        )

    self._current_stage = StageRecord(name=name, kind=kind)
    self._current_tags = []

    for record in self._spec.records:
        if record.category in _SUPPORTED_CATEGORIES:
            for logical in emit_logical(
                record,
                output_dir=self._output_dir,
                file_format=self._file_format,
                stage_id=name,
            ):
                args = to_ops_args(logical)
                tag = self._ops.recorder(*args)
                if isinstance(tag, int):
                    self._current_tags.append(tag)
            continue

        if record.category in _NON_RECORDER_CATEGORIES:
            warnings.warn(
                f"LiveRecorders: skipping record {record.name!r} "
                f"(category={record.category!r}); fiber/layer "
                f"data can't be emitted via classic recorders. "
                f"Use spec.capture(...) for in-process capture "
                f"or spec.emit_mpco(...) for the STKO MPCO "
                f"recorder.",
                stacklevel=2,
            )
            continue

        # Modal already raised in __enter__, but defend in depth.
        if record.category in _MODAL_CATEGORIES:
            continue

        warnings.warn(
            f"LiveRecorders: skipping record {record.name!r} "
            f"with unrecognised category={record.category!r}.",
            stacklevel=2,
        )

end_stage

end_stage() -> None

Remove the current stage's recorders and flush their files.

Source code in src/apeGmsh/results/live/_recorders.py
def end_stage(self) -> None:
    """Remove the current stage's recorders and flush their files."""
    self._require_opened()
    if self._current_stage is None:
        raise RuntimeError(
            "end_stage() called without a matching begin_stage()."
        )

    for tag in self._current_tags:
        try:
            self._ops.remove("recorder", tag)
        except Exception:  # noqa: BLE001 — best-effort flush
            warnings.warn(
                f"LiveRecorders: failed to remove recorder tag "
                f"{tag}; output file may not be flushed.",
                stacklevel=2,
            )

    completed = StageRecord(
        name=self._current_stage.name,
        kind=self._current_stage.kind,
        tags=tuple(self._current_tags),
    )
    self._stages.append(completed)
    self._current_stage = None
    self._current_tags = []

LiveMPCO

apeGmsh.results.live._mpco.LiveMPCO

LiveMPCO(spec: 'ResolvedRecorderSpec', path: 'str | Path', *, ops=None)

Context manager that owns a single in-process MPCO recorder.

Parameters

spec The :class:ResolvedRecorderSpec whose records to emit. path Output .mpco HDF5 file path. Parent directory created on __enter__ if missing. ops The openseespy module (or a stand-in for testing). Defaults to openseespy.opensees resolved lazily on __enter__.

Raises

RuntimeError On __enter__ if ops.recorder('mpco', ...) fails — most commonly because the openseespy build does not include the MPCO recorder.

Source code in src/apeGmsh/results/live/_mpco.py
def __init__(
    self,
    spec: "ResolvedRecorderSpec",
    path: "str | Path",
    *,
    ops=None,
) -> None:
    self._spec = spec
    self._path = Path(path)
    self._ops = ops

    self._opened = False
    self._exited = False
    self._tag: Optional[int] = None

tag property

tag: Optional[int]

The recorder tag returned by ops.recorder, if any.

DomainCapture

apeGmsh.results.capture._domain.DomainCapture

DomainCapture(spec: 'ResolvedDomainCaptureSpec', path: str | Path, fem: 'FEMData', *, ops: Any = None)

Context manager for in-process result capture.

Constructed live via ops.domain_capture(spec, path=...) (bridge-attached) or off-line via :meth:DomainCapture.from_h5 (ndm / ndf sourced from a model.h5 /meta). The user drives the analysis loop and calls step(t) to capture a snapshot.

Source code in src/apeGmsh/results/capture/_domain.py
def __init__(
    self,
    spec: "ResolvedDomainCaptureSpec",
    path: str | Path,
    fem: "FEMData",
    *,
    ops: Any = None,
) -> None:
    self._spec = spec
    self._path = Path(path)
    self._fem = fem
    # Per Phase 9 D8 ``ndm`` / ``ndf`` ride on the resolved spec —
    # set at resolve time by ``ops.domain_capture`` (live bridge)
    # or :meth:`from_h5` (file ``/meta``). The user never passes
    # them directly.
    self._ndm = spec.ndm
    self._ndf = spec.ndf
    self._ops = ops    # injected for testing; lazy-loaded otherwise

    self._writer = None
    self._current_stage: Optional[str] = None
    self._stage_kind: Optional[str] = None
    self._buffers: list[_NodesCapturer] = []
    # Element-level capturers — gauss (Phase 11a), line_stations +
    # nodal_forces (Phase 11b), fibers (Phase 11e). Layered shells
    # remain deferred (see __enter__).
    self._gauss_capturers: list[_GaussCapturer] = []
    self._line_station_capturers: list[_LineStationCapturer] = []
    self._nodal_force_capturers: list[_NodalForcesCapturer] = []
    self._fiber_capturers: list[_FiberCapturer] = []
    self._layer_capturers: list[_LayerCapturer] = []
    # Records that fall through every supported capturer — surface
    # in step() for visibility.
    self._element_level_records: list = []
    # Whether any nodes record needs reactions per-step.
    self._needs_reactions: bool = False

close

close() -> None

Finalise any open stage and close the writer.

Source code in src/apeGmsh/results/capture/_domain.py
def close(self) -> None:
    """Finalise any open stage and close the writer."""
    if self._current_stage is not None:
        self.end_stage()
    if self._writer is not None:
        self._writer.close()
        self._writer = None

from_h5 classmethod

from_h5(model_path: 'str | _Path', *, spec: 'DomainCaptureSpec', fem: 'FEMData', output: 'str | _Path', ops: Any = None) -> 'DomainCapture'

Construct a DomainCapture sourcing ndm / ndf from a model.h5.

Reads /meta/ndm and /meta/ndf from the supplied model_path, resolves spec against fem using those values, and returns a ready-to-use context manager writing to output. Use this entry point when you have a saved model.h5 but no live :class:apeSees bridge in the current process (per Phase 9 D8 the user never types ndm / ndf explicitly).

Parameters

model_path Path to a bridge-emitted model.h5 whose /meta carries ndm and ndf. spec Declarative :class:DomainCaptureSpec. Standalone (no bridge attached) is fine — this method supplies ndm / ndf from the file rather than from a bridge. fem The :class:FEMData to resolve selectors against. Must correspond to the same mesh that produced model_path. output Path the resulting :class:DomainCapture will write to. ops Optional openseespy module (or test stand-in). Defaults to lazy-loading openseespy.opensees.

Returns

DomainCapture Ready to be used as a context manager.

Source code in src/apeGmsh/results/capture/_domain.py
@classmethod
def from_h5(
    cls,
    model_path: "str | _Path",
    *,
    spec: "DomainCaptureSpec",
    fem: "FEMData",
    output: "str | _Path",
    ops: Any = None,
) -> "DomainCapture":
    """Construct a DomainCapture sourcing ``ndm`` / ``ndf`` from a model.h5.

    Reads ``/meta/ndm`` and ``/meta/ndf`` from the supplied
    ``model_path``, resolves ``spec`` against ``fem`` using those
    values, and returns a ready-to-use context manager writing
    to ``output``. Use this entry point when you have a saved
    ``model.h5`` but no live :class:`apeSees` bridge in the
    current process (per Phase 9 D8 the user never types
    ``ndm`` / ``ndf`` explicitly).

    Parameters
    ----------
    model_path
        Path to a bridge-emitted ``model.h5`` whose ``/meta``
        carries ``ndm`` and ``ndf``.
    spec
        Declarative :class:`DomainCaptureSpec`. Standalone (no
        bridge attached) is fine — this method supplies
        ``ndm`` / ``ndf`` from the file rather than from a bridge.
    fem
        The :class:`FEMData` to resolve selectors against. Must
        correspond to the same mesh that produced ``model_path``.
    output
        Path the resulting :class:`DomainCapture` will write to.
    ops
        Optional openseespy module (or test stand-in). Defaults
        to lazy-loading ``openseespy.opensees``.

    Returns
    -------
    DomainCapture
        Ready to be used as a context manager.
    """
    from ...opensees.emitter import h5_reader

    with h5_reader.open(str(model_path)) as model:
        meta = model.meta()
        try:
            ndm = int(meta["ndm"])
            ndf = int(meta["ndf"])
        except KeyError as exc:
            raise RuntimeError(
                f"DomainCapture.from_h5: {model_path!s} has no "
                f"ndm/ndf attrs in /meta (got {sorted(meta)!r})."
            ) from exc
    resolved = spec._resolve_with_explicit_ndm_ndf(
        fem, ndm=ndm, ndf=ndf,
    )
    return cls(resolved, output, fem, ops=ops)

begin_stage

begin_stage(name: str, kind: str = 'transient') -> str

Open a new stage. Returns the stage_id.

Source code in src/apeGmsh/results/capture/_domain.py
def begin_stage(self, name: str, kind: str = "transient") -> str:
    """Open a new stage. Returns the stage_id."""
    if self._writer is None:
        raise RuntimeError(
            "DomainCapture is not open. Use as a context manager."
        )
    if self._current_stage is not None:
        raise RuntimeError(
            f"Stage {self._current_stage!r} still open — call end_stage()."
        )
    # Reset per-stage buffers
    for cap in self._buffers:
        cap.reset()
    for gc in self._gauss_capturers:
        gc.reset()
    for lc in self._line_station_capturers:
        lc.reset()
    for nfc in self._nodal_force_capturers:
        nfc.reset()
    for fc in self._fiber_capturers:
        fc.reset()
    for lc in self._layer_capturers:
        lc.reset()
    # Stage gets a placeholder time vector that we'll fill at end_stage.
    # We can't pre-create the time dataset here because we don't yet
    # know how many steps. We start the stage with an empty time
    # vector and write everything (including time) at end_stage.
    self._current_stage = name
    self._stage_kind = kind
    return name

step

step(t: float) -> None

Capture one snapshot at simulation time t.

Source code in src/apeGmsh/results/capture/_domain.py
def step(self, t: float) -> None:
    """Capture one snapshot at simulation time ``t``."""
    if self._current_stage is None:
        raise RuntimeError("No stage open — call begin_stage() first.")
    if self._element_level_records:
        cats = sorted({r.category for r in self._element_level_records})
        raise NotImplementedError(
            f"DomainCapture does not support element-level "
            f"records of category {cats}. Supported categories: "
            f"``nodes`` (Phase 3), ``modal`` (Phase 3), ``gauss`` "
            f"(Phase 11a), ``line_stations`` (Phase 11b), "
            f"``elements`` per-element-node forces (Phase 11b). "
            f"Fibers and layers are MPCO-only (Phase 11c) — see "
            f"the module docstring."
        )
    ops = self._lazy_ops()
    if self._needs_reactions:
        # Refresh the cached reactions in the domain.
        ops.reactions()
    for cap in self._buffers:
        cap.step(t, ops)
    for gc in self._gauss_capturers:
        gc.step(t, ops)
    for lc in self._line_station_capturers:
        lc.step(t, ops)
    for nfc in self._nodal_force_capturers:
        nfc.step(t, ops)
    for fc in self._fiber_capturers:
        fc.step(t, ops)
    for lc in self._layer_capturers:
        lc.step(t, ops)

end_stage

end_stage() -> None

Flush buffered data for the current stage to disk.

Multiple nodes records may target different node subsets (e.g. displacements on all nodes + reactions on fixed nodes only). The native schema has one _ids per partition, so we merge: take the union of node IDs across records, fill each component with NaN at slots the record didn't visit.

Source code in src/apeGmsh/results/capture/_domain.py
def end_stage(self) -> None:
    """Flush buffered data for the current stage to disk.

    Multiple ``nodes`` records may target different node subsets
    (e.g. displacements on all nodes + reactions on fixed nodes
    only). The native schema has one ``_ids`` per partition, so
    we merge: take the union of node IDs across records, fill
    each component with ``NaN`` at slots the record didn't visit.
    """
    if self._current_stage is None:
        raise RuntimeError("No stage open.")
    assert self._writer is not None

    # Time vector — they must all match (same step cadence).
    time_vec = np.array([], dtype=np.float64)
    for cap in self._buffers:
        if cap._times:
            time_vec = np.array(cap._times, dtype=np.float64)
            break
    if time_vec.size == 0:
        for gc in self._gauss_capturers:
            if gc._times:
                time_vec = np.array(gc._times, dtype=np.float64)
                break
    if time_vec.size == 0:
        for lc in self._line_station_capturers:
            if lc._times:
                time_vec = np.array(lc._times, dtype=np.float64)
                break
    if time_vec.size == 0:
        for nfc in self._nodal_force_capturers:
            if nfc._times:
                time_vec = np.array(nfc._times, dtype=np.float64)
                break
    if time_vec.size == 0:
        for fc in self._fiber_capturers:
            if fc._times:
                time_vec = np.array(fc._times, dtype=np.float64)
                break
    if time_vec.size == 0:
        for lc in self._layer_capturers:
            if lc._times:
                time_vec = np.array(lc._times, dtype=np.float64)
                break

    sid = self._writer.begin_stage(
        name=self._current_stage,
        kind=self._stage_kind or "transient",
        time=time_vec,
    )

    try:
        self._flush_nodes_merged(sid, time_vec)
        self._flush_gauss(sid)
        self._flush_line_stations(sid)
        self._flush_nodal_forces(sid)
        self._flush_fibers(sid)
        self._flush_layers(sid)
    finally:
        # Always close the stage — even if the merge raised, we
        # want the stage closed so subsequent ``begin_stage`` works.
        self._writer.end_stage()
        self._current_stage = None
        self._stage_kind = None

    # ── Skip-summary warnings ─────────────────────────────────
    # Surface any elements the line-stations capturers had to
    # drop (typically disp-based beams whose IP coords aren't
    # introspectable from openseespy in OpenSees v3.7.x). Until
    # this warning lands the user had no signal that their
    # recorder spec was partially honoured.
    self._warn_about_skipped_line_station_elements()

capture_modes

capture_modes(n_modes: Optional[int] = None) -> None

Run ops.eigen() and write one mode-kind stage per mode.

n_modes defaults to the maximum across all modal records in the spec. Pass an explicit value to override.

Source code in src/apeGmsh/results/capture/_domain.py
def capture_modes(self, n_modes: Optional[int] = None) -> None:
    """Run ``ops.eigen()`` and write one mode-kind stage per mode.

    ``n_modes`` defaults to the maximum across all ``modal``
    records in the spec. Pass an explicit value to override.
    """
    modal_records = [
        r for r in self._spec.records if r.category == "modal"
    ]
    if n_modes is None:
        if not modal_records:
            return
        n_modes = max(r.n_modes for r in modal_records)
    if n_modes <= 0:
        return

    ops = self._lazy_ops()
    eigenvalues = ops.eigen(n_modes)
    # ``ops.eigen`` returns a list of eigenvalues (length n_modes).

    node_ids = np.asarray(self._fem.nodes.ids, dtype=np.int64)
    for mode_idx, lam in enumerate(eigenvalues, start=1):
        omega = math.sqrt(lam) if lam > 0 else 0.0
        freq_hz = omega / (2.0 * math.pi)
        period_s = (2.0 * math.pi / omega) if omega > 0 else 0.0

        sid = self._writer.begin_stage(
            name=f"mode_{mode_idx}",
            kind="mode",
            time=np.array([0.0]),
            eigenvalue=float(lam),
            frequency_hz=float(freq_hz),
            period_s=float(period_s),
            mode_index=mode_idx,
        )

        components: dict[str, ndarray] = {}
        # Translational
        axes = ("x", "y", "z")
        for axis_idx in range(min(3, self._ndm)):
            axis = axes[axis_idx]
            shape = np.array([
                ops.nodeEigenvector(int(nid), mode_idx, axis_idx + 1)
                for nid in node_ids
            ], dtype=np.float64)
            components[f"displacement_{axis}"] = shape[None, :]
        # Rotational (only when the model has rotational DOFs)
        if self._ndf >= 6:
            for axis_idx in range(3):
                axis = axes[axis_idx]
                shape = np.array([
                    ops.nodeEigenvector(int(nid), mode_idx, axis_idx + 4)
                    for nid in node_ids
                ], dtype=np.float64)
                components[f"rotation_{axis}"] = shape[None, :]

        self._writer.write_nodes(
            sid, "partition_0",
            node_ids=node_ids,
            components=components,
        )
        self._writer.end_stage()

Inspect

apeGmsh.results._inspect.ResultsInspect

ResultsInspect(results: 'Results')

results.inspect — what's available.

Source code in src/apeGmsh/results/_inspect.py
def __init__(self, results: "Results") -> None:
    self._r = results

summary

summary() -> str

Multi-line human-readable summary.

Source code in src/apeGmsh/results/_inspect.py
def summary(self) -> str:
    """Multi-line human-readable summary."""
    r = self._r
    lines = [f"Results: {r._reader_path()!s}"]

    fem = r.fem
    if fem is not None:
        lines.append(
            f"  FEM: {len(fem.nodes.ids)} nodes, "
            f"{sum(len(g) for g in fem.elements)} elements "
            f"(snapshot_id={fem.snapshot_id})"
        )
    else:
        lines.append("  FEM: not bound")

    stages = r.stages
    if not stages:
        lines.append("  Stages: (none)")
    else:
        lines.append(f"  Stages ({len(stages)}):")
        for s in stages:
            detail = f"steps={s.n_steps}, kind={s.kind}"
            if s.kind == "mode":
                detail += (
                    f", f={s.frequency_hz:.4g} Hz, "
                    f"T={s.period_s:.4g} s, "
                    f"mode_index={s.mode_index}"
                )
            lines.append(f"    - {s.id} ({s.name}): {detail}")

    return "\n".join(lines)

components

components(*, stage: str | None = None) -> dict[str, list[str]]

Available components per topology level for one stage.

If no stage is given, defaults to the only stage when there is exactly one; otherwise raises.

Source code in src/apeGmsh/results/_inspect.py
def components(
    self, *, stage: str | None = None,
) -> dict[str, list[str]]:
    """Available components per topology level for one stage.

    If no stage is given, defaults to the only stage when there is
    exactly one; otherwise raises.
    """
    sid = self._r._resolve_stage(stage)
    return {
        level.value: self._r._reader.available_components(sid, level)
        for level in ResultLevel
    }

diagnose

diagnose(component: str, *, stage: str | None = None) -> str

Explain where a component lives (or doesn't) in this stage.

When a viewer or downstream consumer asks for a component and gets nothing back, this is the routing-side answer to "why is the slab empty?". Walks every topology, calls each composite's available_components(), and returns a human-readable report that shows where component was found and what's actually available at each level.

Parameters

component Canonical component name (e.g. "axial_force", "displacement_z", "stress_xx"). stage Stage id or name. Defaults to the only stage when there is exactly one.

Returns

str Multi-line report. Print it or include it in an error message.

Source code in src/apeGmsh/results/_inspect.py
def diagnose(
    self,
    component: str,
    *,
    stage: str | None = None,
) -> str:
    """Explain where a component lives (or doesn't) in this stage.

    When a viewer or downstream consumer asks for a component and
    gets nothing back, this is the routing-side answer to "why
    is the slab empty?". Walks every topology, calls each
    composite's ``available_components()``, and returns a
    human-readable report that shows where ``component`` was
    found and what's actually available at each level.

    Parameters
    ----------
    component
        Canonical component name (e.g. ``"axial_force"``,
        ``"displacement_z"``, ``"stress_xx"``).
    stage
        Stage id or name. Defaults to the only stage when there
        is exactly one.

    Returns
    -------
    str
        Multi-line report. Print it or include it in an error
        message.
    """
    try:
        sid = self._r._resolve_stage(stage)
    except Exception as exc:
        return f"diagnose({component!r}): could not resolve stage: {exc}"

    lines = [
        f"diagnose({component!r}) — stage={sid!r}",
    ]
    per_level: list[tuple[str, list[str], bool]] = []
    errors: list[tuple[str, str]] = []
    found: list[str] = []

    for level in ResultLevel:
        try:
            comps = self._r._reader.available_components(sid, level)
        except Exception as exc:
            errors.append((level.value, f"{type(exc).__name__}: {exc}"))
            continue
        is_match = component in comps
        per_level.append((level.value, comps, is_match))
        if is_match:
            found.append(level.value)

    if found:
        lines.append(f"  FOUND in: {', '.join(found)}")
    else:
        lines.append("  NOT FOUND in any topology level.")

    # Per-level preview — same for found and not-found, so the user
    # always sees what's actually present at each level.
    for level_value, comps, is_match in per_level:
        preview = ", ".join(comps[:6])
        if len(comps) > 6:
            preview += f", … (+{len(comps) - 6} more)"
        available = preview if comps else "(empty — no buckets present)"
        marker = "✓" if is_match else " "
        lines.append(
            f"    {marker} {level_value:16s}  available: {available}"
        )

    for level_value, msg in errors:
        lines.append(f"      {level_value:16s}  error: {msg}")

    if not found:
        lines.append("")
        lines.append(
            "  If you expected the component above, try:"
        )
        lines.append(
            "    * Check spelling against ``results.inspect.components()``."
        )
        lines.append(
            "    * Check the recorder declared this component in this stage."
        )
        lines.append(
            "    * For MPCO files: confirm the underlying recorder "
            "wrote a bucket the reader knows about (section.force, "
            "localForce, etc.)."
        )

    return "\n".join(lines)

Vocabulary

Canonical result names and shorthand expansion.

apeGmsh.results._vocabulary

apeGmsh.results._vocabulary — deprecation shim (Phase 9).

The canonical vocabulary moved to :mod:apeGmsh._vocabulary so the OpenSees bridge (declaration-side) and the results module (consumer-side) can both import without a layering inversion.

This shim fires a :class:DeprecationWarning once on first import and re-exports the canonical names for one release cycle. Internal apeGmsh code imports from :mod:apeGmsh._vocabulary directly; only external callers see the warning.

ALL_CANONICAL module-attribute

ALL_CANONICAL: frozenset[str] = frozenset(NODAL_KINEMATICS + NODAL_FORCES + PER_ELEMENT_NODAL_FORCES + LINE_DIAGRAMS + LINE_STATION_DEFORMATIONS + STRESS + STRAIN + DERIVED_SCALARS + FIBER + SPRING + MATERIAL_STATE)

expand_shorthand

expand_shorthand(name: str, *, ndm: int = 3, ndf: int = 6) -> tuple[str, ...]

Expand a shorthand or pass through a canonical name.

Translational shorthands clip to ndm axes (e.g. ndm=2displacement_x/y only). Rotational shorthands require rotational DOFs in the active ndf and return () if there are none. Tensor shorthands ("stress", "strain") clip to 3 components in ndm=2 (xx, yy, xy) and 6 in ndm=3.

Raises ValueError if name is neither a known shorthand nor a canonical name.

Source code in src/apeGmsh/_vocabulary.py
def expand_shorthand(
    name: str, *, ndm: int = 3, ndf: int = 6,
) -> tuple[str, ...]:
    """Expand a shorthand or pass through a canonical name.

    Translational shorthands clip to ``ndm`` axes (e.g. ``ndm=2`` →
    ``displacement_x/y`` only). Rotational shorthands require
    rotational DOFs in the active ``ndf`` and return ``()`` if there
    are none. Tensor shorthands (``"stress"``, ``"strain"``) clip to
    3 components in ``ndm=2`` (xx, yy, xy) and 6 in ``ndm=3``.

    Raises ``ValueError`` if ``name`` is neither a known shorthand
    nor a canonical name.
    """
    if is_canonical(name):
        return (name,)

    if name in _SHORTHAND_TRANSLATIONAL:
        full = _SHORTHAND_TRANSLATIONAL[name]
        return _clip_translational(full, ndm)

    if name in _SHORTHAND_ROTATIONAL:
        full = _SHORTHAND_ROTATIONAL[name]
        return _clip_rotational(full, ndm, ndf)

    if name in _SHORTHAND_TENSOR:
        full = _SHORTHAND_TENSOR[name]
        return _clip_tensor(full, ndm)

    if name in _SHORTHAND_LINE_STATION:
        # Line-station shorthands are not clipped by ``ndm``/``ndf``:
        # the catalog declares per-element which subset is emitted,
        # and ``Results`` returns empty slabs for components a given
        # element doesn't expose. Clipping here would hide tokens
        # users genuinely want (e.g. asking for ``section_force`` on
        # a 3D model and missing ``torsion``).
        return _SHORTHAND_LINE_STATION[name]

    if name == "reaction":
        forces = _clip_translational(_SHORTHAND_REACTION[:3], ndm)
        moments = _clip_rotational(_SHORTHAND_REACTION[3:], ndm, ndf)
        return forces + moments

    raise ValueError(
        f"Unknown component '{name}'. Must be a canonical name "
        f"(e.g. 'displacement_x', see ALL_CANONICAL) or a known shorthand "
        f"({sorted(ALL_SHORTHANDS)})."
    )

is_canonical

is_canonical(name: str) -> bool

True if name is a known canonical component name.

Source code in src/apeGmsh/_vocabulary.py
def is_canonical(name: str) -> bool:
    """True if ``name`` is a known canonical component name."""
    if name in ALL_CANONICAL:
        return True
    # Pattern: state_variable_<integer>.
    if name.startswith("state_variable_"):
        suffix = name[len("state_variable_"):]
        return suffix.isdigit()
    # Patterns: fiber_stress_<integer> / fiber_strain_<integer>.
    # Layered shells write a vector per layer (e.g. 5-component
    # plane-stress + transverse-shear), not a scalar; the canonical
    # split is index-based when META labels are generic.
    for stem in ("fiber_stress_", "fiber_strain_"):
        if name.startswith(stem):
            suffix = name[len(stem):]
            if suffix.isdigit():
                return True
    # Patterns: spring_force_<integer> / spring_deformation_<integer>.
    # ZeroLength elements can have N springs; each gets an indexed
    # canonical tied to its position in the configured direction list.
    for stem in ("spring_force_", "spring_deformation_"):
        if name.startswith(stem):
            suffix = name[len(stem):]
            if suffix.isdigit():
                return True
    return False