3. Parsing LS-DYNA keyword files with Python
LS-DYNA keyword files (*.k or *.key) are the primary input format for the solver. They are plain ASCII text, but their structure is far from free-form: every card follows a fixed-column layout inherited from the original 80-column punch-card format. A robust Python parser must therefore respect three things simultaneously — the card type identified by the keyword line, the fixed-width field boundaries inside each data line, and the continuation rules that let a single logical card span many physical lines. This section builds a parser that handles all three.
Reading the file line by line
Never load a large keyword file with read() and split on newlines if you intend to process it incrementally; a full vehicle model can exceed 500 MB. Use a streaming iterator instead, and normalize line endings so that Windows CRLF files behave identically to Unix files:
def iter_lines(path):
with open(path, "r", encoding="ascii", errors="replace") as fh:
for raw in fh:
yield raw.rstrip("\r\n")
Each yielded line is a physical line. The parser's job is to group physical lines into logical cards. A keyword line always begins with * in column 1. Data lines follow until the next keyword line or EOF. Some cards also use a trailing & or a blank keyword field to signal continuation, but for node and element blocks the grouping rule is simply "everything between two keyword lines belongs to the preceding keyword."
Fixed-width field extraction
LS-DYNA defines field widths per card. For the two cards we care about here:
| Card | Field | Columns (1-based) | Width | Type |
|---|---|---|---|---|
*NODE | NID | 1–8 | 8 | int |
*NODE | X | 9–16 | 8 | float |
*NODE | Y | 17–24 | 8 | float |
*NODE | Z | 25–32 | 8 | float |
*ELEMENT_SOLID | EID | 1–8 | 8 | int |
*ELEMENT_SOLID | PID | 9–16 | 8 | int |
*ELEMENT_SOLID | N1…N8 | 17–80 | 8 each | int |
Because the widths are fixed, slicing is exact and far faster than regex. The only subtlety is that LS-DYNA allows fields to be left blank, and a blank integer field means "zero" while a blank float field means "0.0". A safe converter must therefore treat an empty slice as a default rather than raising:
def f_int(s):
s = s.strip()
return int(s) if s else 0
def f_float(s):
s = s.strip()
return float(s) if s else 0.0
def fields(line, widths):
out, pos = [], 0
for w in widths:
out.append(line[pos:pos + w])
pos += w
return out
Note that fields() does not pad short lines. A truncated line — common when a file was edited by hand — will simply yield shorter slices, and the converters above will return defaults for the missing tail. That is usually the desired behavior, but you should log a warning so silent data loss does not go unnoticed.
Extracting nodes and elements
With the primitives in place, the block parser is a small state machine. It tracks the current keyword, dispatches each data line to the appropriate handler, and stores results in dictionaries keyed by ID for later lookup:
NODE_W = [8, 8, 8, 8]
SOLID_W = [8, 8] + [8] * 8
def parse(path):
nodes, solids = {}, {}
kw = None
for line in iter_lines(path):
if line.startswith("*"):
kw = line.strip().upper()
continue
if not line.strip():
continue
if kw == "*NODE":
nid, x, y, z = fields(line, NODE_W)
nodes[f_int(nid)] = (f_float(x), f_float(y), f_float(z))
elif kw == "*ELEMENT_SOLID":
f = fields(line, SOLID_W)
eid, pid = f_int(f[0]), f_int(f[1])
conn = [f_int(v) for v in f[2:] if v.strip()]
solids[eid] = (pid, conn)
return nodes, solids
Two details matter in practice. First, *ELEMENT_SOLID supports 8-node hexahedra, but also 4-node tetrahedra and 6-node pentahedra; the number of non-empty connectivity fields tells you which. Filtering with if v.strip() handles this automatically. Second, keyword lines may carry options such as *ELEMENT_SOLID_ORTHO; the startswith check on the uppercased keyword keeps the dispatch working, but you should split on _ if you need to distinguish variants.
Verifying the parse
A parser that runs without exceptions is not necessarily correct. Add cheap sanity checks: the node count should match the value declared on the *NODE card's header line, every connectivity entry in solids should exist in nodes, and no ID should be zero unless the file genuinely uses zero-based numbering (it does not, by default). A dangling reference almost always means a field-width mistake — for example, using 10-column widths on a file that actually uses 8.
missing = {n for _, conn in solids.values() for n in conn if n not in nodes}
assert not missing, f"{len(missing)} orphan node references"
Typical failure symptoms and their causes: coordinates that look like 1.234e+02 appearing as 1.234e+0 indicate a width that is one column too narrow; node IDs that jump by 10 suggest the ID field is being read as 10 columns instead of 8; and an empty solids dictionary usually means the keyword line was *ELEMENT_SOLID but the dispatch compared against a lowercased string.
What to carry forward
The parser above is deliberately minimal, but it already embodies the three rules that scale to the full keyword language: stream physical lines, group them by keyword, and slice each data line by the card's declared field widths. Once node and element extraction is stable, the same skeleton extends to *PART, *SECTION_SHELL, and *MAT_* cards by adding width tables and handlers. Keep the width tables in one module-level dictionary so that a single edit fixes every card that shares a layout, and always run the orphan-reference check before feeding parsed data into any downstream geometry or meshing step.