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:

CardFieldColumns (1-based)WidthType
*NODENID1–88int
*NODEX9–168float
*NODEY17–248float
*NODEZ25–328float
*ELEMENT_SOLIDEID1–88int
*ELEMENT_SOLIDPID9–168int
*ELEMENT_SOLIDN1…N817–808 eachint

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.

Always uppercase the keyword before comparison. LS-DYNA accepts mixed case, and files exported from different preprocessors are inconsistent about it.

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.

Keyword file → node/element extraction pipeline model.k *NODE 1 0.0 0.0 0.0 2 10.0 0.0 0.0 *ELEMENT_SOLID 1 1 1 2 3 4 … iter_lines() strip CRLF stream, no read() dispatch line[0] == "*" ? → set kw else → fields() slice by width nodes {} nid → (x, y, z) solids {} eid → (pid, [n…]) Fixed-width slicing of one *NODE line (8 columns per field) NID X Y Z ← line[0:8], line[8:16], line[16:24], line[24:32] Validation gate orphan node refs · zero IDs · count mismatch vs header · truncated lines → log warning, do not silently default

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.