Obtaining Results — Five Ways to Run the Analysis¶
apeGmsh keeps what to record separate from how to run OpenSees.
You declare the recorders once, resolve them against your FEMData,
and then pick an execution strategy that fits your workflow. All five
strategies produce files that the same Results reader can open.
This guide walks through each one with a complete, copy-pasteable example. They assume the same model setup; only the execution block differs.
Architecture note. Curious why this works? See Architecture — Obtaining the database for the spec-as-seam pattern and a strategy comparison table.
What can I record? For the full vocabulary — every category, every component, every shorthand, with examples — see the Recorder reference. The same information is available at runtime via
Recorders.categories(),Recorders.components_for(category), andRecorders.shorthands_for(category).
Shared setup — declaration and resolution¶
Every strategy starts here:
from apeGmsh import apeGmsh, Results
from apeGmsh.opensees import apeSees
with apeGmsh(model_name="demo") as g:
# ... build geometry, mesh, physical groups ...
fem = g.mesh.queries.get_fem_data(dim=3)
# OpenSees — post-session, explicit declarations.
ops_bridge = apeSees(fem)
ops_bridge.model(ndm=3, ndf=3)
# ... materials, elements, fix, patterns (re-declare explicitly) ...
# Declare what you want recorded on the apeSees bridge
ops_bridge.recorder.Node(
nodes=fem.nodes.get_ids(pg="Top"), dofs=[1, 2, 3], response="disp")
ops_bridge.recorder.Element(
elements=fem.elements.get_ids(pg="Body"), response="stress")
# Build the frozen, gmsh-independent spec directly
from apeGmsh.results.spec import ResolvedRecorderSpec, ResolvedRecorderRecord
spec = ResolvedRecorderSpec(
fem_snapshot_id=fem.snapshot_id,
records=(ResolvedRecorderRecord(
category="nodes", name="top",
components=("displacement_x", "displacement_y", "displacement_z"),
dt=None, n_steps=None,
node_ids=fem.nodes.get_ids(pg="Top"),
),),
)
# ... pick a strategy below ...
spec is a frozen ResolvedRecorderSpec. From here, you have five
ways to run the analysis and read results.
Strategy A₁ — Export Tcl, run elsewhere¶
For cluster jobs, reproducible scripts, or non-Python tooling.
ops_bridge.tcl("model.tcl", recorders=spec)
# … you run OpenSees on the cluster, files land in out/ …
Then from your laptop, parse the recorder output:
results = Results.from_recorders(spec, "out/", fem=fem)
disp = results.nodes.get(component="displacement_z", pg="Top")
| Pros | Cons |
|---|---|
| Reproducible script you can check in | Requires running OpenSees externally |
| Cluster-friendly | Two-process workflow |
| Output cached by file mtime + spec hash |
Strategy A₂ — Export Python, run elsewhere¶
Same as A₁ but ops.recorder(...) source instead of Tcl.
ops_bridge.py("model.py", recorders=spec)
# … run python model.py, files land in out/ …
results = Results.from_recorders(spec, "out/", fem=fem)
Use when the rest of your production pipeline is Python.
Strategy A₃ — Live recorders in the notebook¶
The new in-process classic-recorder path. Same recorder commands as
A₁/A₂, but pushed directly into the live openseespy domain — no
script written, no subprocess.
import openseespy.opensees as ops
with spec.emit_recorders("out/") as live:
# First stage
live.begin_stage("gravity", kind="static")
ops.system("BandSPD"); ops.numberer("RCM"); ops.constraints("Plain")
ops.test("NormDispIncr", 1e-6, 20); ops.algorithm("Newton")
ops.integrator("LoadControl", 0.1); ops.analysis("Static")
for _ in range(10):
ops.analyze(1)
live.end_stage()
# Second stage — different integrator / pattern, same recorders
live.begin_stage("dynamic", kind="transient")
# ... set up dynamic analysis ...
for _ in range(n_steps):
ops.analyze(1, dt)
live.end_stage()
# Read each stage independently
grav = Results.from_recorders(spec, "out/", fem=fem, stage_id="gravity")
dyn = Results.from_recorders(spec, "out/", fem=fem, stage_id="dynamic")
When stage_id is set and stage_name is left at its default ("analysis"), from_recorders mirrors stage_name = stage_id automatically so the loaded Results stage carries the meaningful name (Results.py:179-180).
How it works¶
__enter__validates the spec; raises immediately if it contains any modal records (those needops.eigen()driving and live on Strategy B instead).begin_stage(name, kind)issuesops.recorder("Node", ...)/ops.recorder("Element", ...)calls with output filenames prefixed<name>__so per-stage files don't collide.end_stage()removes the recorders, which is what flushes their output files.__exit__auto-closes any forgotten stage and warns if you exited without ever callingbegin_stage.
Coverage¶
| Category | Status |
|---|---|
nodes |
Supported |
elements (per-element-node forces) |
Supported |
gauss (continuum stress/strain) |
Supported |
line_stations (beam section forces) |
Supported (emits paired integrationPoints recorder) |
fibers |
Warn-and-skip — use spec.capture or spec.emit_mpco |
layers |
Warn-and-skip — same |
modal |
Raises at __enter__ — use spec.capture |
| Pros | Cons |
|---|---|
| Notebook-native, no subprocess | Read one stage at a time |
| Multi-stage with proper scoping | Modal not supported |
| Same recorder semantics as Tcl |
Strategy B — Domain capture (broadest coverage)¶
apeGmsh queries the live ops domain itself and writes apeGmsh's own
native HDF5 directly. The most complete strategy — supports every
topology level and modal stages.
import openseespy.opensees as ops
with spec.capture(path="run.h5", fem=fem, ndm=3, ndf=3) as cap:
cap.begin_stage("gravity", kind="static")
# ... static analysis setup ...
for _ in range(n_grav):
ops.analyze(1, 1.0)
cap.step(t=ops.getTime()) # ← apeGmsh probes ops here
cap.end_stage()
cap.begin_stage("dynamic", kind="transient")
# ... dynamic analysis setup ...
for _ in range(n_dyn):
ops.analyze(1, dt)
cap.step(t=ops.getTime())
cap.end_stage()
# Modal stages — each mode becomes its own stage
cap.capture_modes(n_modes=10)
# One file holds all stages including modes
results = Results.from_native("run.h5", fem=fem)
for mode in results.modes:
print(mode.mode_index, mode.frequency_hz, mode.period_s)
How it works¶
- Each
cap.step(t)callsops.nodeDisp(...),ops.eleResponse(...), etc. for every record in the spec — translating canonical names to the right openseespy call. - Per-step values buffer in RAM;
end_stage()flushes a chunked HDF5 write viaNativeWriter. capture_modes(n)runsops.eigen(n)and writes one stage per mode withkind="mode"and the eigenvalue / frequency / period in the stage attributes.
Coverage¶
| Category | Status |
|---|---|
| All seven topology levels (incl. fibers, layers, springs) | Supported |
| Modal | Native via capture_modes |
| Multi-stage with mixed kinds | Yes |
| Pros | Cons |
|---|---|
| Broadest coverage | Slower than recorders for huge runs |
| Interactive-friendly | Output is apeGmsh-native, not Tcl-compatible |
| Modal handled natively |
Tri31 strain routing. Tri31 has no element-level
"strains"branch in OpenSees (only"stresses"). For any class listed inPER_MATERIAL_STRAIN_CLASSES(solvers/_element_response.py:2072), capture queries strain per Gauss-point material viaops.eleResponse(eid, "material", "<gp>", "strain")(capture/_domain.py:870-874). Reads are unaffected — see the Tri31 note inguide_results.md.
Strategy C₁ — Export with MPCO, run with STKO¶
For the STKO ecosystem and big parallel runs.
ops_bridge.tcl("model.tcl", recorders=spec, mpco=True)
# … run with STKO loaded — produces run.mpco …
results = Results.from_mpco("run.mpco")
apeGmsh emits a single recorder mpco … line into the script; STKO
writes the HDF5 file. apeGmsh's MPCOReader reads it directly without
re-transcoding.
For parallel runs:
# Multi-partition .mpco — auto-discovers .part-N siblings
results = Results.from_mpco("run.part-0.mpco")
# Opt out of auto-discovery (read only the named partition)
results = Results.from_mpco("run.part-0.mpco", merge_partitions=False)
| Pros | Cons |
|---|---|
| Battle-tested STKO recorder | Requires STKO-loaded OpenSees |
| Fast, parallel-aware | External run |
| Native MPCO support for fibers / layers / modal |
Strategy C₂ — Live MPCO in the notebook¶
The MPCO recorder in-process. Requires an openseespy build with
MPCO compiled in (typically STKO's bundled Python).
import openseespy.opensees as ops
with spec.emit_mpco("run.mpco"):
# Drive the entire analysis — MPCO writes one file with all stages
ops.analysis("Transient")
for _ in range(n_steps):
ops.analyze(1, dt)
results = Results.from_mpco("run.mpco")
How it works¶
__enter__issues a singleops.recorder("mpco", path, -N <tokens>, -E <tokens>)call for the entire spec.- No
begin_stage/end_stageceremony — MPCO writes one file containing all stages withpseudoTimeencoding stage boundaries internally. __exit__removes the recorder, which flushes the HDF5 file.
Build-gate¶
If the active openseespy build doesn't include the MPCO recorder,
__enter__ raises with a clear remediation pointer:
RuntimeError: ops.recorder('mpco', ...) failed. The most likely cause
is that the active openseespy build does not include the MPCO
recorder; vanilla openseespy distributions do not ship it.
Workable options:
- run inside STKO's bundled Python distribution
- use spec.emit_recorders(...) for classic recorders + Results.from_recorders(...)
- export with ops.tcl(..., recorders=spec, mpco=True) and run with STKO loaded
Coverage¶
| Category | Status |
|---|---|
nodes, elements, gauss, line_stations |
Supported |
fibers (section.fiber.stress) |
Native |
layers (layered-section tokens) |
Native |
modal (modesOfVibration) |
Native |
| Pros | Cons |
|---|---|
| Notebook-native, no subprocess | Requires STKO-loaded openseespy |
| Full coverage including fibers / layers | |
| MPCO output is parallel-aware |
Picking a strategy¶
flowchart TD
Start([What's your workflow?]) --> Q1{Run on a cluster<br/>or external machine?}
Q1 -->|Yes| Q2{STKO available?}
Q1 -->|No, in notebook| Q3{STKO openseespy<br/>build available?}
Q2 -->|No| A1[Strategy A₁<br/>export.tcl]
Q2 -->|Yes| C1[Strategy C₁<br/>export.tcl mpco=True]
Q3 -->|Yes| C2[Strategy C₂<br/>emit_mpco]
Q3 -->|No| Q4{Need fibers,<br/>layers, or modal?}
Q4 -->|Yes| B[Strategy B<br/>capture]
Q4 -->|No| A3[Strategy A₃<br/>emit_recorders]
Quick rules of thumb:
- Notebook + you control the build: Strategy C₂ if you have STKO, Strategy B otherwise.
- Notebook + simple recorders only: Strategy A₃.
- Notebook + need modal / fibers / layers + no STKO: Strategy B.
- Cluster: Strategy A₁ for general OpenSees, Strategy C₁ for STKO shops.
- Mixing in Python tooling: Strategy A₂ (same as A₁ but Python source).
Reading the results¶
All five strategies feed into the same Results API. After you have
a results object, the read code is identical:
# Auto-resolve when there's only one stage
disp = results.nodes.get(component="displacement_z", pg="Top")
# Multi-stage: pick one
gravity = results.stage("gravity")
sigma = gravity.elements.gauss.get(component="stress_xx", pg="Body")
# Modes (kind="mode" stages)
for mode in results.modes:
print(mode.mode_index, mode.frequency_hz)
shape = mode.nodes.get(component="displacement_z")
# Visualize
results.viewer()
See apeGmsh.results.Results for the full
composite API and slab dataclass shapes.
Common pitfalls¶
"I called emit_recorders but no files appeared"¶
You forgot begin_stage / end_stage. The LiveRecorders lifecycle
requires explicit stage markers. Exiting the with block without any
begin_stage call will warn — check your stderr.
"Results.from_recorders can't find the file"¶
If you used emit_recorders (Strategy A₃), you must pass
stage_id= matching the name you gave to begin_stage:
# Wrong — looks for r_disp.out
Results.from_recorders(spec, "out/", fem=fem)
# Right — looks for gravity__r_disp.out
Results.from_recorders(spec, "out/", fem=fem, stage_id="gravity")
"emit_mpco raises about the build"¶
You're running on a openseespy build without the MPCO recorder.
Either switch to STKO's bundled Python distribution, or fall back
to Strategy A₃ (emit_recorders) or Strategy B (capture).
"emit_recorders raises about modal records"¶
The classic recorder path can't drive ops.eigen(). Move modal
records to Strategy B (capture):
# Modal records → use capture
with spec.capture("modes.h5", fem=fem) as cap:
cap.capture_modes(n_modes=10)
# Other records → emit_recorders separately
with spec.emit_recorders("out/") as live:
live.begin_stage("dynamic")
...
Or split them at declaration time so the spec for emit_recorders
doesn't contain modal records.