4. Reading and writing LS-DYNA keyword files
LS-DYNA keyword files are plain-text decks organized as a sequence of *KEYWORD cards. Each card begins with an asterisk keyword line, optionally followed by parameter lines, and then one or more fixed-format data lines. For CAE preprocessing, four card families dominate day-to-day work: *NODE, *ELEMENT, *PART, and *SET_*. A robust Python reader must respect the fixed-column layout of these cards, because LS-DYNA does not use delimiters — fields are defined by character positions, not by whitespace.
Fixed-format field layout
For *NODE, each data line carries nid, x, y, z in columns 1–8, 9–16, 17–24, 25–32 (10-character fields are also accepted in many decks). For *ELEMENT_SHELL, the line is eid, pid, n1, n2, n3, n4 in 8-character fields. *PART uses two lines: the first holds pid, secid, mid, eosid, hgid; the second holds title. *SET_NODE_LIST and *SET_PART_LIST begin with sid, solver, then list member IDs, up to eight per line for node sets.
Because the format is column-based, splitting on whitespace works for well-formed decks but fails when a field is blank (e.g., an empty eosid). The safe approach is slicing by column index.
def parse_node_line(line):
# 8-char fields; fall back to 10-char if the line is long enough
if len(line.rstrip("\n")) >= 32:
nid = int(line[0:8]); x = float(line[8:16])
y = float(line[16:24]); z = float(line[24:32])
return nid, x, y, z
raise ValueError("short *NODE line: %r" % line)
Streaming the deck with a card-aware reader
Loading a 500 MB k file with readlines() is wasteful. Iterate line by line, track the current keyword, and dispatch to a handler. This keeps memory flat and lets you stop early once the cards you need are collected.
import re
CARD_RE = re.compile(r"^\*(?P<kw>[A-Z_]+)(?:/(?P<opt>[A-Z_]+))?", re.I)
def iter_cards(path):
kw, opt, buf = None, None, []
with open(path, "r", errors="replace") as fh:
for raw in fh:
line = raw.rstrip("\n")
if line.startswith("*"):
if kw:
yield kw, opt, buf
m = CARD_RE.match(line)
kw = m.group("kw").upper() if m else None
opt = (m.group("opt") or "").upper() if m else ""
buf = []
elif kw:
buf.append(line)
if kw:
yield kw, opt, buf
Two details matter here. First, *INCLUDE cards must be resolved before parsing, otherwise referenced nodes and parts are missing. Second, comment lines beginning with $ should be skipped inside handlers, not inside the iterator, so that handlers can preserve them when writing back.
Parsing *NODE, *ELEMENT, *PART, and *SET
With the iterator in place, each handler is short and testable. The example below collects nodes into a dict keyed by nid, shells into a list of tuples, parts into a dict of pid → (secid, mid, title), and node sets into sid → set(nid).
def load_model(path):
nodes, shells, parts, nsets = {}, [], {}, {}
for kw, opt, buf in iter_cards(path):
if kw == "NODE":
for ln in buf:
if ln.startswith("$") or not ln.strip():
continue
nid, x, y, z = parse_node_line(ln)
nodes[nid] = (x, y, z)
elif kw == "ELEMENT" and opt.startswith("SHELL"):
for ln in buf:
if ln.startswith("$") or not ln.strip():
continue
f = [ln[i:i+8].strip() for i in range(0, 40, 8)]
shells.append(tuple(int(v) for v in f if v))
elif kw == "PART":
for i in range(0, len(buf), 2):
head = buf[i]
if head.startswith("$") or not head.strip():
continue
f = [head[j:j+10].strip() for j in range(0, 50, 10)]
pid = int(f[0]); secid = int(f[1]); mid = int(f[2])
title = buf[i+1].strip() if i+1 < len(buf) else ""
parts[pid] = (secid, mid, title)
elif kw == "SET" and opt.startswith("NODE"):
sid = None
for ln in buf:
if ln.startswith("$") or not ln.strip():
continue
if sid is None:
sid = int(ln[0:10].strip())
nsets[sid] = set()
continue
for j in range(0, len(ln), 8):
v = ln[j:j+8].strip()
if v:
nsets[sid].add(int(v))
return nodes, shells, parts, nsets
Note the *PART loop stepping by two lines: the title line is mandatory in the LS-DYNA manual, but some exporters omit it, so guard with i+1 < len(buf). For *SET_NODE_LIST, the first data line holds sid and solver; subsequent lines hold up to eight node IDs in 8-character fields.
Writing cards back without corrupting the deck
Writing is the mirror image: emit the keyword line, then fixed-width fields. Use "{:>8d}" for integer IDs and "{:>16.7e}" for coordinates to match LS-DYNA's expected precision. Always terminate the file with *END.
def write_nodes(fh, nodes):
fh.write("*NODE\n")
fh.write("$# nid x y z\n")
for nid, (x, y, z) in nodes.items():
fh.write("{:>8d}{:>16.7e}{:>16.7e}{:>16.7e}\n".format(nid, x, y, z))
A common pitfall is mixing 8- and 10-character fields in the same card. LS-DYNA tolerates it in most solvers, but downstream tools such as LS-PrePost may misalign. Pick one width per card and stay consistent.
Verification and typical failure modes
After parsing, run three cheap checks: (1) every element node ID exists in nodes; (2) every pid referenced by elements exists in parts; (3) every sid in nsets is non-empty. These catch the majority of malformed decks.
| Symptom | Likely cause | Fix |
|---|---|---|
ValueError: short *NODE line | 10-char fields used instead of 8 | Detect width from line length or card header |
| Missing nodes after parse | Unresolved *INCLUDE | Expand includes before iterating |
| Part titles shifted by one line | Title line omitted by exporter | Check whether next line starts with * |
| Set members truncated | Assumed 10-char fields in *SET_NODE_LIST | Use 8-char slicing for node sets |
repr() to see exact column positions before writing a parser.The pipeline above is deliberately minimal: one iterator, four handlers, three checks. Once it works on a small deck, extend it with *INCLUDE resolution, *DEFINE_CURVE parsing, or a writer that round-trips the model back to disk. The same column-slicing discipline applies to every additional card you add.