3. Reading and parsing LS-DYNA keyword file
An LS-DYNA keyword file is not a structured data format in the usual sense. It is a line-oriented text stream in which meaning is carried by card position, field width, and the order of keyword blocks. Before any automation can be useful — parameter sweeps, batch model generation, automated QA — you need a parser that turns that stream into an in-memory representation you can query and modify. This section covers the mechanics of that parser: how to read lines, how to interpret fixed-field and free-field cards, how to handle comments, how to resolve *INCLUDE references, and how to assemble everything into a model object.
Line-oriented reading and the keyword block model
LS-DYNA input is organized as a sequence of keyword blocks. Each block begins with a keyword line starting with *, followed by zero or more data cards. A robust reader therefore does not tokenize the whole file at once; it reads line by line and maintains a small state machine: the current keyword, the current card index within that keyword, and any accumulated data.
from dataclasses import dataclass, field
@dataclass
class Card:
raw: str
fields: list
@dataclass
class KeywordBlock:
keyword: str # e.g. "*ELEMENT_SHELL"
options: list # e.g. ["_THICKNESS"]
cards: list = field(default_factory=list)
def read_lines(path):
with open(path, "r", encoding="latin-1") as fh:
for lineno, line in enumerate(fh, 1):
yield lineno, line.rstrip("\n").rstrip("\r")
Two details matter here. First, LS-DYNA files are historically ASCII and may contain non-UTF-8 bytes in comments; opening with latin-1 avoids decode failures while preserving byte values. Second, trailing whitespace must be stripped consistently, because fixed-field parsing depends on exact column positions and a stray \r from a Windows file will shift every field.
Fixed-field versus free-field cards
LS-DYNA supports two card formats. In fixed-field format, each field occupies exactly 10 columns (with 8 characters of content plus 2 spaces of separation in the classic layout). In free-field format, fields are comma-separated and may be written without regard to column position. A parser must detect which format a given card uses.
The practical rule: if the line contains a comma, treat it as free-field; otherwise slice it into 10-character chunks. This is not perfectly rigorous — a fixed-field card can legally contain a comma inside a comment field — but it is the convention used by LS-PrePost and by most third-party parsers, and it is reliable for well-formed decks.
def parse_card(line):
if "," in line:
parts = [p.strip() for p in line.split(",")]
return parts
# fixed-field: 10-column slices
return [line[i:i+10].strip() for i in range(0, len(line), 10)]
def to_float(token, default=0.0):
if token == "":
return default
try:
return float(token)
except ValueError:
return default
Note the empty-token handling. In LS-DYNA, a blank field is not an error; it means "use the default." Your parser must preserve that distinction rather than collapsing blanks to zero, because downstream logic often needs to know whether a value was explicitly specified.
Comment handling
Comments begin with $ and extend to the end of the line. They may appear anywhere, including between data cards of a single keyword block. The correct behavior is to discard them before card parsing but to retain their line numbers for diagnostics.
def strip_comment(line):
idx = line.find("$")
return line if idx < 0 else line[:idx]
One subtlety: a $ inside a quoted string in a *DEFINE or *PARAMETER context is not a comment. For the core structural keywords covered here, however, the simple rule is safe. If you later add support for expression-bearing keywords, gate the comment stripping on the current keyword.
Resolving include files
Real decks are almost never a single file. *INCLUDE cards pull in sub-files, and those may include others, forming a tree. Resolution has two rules that are easy to get wrong:
- Relative paths in
*INCLUDEare resolved relative to the including file's directory, not the process working directory. - An include may be marked with
*INCLUDE_TRANSFORM, which applies a coordinate transformation and optional ID offset to the included content. For a first parser, record the transform parameters and apply them later; do not silently ignore them.
import os
def resolve_includes(path, seen=None):
seen = seen or set()
real = os.path.realpath(path)
if real in seen:
raise RuntimeError(f"circular include: {real}")
seen.add(real)
base = os.path.dirname(real)
for lineno, line in read_lines(real):
clean = strip_comment(line).strip()
if clean.upper().startswith("*INCLUDE"):
target = clean.split()[1].strip('"')
yield from resolve_includes(os.path.join(base, target), seen)
else:
yield lineno, line
The seen set guards against circular includes, which do occur in hand-edited decks and produce infinite loops in naive parsers. The generator form keeps memory flat even for very large assemblies.
Building the internal model representation
Once lines are flattened and cards are parsed, assemble a model object. A dictionary keyed by keyword is sufficient for most automation tasks, but a small class gives you a place to hang lookup indexes and validation.
class KeywordModel:
def __init__(self):
self.blocks = {} # keyword -> list[KeywordBlock]
self.node_ids = {} # nid -> (x, y, z)
self.elem_ids = {} # eid -> (pid, [n1..n4])
def add(self, block):
self.blocks.setdefault(block.keyword, []).append(block)
def index_nodes(self):
for blk in self.blocks.get("*NODE", []):
for c in blk.cards:
nid = int(c.fields[0])
self.node_ids[nid] = tuple(
to_float(c.fields[i]) for i in (1, 2, 3))
The indexes are what make the model useful. With node_ids and elem_ids populated, you can answer questions like "which elements reference node 1042?" or "what is the bounding box of part 7?" without re-scanning the file. Build indexes lazily, after parsing, so that a partial parse still yields a usable object.
node_ids. A missing node is the single most common symptom of a mis-parsed fixed-field card, usually caused by a column shift from an unstripped carriage return or a comment that was not removed.
Worked example: parsing a small deck
Consider a minimal deck with one include and a mixed-format element block:
*NODE
101 0.0000000 0.0000000 0.0000000
102 1.0000000 0.0000000 0.0000000
*INCLUDE
mesh_rest.cfg
*ELEMENT_SHELL
201, 7, 101, 102, 103, 104
Running the parser yields a KeywordModel with two *NODE cards parsed as fixed-field (no comma present) and one *ELEMENT_SHELL card parsed as free-field. The include is expanded in place, so mesh_rest.cfg's contents appear as additional blocks. If mesh_rest.cfg defines nodes 103 and 104, the element's node references resolve; if not, the consistency check above fires and points you at the include path.
Common failure modes
| Symptom | Likely cause | Fix |
|---|---|---|
| Fields shifted by one column | Unstripped \r or leading whitespace | Normalize line endings before slicing |
| Blank fields become 0.0 | Default substitution too early | Keep None for blanks; apply defaults at use site |
| Infinite loop | Circular *INCLUDE | Track visited real paths |
| Missing nodes after parse | Include resolved against wrong base directory | Resolve relative to including file |
| Comment text parsed as data | $ not stripped | Strip before card parsing |
The pipeline above is the minimum viable architecture. Each stage is independently testable: feed the include resolver a deck with a circular reference and confirm it raises; feed the card parser a fixed-field line with a trailing \r and confirm the fields are correct; feed the model assembler a deck with a dangling node reference and confirm the consistency check reports it. Once these stages are solid, the model object becomes a reliable foundation for the automation work in later chapters — parameter editing, batch generation, and automated validation all operate on this representation rather than on raw text.