1. Parse LS-DYNA keyword file for NODE and ELEMENT blocks

Why a dedicated parser is the right first step

An LS-DYNA keyword file (.k or .key) is a flat, card-based text format. Every logical block starts with a * keyword line, optionally followed by parameter lines, and then a fixed number of data cards. For graph conversion we only care about two block families: *NODE (node ID plus three coordinates) and the *ELEMENT_* family (element ID plus a connectivity list). The parser's job is to turn that text into two in-memory structures — a node table and an element table — that later stages can consume without re-reading the file.

The subtlety is that LS-DYNA data cards are fixed-width (10 characters per field by default), not whitespace-delimited. Node and element IDs are commonly written as 8-digit integers that touch their neighbours, so a naive line.split() silently merges fields. A robust parser must therefore slice by column width, with a whitespace fallback for hand-edited files.

Block structure and the fields we keep

A minimal *NODE block looks like this (the *NODE line may carry no parameters, or NSID/R/CP etc.):

*NODE
       101  0.0000000e+00  0.0000000e+00  0.0000000e+00
       102  1.0000000e+00  0.0000000e+00  0.0000000e+00
       103  1.0000000e+00  1.0000000e+00  0.0000000e+00

An *ELEMENT_SOLID block declares its node count on the keyword line (N1..N8 for an 8-node hex, N1..N4 for a tet) and then lists one element per card:

*ELEMENT_SOLID
         1       101       102       103       104       105       106       107       108
         2       102       109       110       103       106       111       112       107

For graph conversion we need exactly four things: node ID, node coordinates, element ID, and the ordered connectivity list. Everything else (part IDs, material references, section IDs) is out of scope for this case and should be skipped rather than parsed.

Parser design

The parser is a single forward pass with a small state machine. State is one of NONE, NODE, or ELEMENT. On each line:

  • If the line starts with *, normalise the keyword (uppercase, strip trailing parameters after the first comma) and switch state. *NODE → NODE; any keyword starting with *ELEMENT → ELEMENT; anything else → NONE.
  • If state is NODE, slice the line into 10-character fields, parse field 0 as the node ID and fields 1–3 as x, y, z.
  • If state is ELEMENT, slice into 10-character fields, parse field 0 as the element ID and fields 1..N as connectivity, where N is the node count declared on the keyword line (default 8 for *ELEMENT_SOLID).
  • Blank lines and comment lines ($) are skipped in every state.

Two practical details matter. First, LS-DYNA allows *INCLUDE to pull in other files; for this case we treat the file as self-contained and record any *INCLUDE we see so the caller can decide whether to recurse. Second, element blocks may be split across several *ELEMENT_SOLID sections (one per part); the parser must accumulate into the same element table rather than reset it.

Fixed-width slicing is the single most important correctness decision. If you slice at 10 characters and the file uses 8-character fields, you will get garbage IDs. Detect the field width once by inspecting the first data card: if len(line) % 10 == 0 and the first 10 characters parse as an integer, use 10; otherwise fall back to whitespace splitting.

Reference implementation

The following Python module is self-contained and runnable. It defines the data model, the parser, and a small CLI that prints a summary so you can verify the parse against the file.

import sys
from dataclasses import dataclass, field

@dataclass
class Mesh:
    nodes: dict = field(default_factory=dict)      # id -> (x, y, z)
    elements: dict = field(default_factory=dict)   # id -> tuple of node ids
    includes: list = field(default_factory=list)

def _fields(line, width=10):
    """Slice a fixed-width LS-DYNA data card into fields."""
    if width and len(line) >= width:
        return [line[i:i+width].strip() for i in range(0, len(line), width)]
    return line.split()

def _detect_width(lines):
    for ln in lines:
        s = ln.rstrip("\n")
        if not s or s.startswith(("*", "$")):
            continue
        if len(s) % 10 == 0 and s[:10].strip().lstrip("-").isdigit():
            return 10
        return None
    return 10

def parse_kfile(path):
    with open(path, "r", encoding="utf-8", errors="replace") as fh:
        lines = fh.readlines()
    width = _detect_width(lines)
    mesh = Mesh()
    state = "NONE"
    nconn = 8  # default for *ELEMENT_SOLID

    for raw in lines:
        line = raw.rstrip("\n")
        if not line.strip() or line.lstrip().startswith("$"):
            continue
        if line.startswith("*"):
            kw = line[1:].split(",")[0].strip().upper()
            if kw == "NODE":
                state = "NODE"
            elif kw.startswith("ELEMENT"):
                state = "ELEMENT"
                nconn = _element_node_count(kw, line)
            elif kw == "INCLUDE":
                state = "NONE"
                mesh.includes.append(line)
            else:
                state = "NONE"
            continue

        if state == "NODE":
            f = _fields(line, width)
            if len(f) < 4:
                continue
            nid = int(f[0])
            mesh.nodes[nid] = (float(f[1]), float(f[2]), float(f[3]))
        elif state == "ELEMENT":
            f = _fields(line, width)
            if len(f) < 2:
                continue
            eid = int(f[0])
            conn = tuple(int(v) for v in f[1:1+nconn] if v)
            mesh.elements[eid] = conn
    return mesh

def _element_node_count(kw, line):
    # *ELEMENT_SOLID with N1..N8 -> 8; *ELEMENT_SHELL -> 4; *ELEMENT_BEAM -> 2
    if kw == "ELEMENT_SOLID":
        return 8
    if kw == "ELEMENT_SHELL":
        return 4
    if kw == "ELEMENT_BEAM":
        return 2
    return 8

if __name__ == "__main__":
    m = parse_kfile(sys.argv[1])
    print(f"nodes={len(m.nodes)} elements={len(m.elements)} includes={len(m.includes)}")
    if m.nodes:
        nid = next(iter(m.nodes))
        print("sample node", nid, m.nodes[nid])
    if m.elements:
        eid = next(iter(m.elements))
        print("sample element", eid, m.elements[eid])

What the parser produces

Running the parser on the two-block example above yields nodes=3 elements=2, with mesh.nodes[101] == (0.0, 0.0, 0.0) and mesh.elements[1] == (101, 102, 103, 104, 105, 106, 107, 108). That is the exact contract the next stage needs: a dictionary keyed by node ID for coordinate lookup, and a dictionary keyed by element ID whose values are ordered node tuples. Because both are plain dicts, downstream graph construction can iterate mesh.elements.values() and emit edges without any further parsing.

model.k *NODE 101 0.0 0.0 0.0 102 1.0 0.0 0.0 103 1.0 1.0 0.0 *ELEMENT_SOLID 1 101 102 103 104 105 106 107 108 2 102 109 110 103 106 111 112 107 $ comment lines skipped *INCLUDE other.k *ELEMENT_SHELL 11 101 102 103 104 State machine NONE NODE ELEMENT switch on *KEYWORD skip $ and blank lines Fixed-width slicer width = 10 fallback: split() field[0] = ID field[1..N] = data mesh.nodes 101 → (0,0,0) 102 → (1,0,0) 103 → (1,1,0) … dict[int, tuple] mesh.elements 1 → (101..108) 2 → (102,109, 110,103,…) dict[int, tuple] → graph stage

Verification and common failure modes

Verify the parse with three cheap checks before trusting it downstream. First, the node count should equal the number of data cards under all *NODE blocks; a mismatch almost always means a keyword line was misclassified. Second, every connectivity ID in mesh.elements must exist in mesh.nodes; a missing ID indicates either a truncated file or a field-width error that produced a wrong integer. Third, the sample element's connectivity length must equal the declared node count — a hex with only 4 entries means the card wrapped and the continuation line was not consumed.

SymptomLikely causeFix
Node IDs are huge or negativeWrong field width; adjacent fields mergedForce width=10 or detect from first card
Element connectivity shorter than expectedCard wrapped onto a continuation lineAppend continuation lines until nconn fields collected
Element count resets between blocksParser reinitialises table on each *ELEMENT_*Accumulate into the same dict
Coordinates are zero everywhereScientific notation parsed with int()Use float() for coordinate fields

With the parser in place, the mesh is fully materialised in memory and the next stage can build the adjacency structure directly from mesh.elements without touching the file again.