3. Parsing PAM-CRASH input decks with Pytho
PAM-CRASH input decks are fixed-format, card-oriented text files. Every meaningful piece of model data — nodes, shell elements, material definitions, boundary conditions, time-step controls — lives on a keyword card that begins with a slash-prefixed keyword such as /NODE, /SHELL, or /MAT. Before any automation can modify, validate, or regenerate a deck, the file must be parsed into a structured in-memory representation. This section builds that parser from the ground up: how to tokenize a line reliably, how to identify block boundaries, and how to extract typed field values from each card.
Why a generic line splitter is not enough
A naive line.split() fails on PAM-CRASH decks for three reasons. First, fields are column-position sensitive in many card families, so a value's meaning depends on where it starts, not just its order. Second, continuation lines and comment lines ($ or # prefixed) must be handled without corrupting the record stream. Third, numeric fields may be written in Fortran-style notation (1.0E+03, 1.0D+03, or even 1.0+03), which Python's float() rejects in the last two forms. A robust tokenizer therefore normalizes these variants before conversion.
The tokenizer's job is narrow and well-defined: given a raw line, return a list of string tokens plus a flag indicating whether the line is a comment, blank, or data line. It should not yet know what a node is. That separation keeps the tokenizer reusable across all card types.
import re
COMMENT_PREFIXES = ("$", "#")
_FLOAT_FIX = re.compile(r"([0-9])D([+-])", re.IGNORECASE)
_FLOAT_FIX2 = re.compile(r"([0-9])([+-][0-9])")
def tokenize(line):
"""Return (kind, tokens) where kind is 'data', 'comment', or 'blank'."""
stripped = line.rstrip("\n")
if not stripped.strip():
return "blank", []
if stripped.lstrip().startswith(COMMENT_PREFIXES):
return "comment", []
# Normalize Fortran exponents to Python-parseable form
norm = _FLOAT_FIX.sub(r"\1E\2", stripped)
norm = _FLOAT_FIX2.sub(r"\1E\2", norm)
return "data", norm.split()
Note the two regex passes. The first converts D exponents; the second inserts an E where a sign directly follows a digit, catching the compact 1.0+03 form. Applying both before splitting means downstream code only ever sees standard scientific notation.
Block identification: from keyword to record group
Once lines are tokenized, the parser walks the file and groups consecutive data lines under the most recent keyword. A keyword line is any data line whose first token starts with /. Everything until the next keyword belongs to that block. This yields a simple but powerful model: a list of (keyword, [lines]) pairs.
def split_blocks(lines):
blocks, current_kw, current = [], None, []
for raw in lines:
kind, toks = tokenize(raw)
if kind != "data":
continue
if toks[0].startswith("/"):
if current_kw is not None:
blocks.append((current_kw, current))
current_kw, current = toks[0].upper(), []
elif current_kw is not None:
current.append(toks)
if current_kw is not None:
blocks.append((current_kw, current))
return blocks
Two subtleties matter here. Keyword matching is case-insensitive in practice, so normalizing to uppercase avoids duplicate handling of /node versus /NODE. And comment lines are dropped rather than stored, because they carry no model data — though a production parser may optionally retain them for round-trip fidelity.
/MAT/1 or /SHELL/1. Treat the full first token as the block key, or split on / to separate the base keyword from its option index. Decide this convention once and apply it consistently across the whole parser.Data extraction: mapping tokens to typed fields
With blocks identified, extraction becomes a per-keyword concern. Each card family has a known field layout. For a /NODE block, each data line typically carries a node ID followed by three coordinates. For a /SHELL block, the line carries an element ID, a part/material ID, and four node IDs. The extractor converts tokens to the correct Python types and returns dictionaries keyed by ID.
def extract_nodes(block_lines):
nodes = {}
for toks in block_lines:
if len(toks) < 4:
continue # malformed or continuation; handle separately
nid = int(float(toks[0]))
x, y, z = (float(toks[1]), float(toks[2]), float(toks[3]))
nodes[nid] = (x, y, z)
return nodes
def extract_shells(block_lines):
shells = {}
for toks in block_lines:
if len(toks) < 6:
continue
eid = int(float(toks[0]))
pid = int(float(toks[1]))
n = [int(float(t)) for t in toks[2:6]]
shells[eid] = {"pid": pid, "nodes": n}
return shells
The int(float(...)) idiom is deliberate: IDs are sometimes written as 1001.0, and a direct int() would raise. Converting through float first tolerates both integer and decimal spellings. Field-count guards (len(toks) < 4) prevent crashes on truncated or wrapped lines, which are common in hand-edited decks.
Putting it together and verifying
A minimal end-to-end parse reads the file, splits blocks, and dispatches each block to its extractor via a keyword-to-function table. The result is a dictionary of model entities that later chapters will mutate and re-serialize.
EXTRACTORS = {
"/NODE": extract_nodes,
"/SHELL": extract_shells,
}
def parse_deck(path):
with open(path, "r") as fh:
blocks = split_blocks(fh.readlines())
model = {}
for kw, lines in blocks:
fn = EXTRACTORS.get(kw)
if fn:
model[kw] = fn(lines)
return model
Verification is straightforward and should always be performed on a real deck. After parsing, check that the node count matches the number of /NODE data lines, that every shell references node IDs present in the node table, and that no ID appears twice. A quick consistency check catches the most common parsing failures: misidentified block boundaries (a keyword swallowed as data) and silent field shifts (a column offset causing coordinates to land in the wrong slot).
The parser described here is intentionally minimal but complete: it tokenizes with Fortran-number tolerance, identifies blocks by keyword, and extracts typed records with defensive guards. Every later automation task — parameter sweeps, material substitution, boundary-condition injection — depends on this foundation being correct, so it is worth validating against a known-good deck before building on top of it.