Skip to content

Results

Post-processing container.

Construction — model= is required

Every Results constructor requires a model, so the post-processing container always has a read-side broker to resolve names, PGs, and connectivity against:

from apeGmsh import Results
from apeGmsh.opensees import OpenSeesModel

# Native apeGmsh HDF5 (a Composed file carrying results + model is common)
model = OpenSeesModel.from_h5("run.h5")
results = Results.from_native("run.h5", model=model)     # model= REQUIRED

# STKO .mpco — model_h5= points at the sibling model archive
results = Results.from_mpco("run.mpco", model_h5="model.h5")  # model_h5= REQUIRED

# Live recorders — fem= and model=
results = Results.from_recorders(spec, "out/", fem=fem, model=model)

Omitting model= (or model_h5= for from_mpco) raises TypeError.

results.model is never None on a constructed Results (ADR 0020 INV-1). Reach the neutral FEMData zone through the broker chain:

osm = results.model            # OpenSeesModel broker (never None)
fem = results.model.fem        # neutral FEMData zone

results.fem is the locally-bound snapshot and may differ from results.model.fem after a .bind().

Lineage — results.lineage warns, never raises

results.lineage returns a Lineage(fem_hash, model_hash, results_hash, warnings) describing the git-style fem → model → results hash chain. Stored-vs-recomputed mismatches surface as [lineage] ... strings in lineage.warnings; the property itself never raises. Call lineage.assert_clean() to escalate any warnings to LineageError.

BindError is gone

Construction no longer rejects a mismatched FEM with BindError (deleted in the three-broker refactor). results.bind(fem) performs no hash validation — pairing a FEM with the right run is the user's responsibility, reported through lineage.warnings.

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, model: 'OpenSeesModel', model_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,
    model: "OpenSeesModel",
    model_path: Optional[Path] = None,
) -> None:
    self._reader = reader
    self._fem = fem
    self._stage_id = stage_id
    self._path = path
    # ADR 0020 INV-1 (Phase 8 prune) — ``_model`` is required and
    # never None.  The three public constructors validate the
    # contract and raise :class:`TypeError` on missing supply;
    # internal callers (``_derive``) propagate the existing handle.
    self._model = model
    # Sibling-archive path for readers that carry no embedded
    # ``/opensees/`` zone (MPCO).  ``None`` when ``self._path``
    # already is the model archive (native Composed file).  The
    # subprocess viewer reads this to forward ``--model-h5`` to
    # the child process — without it, ``__main__.py`` exits(2)
    # on ``.mpco`` paths.
    self._model_path = model_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.

model property

model: 'OpenSeesModel'

The bound :class:OpenSeesModel broker.

Phase 8 (ADR 0020 INV-1) — always non-None on a constructed :class:Results. The chain-forward handle from which the OpenSeesModel and its embedded FEMData can be reached.

lineage property

lineage: 'Lineage'

Phase-6 lineage chain — git-style fem → model → results.

ADR 0021 defines a three-link hash chain fem_hash → model_hash → results_hash where each layer's hash includes its parent's hash (one-directional, tamper-evident). Mismatches between stored and recomputed hashes surface as [lineage] ... warnings in :attr:Lineage.warnings; they never raise from this property (INV-2).

Phase-8 derivation order:

  1. Inherit fem_hash + model_hash + accumulated warnings from :attr:model.lineage (the broker recomputes against the same file).
  2. Read the stored /meta/lineage/results_hash via the reader's results_lineage_attrs helper and recompute from /stages/... via recompute_results_hash; append a drift warning on mismatch.

Readers that don't implement the Phase-6 result-layer protocol methods are tolerated via getattr cushions: their lineage stays at the model layer, no warning emitted.

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

eigen_modes property

eigen_modes: list[EigenMode]

Mode-kind stages as lightweight :class:EigenMode snapshots.

Each :class:EigenMode carries only the four scalar fields (mode_index, eigenvalue, frequency_hz, period_s) — no file handle, no mode-shape arrays. Use this when you need the eigenvalue spectrum but not the per-node shapes (e.g. an LTB Mcr probe, a pickle-able report, or a return value from a function whose Results context is about to be closed).

For the per-node mode shape arrays, use the mode-scoped :class:Results from :attr:modes instead and query via mode.nodes.get(component="displacement_x", ...).

Order matches :attr:modes. For a stable lookup by index, sort: sorted(results.eigen_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, model: 'Optional[OpenSeesModel]' = None, model_path: 'Optional[str | Path]' = None) -> 'Results'

Open an apeGmsh native HDF5 results file.

Phase 8 (ADR 0020 INV-1) — model= is required. Missing supply raises :class:TypeError. Pass model=OpenSeesModel.from_h5(path_to_model_h5) (often the same path as path when the file is a Composed-file per ADR 0020).

If fem is omitted, the embedded /model/ snapshot is used as the bound FEMData.

model_path records the on-disk archive the model was read from, for when it is not path itself — e.g. results whose embedded /model zone is not independently readable. The non-blocking subprocess viewer forwards it as --model-h5 so the child re-reads the model from there instead of from path.

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

    Phase 8 (ADR 0020 INV-1) — ``model=`` is required. Missing
    supply raises :class:`TypeError`. Pass
    ``model=OpenSeesModel.from_h5(path_to_model_h5)`` (often the
    same path as ``path`` when the file is a Composed-file
    per ADR 0020).

    If ``fem`` is omitted, the embedded ``/model/`` snapshot is
    used as the bound FEMData.

    ``model_path`` records the on-disk archive the ``model`` was read
    from, for when it is *not* ``path`` itself — e.g. results whose
    embedded ``/model`` zone is not independently readable. The
    non-blocking subprocess viewer forwards it as ``--model-h5`` so the
    child re-reads the model from there instead of from ``path``.
    """
    if model is None:
        raise TypeError(_MODEL_REQUIRED_MESSAGE)
    from .readers._native import NativeReader
    reader = NativeReader(path)
    bound_fem = _resolve_fem(reader, fem)
    bound_model = resolve_bound_model(reader, model)
    # ``resolve_bound_model`` always returns ``model`` here since
    # we just asserted it is non-None, but route through the helper
    # to keep the resolution semantics in one place.
    assert bound_model is not None
    return cls(
        reader, fem=bound_fem, path=Path(path), model=bound_model,
        model_path=Path(model_path) if model_path is not None else None,
    )._with_autoloaded_definitions()

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, model: 'Optional[OpenSeesModel]' = None) -> 'Results'

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

Phase 8 (ADR 0020 INV-1) — model= is required. Missing supply raises :class:TypeError. The model's /opensees/ zone is embedded into the transcoded native h5 (the Composed-file pattern); downstream :meth:Results.from_native then auto-resolves the broker from the same file.

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,
    model: "Optional[OpenSeesModel]" = None,
) -> "Results":
    """Open the result of an OpenSees run driven by Tcl/Py recorders.

    Phase 8 (ADR 0020 INV-1) — ``model=`` is required. Missing
    supply raises :class:`TypeError`. The model's ``/opensees/``
    zone is embedded into the transcoded native h5 (the
    Composed-file pattern); downstream
    :meth:`Results.from_native` then auto-resolves the broker
    from the same file.

    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.
    """
    if model is None:
        raise TypeError(_MODEL_REQUIRED_MESSAGE)
    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():
        # Materialise the model's ``/opensees/`` zone alongside the
        # transcoded results.  ``OpenSeesModel.to_h5`` (the public,
        # schema-authority-respecting writer) shapes the source;
        # NativeWriter copies the ``/opensees/`` group at open
        # time (Composed-file pattern, ADR 0020 INV-3 preserved).
        model_h5_src: Path = cached_h5.with_suffix(".model.h5")
        model.to_h5(model_h5_src)
        transcoder = RecorderTranscoder(
            spec, out_dir, cached_h5, fem,
            stage_name=stage_name,
            stage_kind=stage_kind,
            file_format=file_format,
            stage_id=stage_id,
            model_h5_src=model_h5_src,
        )
        transcoder.run()

    return cls.from_native(cached_h5, fem=fem, model=model)

from_mpco classmethod

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

Open a STKO .mpco HDF5 results file.

Phase 8 (ADR 0020 INV-1) — model_h5= is required. Missing supply raises :class:TypeError. The broker is loaded via :meth:OpenSeesModel.from_h5 and attached to the resulting :class:Results; INV-3 — the broker is held in memory only (no derived results.h5 is written copying the /opensees/ zone in).

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,
    model_h5: "Optional[str | Path]" = None,
) -> "Results":
    """Open a STKO ``.mpco`` HDF5 results file.

    Phase 8 (ADR 0020 INV-1) — ``model_h5=`` is required. Missing
    supply raises :class:`TypeError`. The broker is loaded via
    :meth:`OpenSeesModel.from_h5` and attached to the resulting
    :class:`Results`; INV-3 — the broker is held *in memory only*
    (no derived ``results.h5`` is written copying the
    ``/opensees/`` zone in).

    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.
    """
    if model_h5 is None:
        raise TypeError(_MODEL_H5_REQUIRED_MESSAGE)
    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_fem(reader, fem)
    # Per INV-3, this is an in-memory rehydrate from the sibling
    # file; we never copy the zone into a derived h5.
    from ..opensees.opensees_model import OpenSeesModel
    bound_model = OpenSeesModel.from_h5(model_h5)
    # ADR 0043 slice 1.3 — MPCO buckets key element results by the
    # OpenSees ops tag; the results API speaks fem_eid. Whenever the
    # bound model carries a real element_meta pairing, the reader must
    # relabel ops↔fem through it. This is NOT compose-only: gmsh
    # numbers lower-dimensional elements first, so almost any solid
    # model with surface physical groups has fem_eid != ops_tag even
    # uncomposed (the former compose-provenance gate silently
    # scrambled / dropped element results for exactly that case —
    # fixed 2026-08-04). A deliberately-unrelated stub ``model_h5=``
    # (common in tests) stays safe through the translator's
    # all-or-nothing `_relabel` contract: ids the model does not
    # fully describe pass through untranslated.
    from .readers._tag_translation import ElementTagTranslator
    _tag_map = ElementTagTranslator.from_model(bound_model)
    if not _tag_map.is_empty:
        reader.attach_tag_map(_tag_map)
    return cls(
        reader, fem=bound_fem, path=anchor, model=bound_model,
        model_path=Path(model_h5),
    )._with_autoloaded_definitions()

from_ladruno classmethod

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

Open a Ladruno .ladruno HDF5 results file.

The Ladruno recorder is the fork's canonical recorder. Unlike .mpco (and unlike :meth:from_mpco, which requires model_h5=), a .ladruno is self-sufficient — it carries its own geometry, regions and beam local axes (schema Principle 0: "this is the native path; no sibling file"). So model_h5= is optional:

  • omitted → the broker is built in-memory from the file's own MODEL group (geometry + inferred ndm/ndf; bridge record zones empty). This is read-time interpretation, not a transcode.
  • supplied → the richer broker is loaded via :meth:OpenSeesModel.from_h5 (full bridge records + lineage), and — whenever the model records an element_meta pairing — the fem_eid↔ops-tag translator is attached (ADR 0043; required for composed models AND for any sparsely-renumbered mesh, e.g. a gmsh solid whose 2-D boundary elements consumed the low ids).

Keys on INFO/GENERATOR="Ladruno" + a supported FORMAT_VERSION (the reader rejects a .mpco / foreign file or an out-of-window version loudly).

Multi-partition merge: a parallel run writes one <stem>.part-<N>.ladruno per rank. Passing one partition path auto-discovers its siblings (<stem>.part-*.ladruno) and merges them into one virtual reader (node-union + element-concat); passing a list merges exactly those paths. merge_partitions=False opts out of sibling auto-discovery.

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

    The Ladruno recorder is the fork's *canonical* recorder. Unlike
    ``.mpco`` (and unlike :meth:`from_mpco`, which **requires**
    ``model_h5=``), a ``.ladruno`` is **self-sufficient** — it carries
    its own geometry, regions and beam local axes (schema Principle 0:
    "this *is* the native path; no sibling file"). So ``model_h5=`` is
    **optional**:

    * omitted → the broker is built in-memory from the file's own
      ``MODEL`` group (geometry + inferred ``ndm``/``ndf``; bridge
      record zones empty). This is read-time interpretation, not a
      transcode.
    * supplied → the richer broker is loaded via
      :meth:`OpenSeesModel.from_h5` (full bridge records + lineage),
      and — whenever the model records an element_meta pairing —
      the fem_eid↔ops-tag translator is attached (ADR 0043; required
      for composed models AND for any sparsely-renumbered mesh, e.g.
      a gmsh solid whose 2-D boundary elements consumed the low ids).

    Keys on ``INFO/GENERATOR="Ladruno"`` + a supported
    ``FORMAT_VERSION`` (the reader rejects a ``.mpco`` / foreign file
    or an out-of-window version loudly).

    Multi-partition merge: a parallel run writes one
    ``<stem>.part-<N>.ladruno`` per rank. Passing one partition path
    auto-discovers its siblings (``<stem>.part-*.ladruno``) and merges
    them into one virtual reader (node-union + element-concat);
    passing a list merges exactly those paths. ``merge_partitions=False``
    opts out of sibling auto-discovery.
    """
    from .readers._ladruno import LadrunoReader
    from .readers._ladruno_multi import (
        LadrunoMultiPartitionReader, discover_partition_files,
    )

    if isinstance(path, (list, tuple)):
        paths = [Path(p) for p in path]
        reader = (
            LadrunoMultiPartitionReader(paths)
            if len(paths) > 1
            else LadrunoReader(paths[0])
        )
        anchor = paths[0]
    else:
        anchor = Path(path)
        discovered = (
            discover_partition_files(anchor)
            if merge_partitions else [anchor]
        )
        reader = (
            LadrunoMultiPartitionReader(discovered)
            if len(discovered) > 1
            else LadrunoReader(discovered[0])
        )
    bound_fem = _resolve_fem(reader, fem)
    bound_model: "Optional[OpenSeesModel]"
    if model_h5 is not None:
        from ..opensees.opensees_model import OpenSeesModel
        bound_model = OpenSeesModel.from_h5(model_h5)
        # Attach the fem_eid↔ops-tag translator whenever the model
        # carries a real element_meta pairing — see the twin comment
        # in :meth:`from_mpco` (the pairing diverges for ANY sparsely
        # renumbered mesh, not just composed models).
        from .readers._tag_translation import ElementTagTranslator
        _tag_map = ElementTagTranslator.from_model(bound_model)
        if not _tag_map.is_empty:
            reader.attach_tag_map(_tag_map)
        model_path: "Optional[Path]" = Path(model_h5)
    else:
        # Self-sufficient path — minimal broker from the file itself.
        bound_model = resolve_bound_model(reader, None)
        model_path = None
    assert bound_model is not None
    return cls(
        reader, fem=bound_fem, path=anchor, model=bound_model,
        model_path=model_path,
    )._with_autoloaded_definitions()

from_fem classmethod

from_fem(fem: 'FEMData', path: 'str | Path | list[str | Path]', *, kind: str = 'auto', merge_partitions: bool = True, cache_root: 'str | Path | None' = None) -> 'Results'

Open a results file against a bare :class:FEMData snapshot.

The one-call route to :class:Results / :meth:viewer for a model that did not go through the apeSees bridge — e.g. a physical-group model where fem = g.mesh.queries.get_fem_data() drove a hand-written OpenSees deck. Without this the only routes were the bridge-bound constructors (which need a bridge-emitted model.h5 / OpenSeesModel) or the self-describing :meth:from_ladruno.

from_fem materialises a neutral-only model.h5 from fem (cached, keyed by fem.snapshot_id) and binds it to the reader for path. Materialising a file — rather than an in-memory model — is deliberate: it sets model_path so the non-blocking / web viewers work (they forward --model-h5), not just data access.

Parameters

fem The bound snapshot (from g.mesh.queries.get_fem_data() or FEMData.from_h5). A composed fem is refused — see below. path The results file (or a partition list). kind "mpco" / "ladruno" / "native", or "auto" (default) to detect from the suffix (.mpco / .ladruno; a native .h5 must pass kind="native"). merge_partitions Forwarded to :meth:from_mpco / :meth:from_ladruno for .part-N auto-discovery. cache_root Where the materialised model.h5 is written — under <cache_root>/from_fem/ (default <cwd>/results/from_fem/ or $APEGMSH_RESULTS_DIR).

Raises

ValueError When fem is composed (g.compose / from_h5 assembly). Element / Gauss results are relabelled through the fem_eid ↔ ops-tag map that only a real bridge run records; a neutral-only model.h5 carries none, so a composed model would silently mislabel every element result. Build the model through apeSees and pass its model.h5 (e.g. apeSees(fem).h5(...) / g.savefrom_mpco(path, model_h5=...)).

Notes

A bare fem carries no envelope ndf (MeshInfo has none), so the cached model's ndf is 0. This is harmless for reading results and for the viewer; it only matters for deck re-emit (model.build(...)), which is not this path's purpose.

Source code in src/apeGmsh/results/Results.py
@classmethod
def from_fem(
    cls,
    fem: "FEMData",
    path: "str | Path | list[str | Path]",
    *,
    kind: str = "auto",
    merge_partitions: bool = True,
    cache_root: "str | Path | None" = None,
) -> "Results":
    """Open a results file against a bare :class:`FEMData` snapshot.

    The one-call route to :class:`Results` / :meth:`viewer` for a
    model that did **not** go through the ``apeSees`` bridge — e.g. a
    physical-group model where ``fem = g.mesh.queries.get_fem_data()``
    drove a hand-written OpenSees deck.  Without this the only routes
    were the bridge-bound constructors (which need a bridge-emitted
    ``model.h5`` / ``OpenSeesModel``) or the self-describing
    :meth:`from_ladruno`.

    ``from_fem`` materialises a neutral-only ``model.h5`` from ``fem``
    (cached, keyed by ``fem.snapshot_id``) and binds it to the reader
    for ``path``.  Materialising a file — rather than an in-memory
    model — is deliberate: it sets ``model_path`` so the non-blocking
    / web viewers work (they forward ``--model-h5``), not just data
    access.

    Parameters
    ----------
    fem
        The bound snapshot (from ``g.mesh.queries.get_fem_data()`` or
        ``FEMData.from_h5``).  A **composed** fem is refused — see
        below.
    path
        The results file (or a partition list).
    kind
        ``"mpco"`` / ``"ladruno"`` / ``"native"``, or ``"auto"``
        (default) to detect from the suffix (``.mpco`` / ``.ladruno``;
        a native ``.h5`` must pass ``kind="native"``).
    merge_partitions
        Forwarded to :meth:`from_mpco` / :meth:`from_ladruno` for
        ``.part-N`` auto-discovery.
    cache_root
        Where the materialised ``model.h5`` is written — under
        ``<cache_root>/from_fem/`` (default ``<cwd>/results/from_fem/``
        or ``$APEGMSH_RESULTS_DIR``).

    Raises
    ------
    ValueError
        When ``fem`` is **composed** (``g.compose`` / ``from_h5``
        assembly).  Element / Gauss results are relabelled through the
        ``fem_eid ↔ ops-tag`` map that only a real bridge run records;
        a neutral-only ``model.h5`` carries none, so a composed model
        would silently mislabel every element result.  Build the model
        through ``apeSees`` and pass its ``model.h5`` (e.g.
        ``apeSees(fem).h5(...)`` / ``g.save`` →
        ``from_mpco(path, model_h5=...)``).

    Notes
    -----
    A bare fem carries no envelope ``ndf`` (``MeshInfo`` has none), so
    the cached model's ``ndf`` is ``0``.  This is harmless for reading
    results and for the viewer; it only matters for deck **re-emit**
    (``model.build(...)``), which is not this path's purpose.
    """
    # ADR 0043 slice 1.3 — element results relabel through the
    # bridge-emitted element_meta.  A neutral-only model.h5 carries
    # none, but ``composed_from`` round-trips, so from_mpco /
    # from_ladruno would see a "composed" model, attach an
    # element-less (empty) tag translator, and silently mislabel
    # every element/gauss/fiber result.  Refuse loudly.
    if len(getattr(fem, "composed_from", ()) or ()) > 0:
        raise ValueError(
            "Results.from_fem: the FEMData is composed (g.compose / "
            "from_h5). Element/Gauss results need the bridge's "
            "fem_eid<->ops-tag map, which a neutral-only model.h5 "
            "cannot provide. Build the model through apeSees and pass "
            "its model.h5 — e.g. Results.from_mpco(path, "
            "model_h5='model.h5')."
        )

    resolved_kind = _resolve_results_kind(kind, path)

    # Materialise a neutral-only model.h5, cached by content hash.
    from .writers._cache import resolve_cache_root

    cache_dir = resolve_cache_root(cache_root) / "from_fem"
    cache_dir.mkdir(parents=True, exist_ok=True)
    snap = str(getattr(fem, "snapshot_id", "") or "model")
    cached = cache_dir / f"{snap}.model.h5"
    if not cached.exists():
        fem.to_h5(str(cached))

    if resolved_kind == "mpco":
        return cls.from_mpco(
            path, fem=fem, model_h5=cached,
            merge_partitions=merge_partitions,
        )
    if resolved_kind == "ladruno":
        return cls.from_ladruno(
            path, fem=fem, model_h5=cached,
            merge_partitions=merge_partitions,
        )
    # native — pass the rehydrated neutral-only model + its path so
    # the subprocess viewer can forward --model-h5.
    from ..opensees.opensees_model import OpenSeesModel

    return cls.from_native(
        path, fem=fem, model=OpenSeesModel.from_h5(cached),
        model_path=cached,
    )

energy

energy(*, region: 'Optional[int]' = None, stage: Optional[str] = None) -> 'Any'

Energy-balance time history — Ladruno-recorder feature.

Returns a :class:pandas.DataFrame of the closure components KE / IE / DW / ULW / RES / ERR indexed by simulation time, written by the recorder's -G energy verb.

  • region=None → whole-domain balance (ON_DOMAIN).
  • region=<tag> → the per-region balance (ON_REGIONS) for the OpenSees region tag.

ERR (the normalized energy-balance error %) is the headline solution-quality diagnostic for explicit runs. Raises :class:TypeError on a non-Ladruno results object (MPCO / native carry no energy balance) and ValueError if energy was not recorded / the region is unknown.

results.plot.energy(...) renders this as a matplotlib time-history figure.

Source code in src/apeGmsh/results/Results.py
def energy(
    self,
    *,
    region: "Optional[int]" = None,
    stage: Optional[str] = None,
) -> "Any":
    """Energy-balance time history — **Ladruno-recorder feature**.

    Returns a :class:`pandas.DataFrame` of the closure components
    ``KE`` / ``IE`` / ``DW`` / ``ULW`` / ``RES`` / ``ERR`` indexed by
    simulation time, written by the recorder's ``-G energy`` verb.

    * ``region=None`` → whole-domain balance (``ON_DOMAIN``).
    * ``region=<tag>`` → the per-region balance (``ON_REGIONS``) for
      the OpenSees region tag.

    ``ERR`` (the normalized energy-balance error %) is the headline
    solution-quality diagnostic for explicit runs. Raises
    :class:`TypeError` on a non-Ladruno results object (MPCO / native
    carry no energy balance) and ``ValueError`` if energy was not
    recorded / the region is unknown.

    ``results.plot.energy(...)`` renders this as a matplotlib
    time-history figure.
    """
    read_energy = getattr(self._reader, "read_energy", None)
    if read_energy is None:
        raise TypeError(
            "Results.energy() is a Ladruno-recorder feature. Open a "
            ".ladruno via Results.from_ladruno(...) recorded with the "
            "'-G energy' verb; MPCO / native results carry no energy "
            "balance."
        )
    sid = self._resolve_stage(stage)
    cols, values, time = read_energy(sid, region=region)
    import pandas as pd
    return pd.DataFrame(
        values, columns=cols, index=pd.Index(time, name="time"),
    )

energy_regions

energy_regions(*, stage: Optional[str] = None) -> 'list[int]'

OpenSees region tags with a recorded per-region energy balance.

The bridge auto-allocates an integer region tag when a Ladruno recorder is given energy_pg= (or a value filter + energy); that tag is opaque to the author. This lists the tags actually present in ON_REGIONS/energyBalance so you can pick one to pass to :meth:energy — e.g. r.energy(region=r.energy_regions()[0]).

Returns [] when only the whole-model balance was recorded (read it with energy(), no region=). Ladruno-recorder feature; raises :class:TypeError on MPCO / native results.

Source code in src/apeGmsh/results/Results.py
def energy_regions(self, *, stage: Optional[str] = None) -> "list[int]":
    """OpenSees region tags with a recorded per-region energy balance.

    The bridge auto-allocates an integer region tag when a Ladruno
    recorder is given ``energy_pg=`` (or a value filter + ``energy``);
    that tag is opaque to the author. This lists the tags actually
    present in ``ON_REGIONS/energyBalance`` so you can pick one to pass
    to :meth:`energy` — e.g. ``r.energy(region=r.energy_regions()[0])``.

    Returns ``[]`` when only the whole-model balance was recorded
    (read it with ``energy()``, no ``region=``). Ladruno-recorder
    feature; raises :class:`TypeError` on MPCO / native results.
    """
    available = getattr(self._reader, "available_energy_regions", None)
    if available is None:
        raise TypeError(
            "Results.energy_regions() is a Ladruno-recorder feature. "
            "Open a .ladruno via Results.from_ladruno(...); MPCO / "
            "native results carry no energy balance."
        )
    sid = self._resolve_stage(stage)
    return available(sid)

node_envelope

node_envelope(component: str, *, stage: Optional[str] = None) -> 'Any'

Per-node time-reduced extremes — Ladruno -envelope feature.

When a .ladruno is recorded with the recorder's -envelope flag, each node channel stores componentwise running extremes (MIN/MAX/ABSMAX and the step at which the abs-extreme occurred) instead of a time series — the cheap way to capture peak response over a long run without keeping every step.

Returns a :class:pandas.DataFrame indexed by node id with columns min / max / absmax / arg_step for component (a neutral name like "displacement_x"). Raises :class:TypeError on a non-Ladruno results object, and :class:ValueError if the file was not recorded with -envelope or the component is absent.

results.plot.node_envelope(...) paints a chosen measure on the mesh as a matplotlib figure.

Source code in src/apeGmsh/results/Results.py
def node_envelope(
    self,
    component: str,
    *,
    stage: Optional[str] = None,
) -> "Any":
    """Per-node time-reduced extremes — **Ladruno ``-envelope`` feature**.

    When a ``.ladruno`` is recorded with the recorder's ``-envelope``
    flag, each node channel stores componentwise running extremes
    (``MIN``/``MAX``/``ABSMAX`` and the step at which the abs-extreme
    occurred) *instead of* a time series — the cheap way to capture peak
    response over a long run without keeping every step.

    Returns a :class:`pandas.DataFrame` indexed by node id with columns
    ``min`` / ``max`` / ``absmax`` / ``arg_step`` for ``component`` (a
    neutral name like ``"displacement_x"``). Raises :class:`TypeError`
    on a non-Ladruno results object, and :class:`ValueError` if the file
    was not recorded with ``-envelope`` or the component is absent.

    ``results.plot.node_envelope(...)`` paints a chosen measure on
    the mesh as a matplotlib figure.
    """
    read_node_envelope = getattr(self._reader, "read_node_envelope", None)
    if read_node_envelope is None:
        raise TypeError(
            "Results.node_envelope() is a Ladruno-recorder feature. Open "
            "a single-file .ladruno via Results.from_ladruno(...) recorded "
            "with the '-envelope' flag. (MPCO / native results and "
            "partitioned .ladruno envelope merges are not supported.)"
        )
    sid = self._resolve_stage(stage)
    env = read_node_envelope(sid, component)
    import pandas as pd
    return pd.DataFrame(
        {
            "min": env.min,
            "max": env.max,
            "absmax": env.absmax,
            "arg_step": env.arg_step,
        },
        index=pd.Index(env.node_ids, name="node_id"),
    )

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

demo classmethod

demo(**kwargs) -> 'Results'

Return a ready-to-view demo :class:Results (cantilever pushover).

Zero-setup sample data so Results.demo().show_web() (or .viewer()) renders without supplying an .mpco / model.h5 pair — handy for docs, smoke tests, and trying the viewer. A real apeSees-emitted model with a synthetic, ramped cantilever deflection (no OpenSees solve). See :func:apeGmsh.results.make_demo_results for the keyword options (length / n_elements / n_steps / tip_drift / path).

Source code in src/apeGmsh/results/Results.py
@classmethod
def demo(cls, **kwargs) -> "Results":
    """Return a ready-to-view demo :class:`Results` (cantilever pushover).

    Zero-setup sample data so ``Results.demo().show_web()`` (or
    ``.viewer()``) renders without supplying an ``.mpco`` /
    ``model.h5`` pair — handy for docs, smoke tests, and trying the
    viewer. A real ``apeSees``-emitted model with a synthetic, ramped
    cantilever deflection (no OpenSees solve). See
    :func:`apeGmsh.results.make_demo_results` for the keyword options
    (``length`` / ``n_elements`` / ``n_steps`` / ``tip_drift`` /
    ``path``).
    """
    from .demo import make_demo_results
    return make_demo_results(**kwargs)

assess

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

Compile a v1 :class:~apeGmsh.assess.AssessmentReport.

figures=True calls :meth:render_pack. Default is False.

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

    ``figures=True`` calls :meth:`render_pack`. Default is ``False``.
    """
    from apeGmsh.assess import assess_results
    return assess_results(self, figures=figures, out_dir=out_dir)

session

session()

The presentation session for these results (ADR 0098 §1).

Presentation with no window: a ResultsSession (from apeGmsh.results.session) bound to this broker, booted with the default picture — ONE empty mesh view (grey analysis mesh, no slots, no legends). Configure it (slots, deform, time), then s.render("a.png") for a still; the Qt client (s.show()) arrives at S2 and viewer() flips onto it at S6.

Persisted section cuts boot as view clips (ADR 0098 S6b). The retired section_cut diagram kind took its auto-load contract with it, but not the contract itself: cuts persisted under /opensees/cuts/ come back on the booted view as clips. Only the ones that translate honestly do — a cut that named a strict subset of the model's elements, or that carries a bounding polygon, cuts LESS than a view clip does, so it is skipped with one [session] line rather than silently widening what disappears from the screen. Reading the cuts can never fail this call: a bad zone is a line, not a traceback.

Source code in src/apeGmsh/results/Results.py
def session(self):
    """The presentation session for these results (ADR 0098 §1).

    Presentation with no window: a ``ResultsSession`` (from
    ``apeGmsh.results.session``) bound to this broker, booted with
    the default picture — ONE empty mesh view (grey analysis mesh,
    no slots, no legends). Configure it (slots, deform, time), then
    ``s.render("a.png")`` for a still; the Qt client (``s.show()``)
    arrives at S2 and ``viewer()`` flips onto it at S6.

    **Persisted section cuts boot as view clips** (ADR 0098 S6b).
    The retired ``section_cut`` diagram kind took its auto-load
    contract with it, but not the contract itself: cuts persisted
    under ``/opensees/cuts/`` come back on the booted view as
    clips. Only the ones that translate honestly do — a cut that
    named a strict subset of the model's elements, or that carries
    a bounding polygon, cuts LESS than a view clip does, so it is
    skipped with one ``[session]`` line rather than silently
    widening what disappears from the screen. Reading the cuts can
    never fail this call: a bad zone is a line, not a traceback.
    """
    from .session import ResultsSession
    from .session._cuts import attach_persisted_cuts

    s = ResultsSession(results=self)
    view = s.add_view()
    try:
        notices = attach_persisted_cuts(self, view)
    except Exception as exc:  # noqa: BLE001 - the session always boots
        notices = (
            f"persisted section cuts could not be loaded as view "
            f"clips: {type(exc).__name__}: {exc}",
        )
    for notice in notices:
        print(f"[session] {notice}")
    return s

viewer

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

Open the post-solve results window on a ResultsSession.

Sugar for :meth:session + show() (ADR 0098 §1, flipped at S6a). The one-liner is unchanged; what it opens is not. A :class:~apeGmsh.results.session.ResultsSession is the document — tiled mesh and plot panes, each with the closed §4 slot catalog — and the window is a client that projects it. The retired Geometry / Composition / Diagram window is gone from this door. Everything the window does, a script can do to the same object::

s = results.session()   # the document, no window
s.render("a.png")       # a still, no Qt
results.viewer()        # the human one-liner
Parameters

blocking None (default) — auto: True in scripts and the plain CLI, False inside a Jupyter / IPython ZMQ kernel, where the blocking Qt loop would freeze (often kill) the kernel. An in-memory Results in a notebook cannot spawn a subprocess and falls back to :meth:show_web. Either notebook path announces itself with one line. True — open the window in-process and block the calling thread until it 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 What to do with a session snapshot saved beside the results file. True restores silently, False ignores it, "prompt" (default) asks. No effect for in-memory Results, which have no file to sit beside. save_session If True (default), the session — panes, slots, pose, time link, selection — is written to <results>.viewer-session.json when the window closes. False disables auto-save. Auto-save also disarms itself for a window that could not read an existing file there, so the unreadable file survives (INV-SESSION-OPEN, see apeGmsh.results.session._boot).

Returns

ResultsSession The session the window projected, after the window closes (blocking). Still live: query it, render stills off it, snapshot it. subprocess.Popen The spawned process handle (non-blocking). Deliberately not unified with the blocking return — a session in this process is not what the child window is showing. WebViewer The :meth:show_web handle (auto mode, in-memory Results in a notebook). The web client is a later client of the same session; until it lands this hatch keeps today's path. 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.

Notes

The v13 <results>.viewer-session.json written by the retired window is not restorable (ADR 0098 Consequences). The first flipped open says so in one line and renames it aside to .legacy — never overwriting it, and never overwriting an aside that is already there.

cuts= is retired with the diagram ontology (§1): a cut plane is clip state on a view. Build cuts with :mod:apeGmsh.cuts and add them as clips — results.session() then view.add_clip.

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

    Sugar for :meth:`session` + ``show()`` (ADR 0098 §1, flipped at
    S6a). The one-liner is unchanged; what it opens is not. A
    :class:`~apeGmsh.results.session.ResultsSession` is the
    document — tiled mesh and plot panes, each with the closed §4
    slot catalog — and the window is a client that projects it. The
    retired Geometry / Composition / Diagram window is gone from
    this door. Everything the window does, a script can do to the
    same object::

        s = results.session()   # the document, no window
        s.render("a.png")       # a still, no Qt
        results.viewer()        # the human one-liner

    Parameters
    ----------
    blocking
        ``None`` (default) — auto: ``True`` in scripts and the
        plain CLI, ``False`` inside a Jupyter / IPython ZMQ kernel,
        where the blocking Qt loop would freeze (often kill) the
        kernel. An in-memory Results in a notebook cannot spawn a
        subprocess and falls back to :meth:`show_web`. Either
        notebook path announces itself with one line.
        ``True`` — open the window in-process and block the calling
        thread until it 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
        What to do with a session snapshot saved beside the results
        file. ``True`` restores silently, ``False`` ignores it,
        ``"prompt"`` (default) asks. No effect for in-memory
        Results, which have no file to sit beside.
    save_session
        If ``True`` (default), the session — panes, slots, pose,
        time link, selection — is written to
        ``<results>.viewer-session.json`` when the window closes.
        ``False`` disables auto-save. Auto-save also disarms itself
        for a window that could not read an existing file there, so
        the unreadable file survives (INV-SESSION-OPEN, see
        ``apeGmsh.results.session._boot``).

    Returns
    -------
    ResultsSession
        The session the window projected, **after** the window
        closes (blocking). Still live: query it, render stills off
        it, snapshot it.
    subprocess.Popen
        The spawned process handle (non-blocking). Deliberately not
        unified with the blocking return — a session in this
        process is not what the child window is showing.
    WebViewer
        The :meth:`show_web` handle (auto mode, in-memory Results
        in a notebook). The web client is a later client of the
        same session; until it lands this hatch keeps today's path.
    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.

    Notes
    -----
    The v13 ``<results>.viewer-session.json`` written by the retired
    window is not restorable (ADR 0098 Consequences). The first
    flipped open says so in one line and renames it aside to
    ``.legacy`` — never overwriting it, and never overwriting an
    aside that is already there.

    ``cuts=`` is retired with the diagram ontology (§1): a cut plane
    is clip state on a view. Build cuts with :mod:`apeGmsh.cuts` and
    add them as clips — ``results.session()`` then ``view.add_clip``.
    """
    import os
    if os.environ.get("APEGMSH_SKIP_VIEWER"):
        print("[skip viewer] APEGMSH_SKIP_VIEWER set")
        return None
    if blocking is None:
        if not _in_notebook_kernel():
            blocking = True
        elif self._path is None:
            print(
                "[viewer] notebook kernel detected and this Results "
                "is in-memory — opening the web viewer instead of "
                "blocking the kernel (pass blocking=True to force "
                "the Qt window)."
            )
            return self.show_web()
        else:
            print(
                "[viewer] notebook kernel detected — spawning the "
                "viewer as a separate process so the kernel keeps "
                "running (pass blocking=True to open it in-process)."
            )
            blocking = False
    if not blocking:
        handle = self._spawn_viewer_subprocess(
            title=title,
            restore_session=restore_session,
            save_session=save_session,
        )
        # 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
    return self._show_session_window(
        title=title,
        restore_session=restore_session,
        save_session=save_session,
    )

render

render(path: 'str | Path', *, view: str = 'contour', component: Optional[str] = None, step: int = -1, deform: 'Optional[Any]' = None, camera: 'Optional[str]' = None, window_size: tuple[int, int] = (1280, 720)) -> 'Optional[Path]'

Write one offscreen still (ADR 0094 S1).

VTK offscreen from the viewer scene / diagram pipeline — no Qt window, no event loop, no setup(plotter, director). view is a closed set: mesh / contour / deformed / reactions.

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

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

Source code in src/apeGmsh/results/Results.py
def render(
    self,
    path: "str | Path",
    *,
    view: str = "contour",
    component: Optional[str] = None,
    step: int = -1,
    deform: "Optional[Any]" = None,
    camera: "Optional[str]" = None,
    window_size: tuple[int, int] = (1280, 720),
) -> "Optional[Path]":
    """Write one offscreen still (ADR 0094 S1).

    VTK offscreen from the viewer scene / diagram pipeline — no
    Qt window, no event loop, no ``setup(plotter, director)``.
    ``view`` is a closed set: ``mesh`` / ``contour`` / ``deformed``
    / ``reactions``.

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

    Returns the written :class:`~pathlib.Path`, or ``None`` (and
    prints the ``[skip viewer]`` notice) under
    ``APEGMSH_SKIP_VIEWER=1`` or with no GL.
    """
    from apeGmsh.viewers.render import render_results
    return render_results(
        self, path,
        view=view, component=component, step=step,
        deform=deform, camera=camera, window_size=window_size,
    )

render_pack

render_pack(out_dir: 'str | Path', *, camera: 'Optional[str]' = None, window_size: tuple[int, int] = (1280, 720)) -> tuple[Path, ...]

Write the canned report pack (ADR 0094 S3).

Returns the tuple of written paths, or () under APEGMSH_SKIP_VIEWER=1 / no GL (and prints the [skip viewer] notice). Closed view= set only; no setup(). There is no fem.render_pack.

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

Source code in src/apeGmsh/results/Results.py
def render_pack(
    self,
    out_dir: "str | Path",
    *,
    camera: "Optional[str]" = None,
    window_size: tuple[int, int] = (1280, 720),
) -> tuple[Path, ...]:
    """Write the canned report pack (ADR 0094 S3).

    Returns the tuple of written paths, or ``()`` under
    ``APEGMSH_SKIP_VIEWER=1`` / no GL (and prints the
    ``[skip viewer]`` notice). Closed ``view=`` set only; no
    ``setup()``. There is no ``fem.render_pack``.

    ``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_pack as _render_pack
    return _render_pack(
        self, out_dir, camera=camera, window_size=window_size,
    )

export_animation

export_animation(path: 'str | Any', *, fps: int = 30, step_stride: int = 1, stage: 'Optional[str]' = None, deform: 'Optional[Any]' = None, camera: 'Optional[Any]' = None, window_size: 'Optional[tuple[int, int]]' = (1280, 720), setup: 'Optional[Any]' = None)

Render the time history to a video / GIF without a GUI session.

Builds the full results viewer off-screen (so deformation, contours, camera, and theming are pixel-identical to the interactive viewer), walks every step capturing a frame, and encodes to the format chosen by path's suffix — .mp4 (H.264, needs the apegmsh[animation] extra) or .gif (Pillow, no extra). The viewer window is shown briefly while rendering (the OpenGL context requires a realized surface) but no blocking event loop is entered.

Parameters

path Output file. Suffix selects the format (.mp4 / .gif). fps Frames per second of the output. step_stride Capture every N-th step (plus always the last). Useful to keep long histories short. stage Stage id/name to animate. Defaults to the active stage. deform Deformed-shape scaling. A number applies that scale to the "displacement" field; a (field, scale) pair selects another field. None (default) renders the undeformed mesh. camera Optional value assigned to plotter.camera_position (e.g. "iso", "xy", or an explicit position triple) before rendering. None keeps the auto-framed camera. window_size (width, height) of the rendered frames. None keeps the viewer's default size. setup Optional callback(plotter, director) invoked after the scene is built and before capture — the escape hatch for adding contours / section cuts / custom camera work via the same APIs the interactive viewer uses.

Returns

pathlib.Path The resolved output path, or None when APEGMSH_SKIP_VIEWER is set in the environment.

Source code in src/apeGmsh/results/Results.py
def export_animation(
    self,
    path: "str | Any",
    *,
    fps: int = 30,
    step_stride: int = 1,
    stage: "Optional[str]" = None,
    deform: "Optional[Any]" = None,
    camera: "Optional[Any]" = None,
    window_size: "Optional[tuple[int, int]]" = (1280, 720),
    setup: "Optional[Any]" = None,
):
    """Render the time history to a video / GIF without a GUI session.

    Builds the full results viewer off-screen (so deformation,
    contours, camera, and theming are pixel-identical to the
    interactive viewer), walks every step capturing a frame, and
    encodes to the format chosen by ``path``'s suffix — ``.mp4``
    (H.264, needs the ``apegmsh[animation]`` extra) or ``.gif``
    (Pillow, no extra). The viewer window is shown briefly while
    rendering (the OpenGL context requires a realized surface) but
    no blocking event loop is entered.

    Parameters
    ----------
    path
        Output file. Suffix selects the format (``.mp4`` / ``.gif``).
    fps
        Frames per second of the output.
    step_stride
        Capture every N-th step (plus always the last). Useful to
        keep long histories short.
    stage
        Stage id/name to animate. Defaults to the active stage.
    deform
        Deformed-shape scaling. A number applies that scale to the
        ``"displacement"`` field; a ``(field, scale)`` pair selects
        another field. ``None`` (default) renders the undeformed
        mesh.
    camera
        Optional value assigned to ``plotter.camera_position`` (e.g.
        ``"iso"``, ``"xy"``, or an explicit position triple) before
        rendering. ``None`` keeps the auto-framed camera.
    window_size
        ``(width, height)`` of the rendered frames. ``None`` keeps
        the viewer's default size.
    setup
        Optional ``callback(plotter, director)`` invoked after the
        scene is built and before capture — the escape hatch for
        adding contours / section cuts / custom camera work via the
        same APIs the interactive viewer uses.

    Returns
    -------
    pathlib.Path
        The resolved output path, or ``None`` when
        ``APEGMSH_SKIP_VIEWER`` is set in the environment.
    """
    import os
    if os.environ.get("APEGMSH_SKIP_VIEWER"):
        print("[skip viewer] APEGMSH_SKIP_VIEWER set")
        return None
    from ..viewers.results_viewer import ResultsViewer

    viewer = ResultsViewer(
        self, restore_session=False, save_session=False,
    )
    # Borrow this live Results — don't close its HDF5 handle on
    # teardown (the caller keeps using it). Set BEFORE show() so a
    # build failure that triggers teardown still leaves it open.
    viewer._own_results_close = False  # noqa: SLF001
    try:
        # show() is inside the try so a failed off-screen realize
        # (GL / pixel-format error) still hits ``viewer.close()`` —
        # otherwise a half-built, possibly-visible window leaks.
        viewer.show(run_loop=False, window_size=window_size)
        director = viewer.director
        plotter = viewer.plotter
        if stage is not None:
            director.set_stage(stage)
        if deform is not None:
            if isinstance(deform, (tuple, list)):
                d_field, d_scale = deform[0], float(deform[1])
            else:
                d_field, d_scale = "displacement", float(deform)
            geoms = director.geometries
            active = geoms.active or (
                geoms.geometries[0] if geoms.geometries else None
            )
            if active is not None:
                geoms.set_deformation(
                    active.id, enabled=True,
                    field=d_field, scale=d_scale,
                )
        if camera is not None:
            try:
                plotter.camera_position = camera
            except Exception:
                pass
        if setup is not None:
            setup(plotter, director)
        return viewer.export_animation(
            path, fps=fps, step_stride=step_stride,
        )
    finally:
        viewer.close()

show_web

show_web(*, stage: 'Optional[str]' = None, show: bool = True, controls: bool = True, render_mode: str = 'client')

Open the view-only web / Jupyter results viewer (ADR 0042 R-C).

Renders the FEM substrate plus any diagrams the director holds through a pyvista.trame backend — the kernel-safe path that replaces the blocking Qt :meth:viewer in a notebook. View-only (picking is deferred to R-D), but with a step slider + per-layer visibility checkboxes when ipywidgets is available.

No results file handy? Results.demo().show_web() renders a zero-setup cantilever-pushover sample.

Parameters

stage Stage id or name to activate; defaults to the first stage. show When True (default), display inline immediately. When False, return the :class:~apeGmsh.viewers.web_viewer.WebViewer unshown so diagrams can be added via viewer.director first. controls When True (default), stack an ipywidgets control panel (step slider + layer toggles) above the view. Degrades to a bare view if ipywidgets is absent. render_mode "client" (default) renders in the browser via WebGL — fast camera interaction. "server" renders on the kernel and streams images (laggy, most VTK-feature-complete; for very large models). "hybrid" is pyvista's trame backend with a local/remote toggle in the toolbar.

Returns

WebViewer The viewer handle (.director / .set_step / .show).

Source code in src/apeGmsh/results/Results.py
def show_web(
    self,
    *,
    stage: "Optional[str]" = None,
    show: bool = True,
    controls: bool = True,
    render_mode: str = "client",
):
    """Open the view-only web / Jupyter results viewer (ADR 0042 R-C).

    Renders the FEM substrate plus any diagrams the director holds
    through a ``pyvista.trame`` backend — the kernel-safe path that
    replaces the blocking Qt :meth:`viewer` in a notebook. View-only
    (picking is deferred to R-D), but with a step slider + per-layer
    visibility checkboxes when ``ipywidgets`` is available.

    No results file handy? ``Results.demo().show_web()`` renders a
    zero-setup cantilever-pushover sample.

    Parameters
    ----------
    stage
        Stage id or name to activate; defaults to the first stage.
    show
        When ``True`` (default), display inline immediately. When
        ``False``, return the :class:`~apeGmsh.viewers.web_viewer.WebViewer`
        unshown so diagrams can be added via ``viewer.director`` first.
    controls
        When ``True`` (default), stack an ``ipywidgets`` control panel
        (step slider + layer toggles) above the view. Degrades to a
        bare view if ``ipywidgets`` is absent.
    render_mode
        ``"client"`` (default) renders in the browser via WebGL — fast
        camera interaction. ``"server"`` renders on the kernel and
        streams images (laggy, most VTK-feature-complete; for very
        large models). ``"hybrid"`` is pyvista's ``trame`` backend with
        a local/remote toggle in the toolbar.

    Returns
    -------
    WebViewer
        The viewer handle (``.director`` / ``.set_step`` / ``.show``).
    """
    from ..viewers.web_viewer import show_web as _show_web
    return _show_web(
        self, stage=stage, show=show, controls=controls,
        render_mode=render_mode,
    )

serve_web

serve_web(*, stage: 'Optional[str]' = None, render_mode: str = 'client', port: 'Optional[int]' = None, open_browser: bool = True, title: str = 'apeGmsh', **start_kwargs)

Serve the results as a standalone trame web app (ADR 0042 R-C).

The non-Jupyter counterpart of :meth:show_web: builds a vuetify3 single-page app (the FEM view plus a step slider and per-layer switches) and serves it at a local URL, opening a browser tab and blocking until stopped (Ctrl-C). In a notebook use :meth:show_web instead.

Parameters

stage Stage id or name to activate; defaults to the first stage. render_mode "client" (default), "server", or "hybrid" — see :meth:show_web. port Port to serve on; None lets trame pick one. open_browser Open a browser tab at the served URL. title App title shown in the toolbar. **start_kwargs Passed through to the trame server.start (e.g. exec_mode).

Returns

WebViewer The viewer handle.

Source code in src/apeGmsh/results/Results.py
def serve_web(
    self,
    *,
    stage: "Optional[str]" = None,
    render_mode: str = "client",
    port: "Optional[int]" = None,
    open_browser: bool = True,
    title: str = "apeGmsh",
    **start_kwargs,
):
    """Serve the results as a standalone trame web app (ADR 0042 R-C).

    The non-Jupyter counterpart of :meth:`show_web`: builds a vuetify3
    single-page app (the FEM view plus a step slider and per-layer
    switches) and serves it at a local URL, opening a browser tab and
    blocking until stopped (Ctrl-C). In a notebook use :meth:`show_web`
    instead.

    Parameters
    ----------
    stage
        Stage id or name to activate; defaults to the first stage.
    render_mode
        ``"client"`` (default), ``"server"``, or ``"hybrid"`` — see
        :meth:`show_web`.
    port
        Port to serve on; ``None`` lets trame pick one.
    open_browser
        Open a browser tab at the served URL.
    title
        App title shown in the toolbar.
    **start_kwargs
        Passed through to the trame ``server.start`` (e.g.
        ``exec_mode``).

    Returns
    -------
    WebViewer
        The viewer handle.
    """
    from ..viewers.web_viewer import serve_web as _serve_web
    return _serve_web(
        self, stage=stage, render_mode=render_mode, port=port,
        open_browser=open_browser, title=title, **start_kwargs,
    )

save_definitions

save_definitions(path: 'Optional[str | Path]' = None) -> Path

Persist this Results' custom scalar definitions to a JSON sidecar (default <results>.defs.json).

Reloaded automatically by :meth:from_native / :meth:from_mpco / :meth:from_ladruno, and carried to the subprocess viewer. Raises for an in-memory Results with no path= given.

Source code in src/apeGmsh/results/Results.py
def save_definitions(self, path: "Optional[str | Path]" = None) -> Path:
    """Persist this Results' custom scalar definitions to a JSON
    sidecar (default ``<results>.defs.json``).

    Reloaded automatically by :meth:`from_native` / :meth:`from_mpco`
    / :meth:`from_ladruno`, and carried to the subprocess viewer.
    Raises for an in-memory Results with no ``path=`` given.
    """
    import json
    if path is None:
        if self._path is None:
            raise RuntimeError(
                "in-memory Results has no default sidecar path; "
                "pass save_definitions(path=...)."
            )
        path = self._default_defs_path(self._path)
    path = Path(path)
    path.write_text(
        json.dumps(self._definitions_payload(), indent=2), encoding="utf-8",
    )
    return path

load_definitions

load_definitions(path: 'Optional[str | Path]' = None) -> int

Load and register custom scalar definitions from a JSON sidecar (default <results>.defs.json). Returns the count applied; a missing file is a no-op returning 0. Idempotent / best-effort — see :meth:_apply_definitions_payload.

Source code in src/apeGmsh/results/Results.py
def load_definitions(self, path: "Optional[str | Path]" = None) -> int:
    """Load and register custom scalar definitions from a JSON sidecar
    (default ``<results>.defs.json``). Returns the count applied; a
    missing file is a no-op returning 0. Idempotent / best-effort —
    see :meth:`_apply_definitions_payload`."""
    import json
    if path is None:
        if self._path is None:
            return 0
        path = self._default_defs_path(self._path)
    path = Path(path)
    if not path.exists():
        return 0
    payload = json.loads(path.read_text(encoding="utf-8"))
    return self._apply_definitions_payload(payload)

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,), station_natural_coord: (sum_F,) | None 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.

LocalAxes dataclass

LocalAxes(element_ids: ndarray, quaternions: ndarray)

Per-element local coordinate frames (beam / shell orientation).

Read from a .ladruno MODEL/LOCAL_AXES group — the orientation a .mpco does not carry for beam-columns (so apeGmsh can orient line / section-force diagrams straight from .ladruno for wired element classes, instead of the native vecxz path).

quaternions are scalar-first (w, x, y, z), mapping global → element-local at the reference configuration. The local axes are the rows of the rotation matrix (OpenSees quatFromMat stores the transpose convention), so matrices[k] has row 0 = local x, row 1 = local y, row 2 = local z — each expressed in global coordinates. Elements with no recorded frame get the identity quaternion.

matrices property

matrices: ndarray

(n, 3, 3) per-element rotations; rows are the local axes in global coordinates.

x_axis property

x_axis: ndarray

(n, 3) — each element's local x-axis (beam axis) in global coords.

y_axis property

y_axis: ndarray

(n, 3) — each element's local y-axis in global coords.

z_axis property

z_axis: ndarray

(n, 3) — each element's local z-axis in global coords.

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, local_axes_quaternion: Optional[ndarray] = None)

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, station_natural_coord: Optional[ndarray] = None)

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.

EigenMode dataclass

EigenMode(mode_index: int, eigenvalue: float, frequency_hz: float, period_s: float)

Lightweight snapshot of one eigenmode — no file handle.

Returned by :attr:apeGmsh.results.Results.eigen_modes. Mirrors the mode-only fields of :class:StageInfo but is detachable from the Results context, so it is safe to pickle, pass between processes, or return from a function whose Results was already closed.

For the mode shape (per-node displacement vectors), use the mode-scoped :class:apeGmsh.results.Results from results.modes and query via results.nodes.get(component="displacement_x", ...) — that path holds the file handle and reads the underlying arrays.

omega_rad_s property

omega_rad_s: float

Angular frequency omega = sqrt(eigenvalue) in rad/s.

Returns 0.0 for non-positive (rigid-body or numerical zero) eigenvalues — matches capture_modes' behaviour.

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

opensees_model

opensees_model() -> 'Optional[OpenSeesModel]'

Embedded :class:OpenSeesModel from the file's /opensees/ zone.

Phase 4 (ADR 0020) — the Composed-file pattern. Native readers auto-resolve from the file when the zone is present (silent, no warning); third-party file readers (MPCO) return None.

Returns

OpenSeesModel | None The rehydrated broker when the file carries the zone, None otherwise. INV-1 — Phase 4 callers must tolerate None; the value becomes required only in Phase 8.

Source code in src/apeGmsh/results/readers/_protocol.py
def opensees_model(self) -> "Optional[OpenSeesModel]":
    """Embedded :class:`OpenSeesModel` from the file's ``/opensees/`` zone.

    Phase 4 (ADR 0020) — the Composed-file pattern. Native readers
    auto-resolve from the file when the zone is present (silent, no
    warning); third-party file readers (MPCO) return ``None``.

    Returns
    -------
    OpenSeesModel | None
        The rehydrated broker when the file carries the zone,
        ``None`` otherwise. INV-1 — Phase 4 callers must tolerate
        ``None``; the value becomes required only in Phase 8.
    """
    ...

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, bridge: Any = None, tag_map: 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,
    bridge: Any = None,
    tag_map: Any = None,
) -> None:
    self._spec = spec
    self._path = Path(path)
    # ADR 0043 slice 1.3 follow-up — fem_eid↔ops-tag translator for a
    # composed model. ``None`` (uncomposed / no model) ⇒ no
    # translation. Set here when supplied (``from_h5``); otherwise
    # built from the bridge sidecar at ``__enter__``.
    self._tag_map = tag_map
    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
    # Phase 4 (ADR 0020) — when supplied, the live :class:`apeSees`
    # bridge writes a sidecar ``model.h5`` at __enter__ time whose
    # ``/opensees/`` zone is composed into the run h5 via
    # NativeWriter (Composed-file pattern). ``None`` keeps the
    # pre-Phase-4 single-zone (``/meta`` + ``/model/`` only)
    # output shape.
    self._bridge = bridge

    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
    # Phase 4 — sidecar model.h5 path the bridge wrote to, kept so
    # __exit__ can clean it up after NativeWriter copied the zone
    # into the run file.
    self._model_h5_sidecar: Optional[Path] = None

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
    # Phase 4 (ADR 0020) — once the Composed file's ``/opensees/``
    # zone has been written, the bridge-emitted sidecar model.h5
    # is redundant (the run file is self-contained). Remove it so
    # the user's directory stays clean. Tolerate missing-file: a
    # failed __enter__ before the bridge wrote the sidecar would
    # leave ``_model_h5_sidecar`` pointing at a non-existent path.
    if self._model_h5_sidecar is not None:
        try:
            self._model_h5_sidecar.unlink(missing_ok=True)
        except OSError:
            # Best-effort cleanup; never let it mask a real
            # exception from the analysis loop.
            pass
        self._model_h5_sidecar = 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.

Notes

Phase 4 (ADR 0020) — this entry point does not accept a bridge= kwarg because there is no live bridge in scope. For the Composed-file pattern with a file-based workflow, the caller can pre-build the bridge themselves and pass it via :meth:DomainCapture (the constructor) instead of from_h5.

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.

    Notes
    -----
    Phase 4 (ADR 0020) — this entry point does not accept a
    ``bridge=`` kwarg because there is no live bridge in scope.
    For the Composed-file pattern with a file-based workflow,
    the caller can pre-build the bridge themselves and pass it
    via :meth:`DomainCapture` (the constructor) instead of
    ``from_h5``.
    """
    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,
    )
    # ADR 0043 slice 1.3 follow-up — when the model.h5 is composed,
    # build the fem_eid↔ops-tag translator so capturers query the
    # live ops domain with the right tags. ``None`` for an uncomposed
    # model (translation would be a no-op).
    tag_map = _maybe_build_capture_tag_map(model_path)
    return cls(resolved, output, fem, ops=ops, tag_map=tag_map)

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()
    # Same for gauss captures: an element class with no
    # RESPONSE_CATALOG entry (e.g. LadrunoUP, whose effective
    # stress is not yet catalogued) is silently dropped from the
    # gauss group build — without this the record's dataset comes
    # out empty with zero user-facing signal.
    self._warn_about_skipped_gauss_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):
        if lam > 0:
            omega = math.sqrt(lam)
        else:
            # A non-positive eigenvalue is physically meaningful — a
            # rigid-body mode / mechanism or an unconverged eigensolve.
            # Don't silently bury it as a 0 Hz mode; warn so the user
            # treats the shape with suspicion (the stage is still
            # written, with frequency/period = 0).
            warnings.warn(
                f"Mode {mode_idx} has a non-positive eigenvalue "
                f"({lam:.6g}); this indicates a spurious/unstable mode "
                f"(rigid-body mechanism or unconverged eigensolve). "
                f"Writing frequency=0 / period=0 for it.",
                UserWarning,
                stacklevel=2,
            )
            omega = 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] = {}
        axes = ("x", "y", "z")
        n_trans = min(3, self._ndm)
        want_rotations = self._ndf >= 6

        # ONE call per node, then slice — never a call per (node,
        # DOF) index.
        #
        # Asking `nodeEigenvector(nid, mode, dof)` for dof 4-6 gated
        # on the model ENVELOPE ndf cannot read a mixed-DOF model at
        # all: in any solid + frame assembly the envelope is 6 while
        # the solid nodes carry 3, and OpenSees answers "dofTag? too
        # large" and raises. Every such model — the whole class this
        # capture exists for — failed at the first mode.
        #
        # A node's own vector is as long as ITS ndf, so slicing it is
        # correct per node with no envelope guesswork, and a node
        # with no rotational DOFs contributes 0.0 to a rotation
        # component that, for it, does not exist.
        trans = np.zeros((n_trans, node_ids.size), dtype=np.float64)
        rot = np.zeros((3, node_ids.size), dtype=np.float64)
        for col, nid in enumerate(node_ids):
            vec = ops.nodeEigenvector(int(nid), mode_idx)
            for axis_idx in range(min(n_trans, len(vec))):
                trans[axis_idx, col] = vec[axis_idx]
            if want_rotations and len(vec) >= 6:
                for axis_idx in range(3):
                    rot[axis_idx, col] = vec[3 + axis_idx]

        for axis_idx in range(n_trans):
            components[f"displacement_{axes[axis_idx]}"] = (
                trans[axis_idx][None, :]
            )
        if want_rotations:
            for axis_idx in range(3):
                components[f"rotation_{axes[axis_idx]}"] = (
                    rot[axis_idx][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 + SHELL_STRESS_RESULTANTS + SHELL_GENERALIZED_STRAINS + STRESS + STRAIN + PLASTIC_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 in _SHORTHAND_SHELL:
        # Not clipped either: the eight resultants are one fixed
        # response layout, and which subset a given shell class emits
        # is the catalog's answer, not ``ndm``'s.
        return _SHORTHAND_SHELL[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