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.

SymptomLikely causeFix
ValueError: short *NODE line10-char fields used instead of 8Detect width from line length or card header
Missing nodes after parseUnresolved *INCLUDEExpand includes before iterating
Part titles shifted by one lineTitle line omitted by exporterCheck whether next line starts with *
Set members truncatedAssumed 10-char fields in *SET_NODE_LISTUse 8-char slicing for node sets
When debugging a new deck, dump the first 20 lines of each card with repr() to see exact column positions before writing a parser.
LS-DYNA Keyword Parsing Pipeline model.k *NODE *ELEMENT_SHELL *PART / *SET_* iter_cards() line-by-line stream CARD_RE dispatch yield (kw, opt, buf) Card handlers parse_node_line() shell field slicing part 2-line block set member loop Model dict nodes / shells parts / nsets id → data Fixed-column field layout (8-char example) eid pid n1 n2 n3 n4 — — — — 1–8 9–16 17–24 25–32 33–40 41–48 Post-parse validation checks 1 Every element node ID exists in the node dictionary 2 Every element pid resolves to a parsed *PART entry 3 Every *SET_* sid contains at least one member ID Failures here usually indicate unresolved *INCLUDE cards or field-width mismatches.

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.