3. Parsing LS-DYNA keyword files with Pytho
LS-DYNA input decks are plain-text keyword files. Every card begins with an asterisk, and the meaning of each line depends on the currently active keyword block. Before any automation can rename parts, patch material parameters, or extract section data, the deck must be parsed into a structure Python can reason about. This section covers the physical layout of a keyword file, the four constructs that dominate real decks (*KEYWORD, *INCLUDE, *PARAMETER, and *DEFINE), and the text-processing techniques that keep a parser stable across the many dialects LS-DYNA accepts.
Deck structure and the fixed-width convention
A keyword file is a sequence of blocks. Each block starts with a keyword line such as *ELEMENT_SHELL or *MAT_PIECEWISE_LINEAR_PLASTICITY, optionally followed by a title line, then one or more data lines. LS-DYNA historically reads data lines as fixed-width fields of 8 or 10 characters, but modern decks are usually written comma-separated. A robust parser must handle both, because a deck assembled from multiple sources will mix them.
The first non-comment line is normally *KEYWORD, which declares the file format. Two variants matter:
*KEYWORD
*KEYWORD, LOCAL
*KEYWORD, DYNA3D
*KEYWORD, LONG=Y
LONG=Y switches node and element IDs to 64-bit, which changes field widths. If your parser assumes 8-character fields and the deck is long-format, IDs will silently truncate. Read the *KEYWORD line first and set a width policy before parsing anything else.
Handling *INCLUDE
Real production decks are almost never a single file. They use *INCLUDE to pull in sub-assemblies, material libraries, and boundary-condition fragments:
*INCLUDE
../materials/steel_DP780.k
*INCLUDE_PATH
/opt/lsdyna/libraries
Two rules govern correct resolution. First, relative paths in *INCLUDE are resolved relative to the directory of the file that contains the include, not the working directory. Second, *INCLUDE_PATH entries are searched in order when a file is not found locally. A parser that ignores these rules will fail on decks that run fine in LS-DYNA. The practical approach is a recursive loader that tracks the current file's directory and maintains a search path list.
*PARAMETER and *DEFINE substitution
Parameterized decks use *PARAMETER to declare named values and & or *DEFINE to reference them:
*PARAMETER
thk_shell 2.5
mat_steel 1
*DEFINE_CURVE
&curve_id
0.0, 0.0
0.01, 250.0
Substitution happens before the deck is interpreted, so a parser must expand parameters in a pre-pass. The syntax is not uniform: older decks use &name, newer ones use *DEFINE_... with & references, and some use %name%. A safe strategy is to collect all *PARAMETER entries into a dictionary, then run a regex substitution over every subsequent line, iterating until no further replacements occur (to handle parameters defined in terms of other parameters).
Parameter names are case-insensitive in LS-DYNA but the substitution is textual. If a parameter value is 2.5 and it appears inside a quoted title, the substitution will still fire. Restrict substitution to data lines, not title lines, unless you have verified the deck does not rely on that behavior.
A minimal but robust parser skeleton
The following structure handles the common cases without external dependencies. It reads a file, resolves includes, expands parameters, and yields blocks as dictionaries.
import re, os
PARAM_RE = re.compile(r'&([A-Za-z_]\w*)')
INCLUDE_RE = re.compile(r'^\*INCLUDE\b', re.I)
def load_deck(path, search_paths=None, _seen=None):
search_paths = search_paths or []
_seen = _seen or set()
path = os.path.abspath(path)
if path in _seen:
raise RuntimeError(f"circular include: {path}")
_seen.add(path)
with open(path, 'r', errors='replace') as f:
lines = f.read().splitlines()
base = os.path.dirname(path)
out = []
for ln in lines:
if INCLUDE_RE.match(ln):
continue # handled by caller
out.append(ln)
return out, base
This is deliberately incomplete: it shows the shape of the loader. The include resolution loop, parameter expansion, and block splitting are added next.
Splitting into blocks
Once includes are resolved and parameters expanded, split the flat line list into blocks. A block starts at any line whose first non-space character is *. Everything until the next such line belongs to the current block. The first line is the keyword; the second line, if present and not starting with *, is the title; the rest are data lines.
def split_blocks(lines):
blocks, cur = [], None
for ln in lines:
if ln.lstrip().startswith('*'):
if cur: blocks.append(cur)
cur = {'keyword': ln.strip(), 'title': '', 'data': []}
elif cur is not None:
if not cur['title'] and not cur['data']:
cur['title'] = ln
else:
cur['data'].append(ln)
if cur: blocks.append(cur)
return blocks
This simple splitter is correct for the vast majority of decks. The edge cases are *INCLUDE (already handled), *PARAMETER (should be consumed before splitting), and comment lines beginning with $, which should be dropped or preserved depending on whether you need to round-trip the deck.
Field parsing: comma vs fixed-width
Data lines come in two flavors. Comma-separated lines are trivial: line.split(',') and strip whitespace. Fixed-width lines require slicing at 8- or 10-character boundaries. Detect the format per line: if the line contains a comma, treat it as CSV; otherwise slice. This heuristic fails only for lines that contain a comma inside a quoted string, which is rare in LS-DYNA data lines.
def parse_fields(line, width=8):
if ',' in line:
return [f.strip() for f in line.split(',')]
return [line[i:i+width].strip()
for i in range(0, len(line), width)]
For long-format decks, pass width=10 or width=20 depending on the keyword. The *KEYWORD, LONG=Y declaration is the authoritative signal.
What the parser should produce
A useful parser output is a list of block dictionaries, each with a normalized keyword (uppercase, no leading *), a title, and a list of parsed field rows. From there, downstream code can index blocks by keyword, extract node coordinates, or rewrite material parameters. The parser should also record the source file and line number for every block, because error messages that say "line 412 of main.k" are far more useful than "parse error".
Common failure modes
| Symptom | Likely cause | Fix |
|---|---|---|
| IDs truncated to 8 digits | Long-format deck parsed with width=8 | Read *KEYWORD, LONG=Y and set width |
| Missing materials | Relative *INCLUDE resolved against CWD | Resolve against the including file's directory |
| Parameter not substituted | Substitution ran once, not iteratively | Loop until no &name remains |
| Infinite loop | Circular include | Track visited absolute paths |
| Fields shifted by one | Title line treated as data | Skip the first non-keyword line as title |
With the loader, parameter expander, block splitter, and field parser in place, the deck is now a Python data structure. The next step is to build queries on top of it: find all *MAT_ blocks, extract node sets, or rewrite thickness values. Those operations are straightforward once the parsing layer is correct, and they are the subject of the following sections.