Interop reader authoring#
How to add a new card / keyword to a vendor reader. The three
production readers — NASTRAN from_bdf, Abaqus from_inp,
Ansys MAPDL from_dat / from_cdb — share a common shape but
different parser idioms; this page maps the work end-to-end so
adding the next card is a mechanical edit.
Authoring driver: most reader gaps are surfaced by a VM in the
verification corpus that hits a card the parser doesn’t yet
handle. When that happens, file the gap as a child issue (label
[interop] + verify-blocked), tick the registry row XFAIL,
land the card support in a focused PR, then flip the row. The VM
spec at Verification-manual spec is the upstream consumer of
this guide.
Reader landscape#
Reader |
Module |
Parser idiom |
|---|---|---|
NASTRAN BDF |
|
Dispatch table. |
Abaqus INP |
|
Block parser. Keywords are |
Ansys MAPDL |
|
Verb interpreter. APDL is procedural; each verb
( |
Ansys MAPDL |
|
Binary archive reader; covers the same surface as
|
Each reader’s public API is the from_<ext>(path) -> Model entry
point. All file I/O, string parsing, dispatch, and materialisation
into a Model is internal.
The materialisation contract#
Cards / blocks accumulate into a per-vendor data structure
(_BdfData, _InpData, etc.). When the parser is done, a
materialisation pass converts that structure into a Model —
node coordinates, element connectivity, materials, real constants,
boundary conditions, loads. The materialisation contract is the
neutral representation the rest of the solver consumes; per-
vendor parsers map onto it, and per-element kernels read from it.
Real-constant slot conventions (excerpted from the codebase):
Element family |
|
|---|---|
Shell ( |
|
Rod / truss ( |
|
Beam ( |
|
Concentrated mass ( |
|
Solid ( |
|
Materials are dictionaries keyed by property name (EX, PRXY,
DENS, ALPX, …) — not a vendor-specific struct. Every
reader normalises into the same key set so kernels don’t care
where the material came from.
Boundary conditions and loads land on the Model via the same
per-DOF API regardless of vendor (model.point_data_to_dirichlet,
model.add_force_dof, etc.).
Adding a card to from_bdf#
Worked example: adding a hypothetical CBUSH card (a 6-DOF
spring-damper element). The BDF reader is the simplest pattern,
so this is the canonical walkthrough.
Step 1 — extend the data collector#
_BdfData already holds element / property / material / BC /
load tables. Most cards land in an existing table. When in
doubt, mirror the closest existing card.
Step 2 — write the per-card parser#
Per-card parsers all have signature (fields: list[str], data:
_BdfData) -> None and follow this pattern:
def _parse_cbush(fields: list[str], data: _BdfData) -> None:
# CBUSH eid pid ga gb [orient1 orient2 orient3] [pa pb [s ot]]
if len(fields) < 4:
return
eid = _as_int(fields[1])
pid = _as_int(fields[2])
ga = _as_int(fields[3])
gb = _as_int(fields[4]) if len(fields) > 4 else None
if eid is None or pid is None or ga is None:
return
data.elements[eid] = _Element(
eid=eid, pid=pid, kind="bush", nodes=(ga, gb) if gb else (ga,),
)
Conventions:
Use
_as_int/_as_floatfor field parsing — they handle blank fields and BDF’s signed/scientific number quirks.Validate before accumulating. A card with a missing required field is a parse-warning, not a partial accumulation.
Don’t raise on optional cards we don’t yet support — log debug and skip. Raise only when the card was understood but the option isn’t supported (e.g. PBAR with a non-zero
I12); surface that as a kernel-side gap.
Step 3 — register in _CARD_DISPATCH#
Add a row mirroring the existing entries:
_CARD_DISPATCH = {
# ... existing entries unchanged ...
"CBUSH": _parse_cbush,
}
The phase comments (# Phase 1, # Phase 2b, etc.) are
authoring scars from earlier rounds; new cards drop in next to
their family.
Step 4 — extend materialisation#
Cards that introduce a new element kind require a route in
_ELEMENT_MAP and a real-constant packaging branch. The pattern
in _bdf.py is:
_ELEMENT_MAP = {
# ... existing entries unchanged ...
("CBUSH", 2): "BUSH6", # 6-DOF spring kernel
}
…and a new branch in the materialisation loop (mirrors the
existing shell / rod / beam arms):
if prop.kind == "bush":
real = (prop.k_translational, prop.k_rotational)
If the kernel doesn’t exist yet (most common case for a new card), this is a coordinated card+kernel PR — see Coordinated card+kernel landings.
Step 5 — unit test#
Every new card lands with a focused unit test under
tests/interop/nastran/test_bdf_reader_phase<N>.py. The test
authors a minimal fixture under
tests/interop/nastran/fixtures/<descriptor>.bdf and asserts:
Card parsed (element exists in the registry).
Real-constant slots land at the right positions and values.
Material / BC / load fields propagate correctly.
Don’t assert solve results in the interop unit test — that’s the cross-solver harness’s job (see Test-suite layout for the test-category boundary).
A worked example pattern (from
tests/interop/nastran/test_bdf_reader_phase2b.py):
def test_cbar_beam_pbar_reals_land_correctly():
model = from_bdf(_FIXTURES / "cbar_beam.bdf")
reals = np.asarray(model._real_constants[1])
assert reals[0] == pytest.approx(1.0e-4) # A
assert reals[1] == pytest.approx(2.0e-8) # IZZ ← I2
assert reals[2] == pytest.approx(1.0e-8) # IYY ← I1
assert reals[3] == pytest.approx(5.0e-9) # J
Adding a keyword block to from_inp#
Abaqus is more forgiving — keywords are blocks delimited by
*<NAME> lines, and the dispatch is on the keyword name plus
its parameters dict. Same five-step pattern, slightly different
plumbing.
Step 1 — write the block parser#
Block parsers have signature
(data, params: dict[str, str], lines: list[str]) -> None.
params is the comma-separated key=value list on the keyword
line; lines is the data block until the next *.
Step 2 — register in the keyword dispatch#
The INP reader has a _KEYWORD_DISPATCH analogous to BDF’s
_CARD_DISPATCH:
_KEYWORD_DISPATCH = {
# ... existing entries unchanged ...
"SPRING": _parse_spring_block,
}
Step 3 — extend materialisation#
Same as BDF: route to the appropriate kernel via
_ELEMENT_TYPE_MAP, populate real constants in the
materialisation pass.
Step 4 — handle the params dict’s options#
Abaqus keywords frequently carry options (SECTION=GENERAL,
MATERIAL=<name>, etc.). Handle them inline in the block
parser; raise NotImplementedError with a clear message when an
option exists but isn’t supported yet — the corpus authoring agent
sees that and opens a child issue.
Step 5 — unit test#
Mirrors the BDF pattern but lives under
tests/interop/abaqus/test_inp_reader_phase<N>.py.
The MAPDL from_dat shim#
MAPDL’s .dat deck is procedural — each verb is an instruction
to the solver. The current reader (#522 Phase 1) sits on top of
the APDL shim under
femorph_solver.interop.mapdl._apdl_dialect: from_dat walks
the deck a verb at a time and translates to apdl.<verb>(...)
calls.
Adding a new APDL verb is two edits:
Shim method — add
def <verb>(self, *args)to theAPDLclass with the side effect on the boundModel.Dat dialect — add the verb name to the dispatch in
_apdl_dialect.pysofrom_datrecognises it.
The build-path tests under tests/validation/<vendor>_vm/ use
the shim directly — that’s how a VM gets verified before the
.dat parser handles every required verb. When the verb lands,
the harness picks up the row automatically (see
Reader-pending fallback).
Coordinated card+kernel landings#
When a new card requires an element kernel that doesn’t exist yet, the work is one PR (or two PRs in lock-step) covering both:
Kernel — a new element under
src/femorph_solver/elements/withke,me, real-constant layout, and registration. Seekernel_authoring(planned) for the full walkthrough.Reader — the parser changes above, plus the
_ELEMENT_MAP/_ELEMENT_TYPE_MAProute into the new kernel.VM round-trip — flip the registry XFAIL once the harness reads the deck successfully.
Recent worked examples:
#549 — PLANE182 EAS (Wilson Q6) — kernel-side
QUAD4_PLANEtech="enhanced"formulation + reader change to routeKEYOPT(1)=2decks through it.#622 — SHELL281 (Quad8Shell) — full new kernel + INP / BDF route + VM6 round-trip.
#580 — PIPE family — circular hollow beam + internal / external pressure load card on the BDF side.
#515 — SECTYPE,1,BEAM,I — derived I-section reals from the vendor’s section-shape namespace.
In each case the PR title is feat(elements|interop): <thing>,
the body cites the registry row(s) it unblocks, and the merge
flips the corresponding ☐ → ☑ on the detail tracker (#345 /
#511 / #322).
Common pitfalls#
Side-effecting in the parser pass. Per-card parsers should only accumulate. Materialisation is a separate pass; mixing them makes “what’s actually in this deck” hard to inspect at the data-collector level.
Silent-skip on a recognised card. If you recognise a card, parse it. Don’t
passand hope the test catches it — the test won’t, because nothing else in the deck depends on the field you skipped.Embedding vendor convention into the kernel. The MSC PBAR
I1/ AbaqusI11semantics are vendor convention, not kernel convention. Translate at the reader boundary (real = (A, I2, I1, J)for BDF,(A, I22, I11, J)for INP) — the kernel always sees(A, IZZ, IYY, J). See #509 / #573 for the prior reference case where this was botched.Editing a fixture to make a parser test pass. Fixtures are immutable. See Fixtures and decks for the rule and the two narrow exceptions.
Coupling reader changes to a kernel that hasn’t merged yet. Land them together in one PR or in adjacent PRs that gate on each other. Don’t merge a reader edit that points at a kernel the next
mainlacks.Forgetting the materialisation comment block. Each property kind’s real-constant packaging in the materialisation loop carries an inline comment (
# real[1] = IZZ ← I2 …). Future readers depend on those comments to audit cross-vendor parity; don’t strip them.
Where things live#
Concern |
Path |
|---|---|
Per-vendor reader source |
|
Per-vendor unit tests |
|
Per-vendor fixtures |
|
Cross-solver harness (closed-form assertions) |
|
Registry rows |
|
Build-path fallback (reader-pending VMs) |
|
Element kernels |
|
Element registration |
|
Per-element specs (with KEYOPT / formulation kwargs) |
|
Companion pages: Verification-manual spec (consumer of this guide — VM ingest is what surfaces most reader gaps), Fixtures and decks (immutable-deck rule + provenance), Test-suite layout (where each kind of test lives).