4. Parsing LS-DYNA keyword files with Pytho
LS-DYNA keyword files are the primary input format for crash simulation models. A typical full-vehicle model contains hundreds of thousands of lines organized into keyword blocks, each beginning with an asterisk (*) followed by a keyword name such as *NODE, *ELEMENT, *PART, *MAT, or *SECTION. Before any automation task — model checking, parameter sweeps, mesh statistics, or automated report generation — you need to convert these flat text blocks into structured Python data. This section builds a reusable parser that reads the five most common card types into dictionaries and lists that downstream code can query directly.
Understanding the Keyword File Structure
LS-DYNA keyword files follow a fixed-field, comma-delimited format. Each keyword block starts with a *KEYWORD line, optionally followed by a title line, then data cards. Fields are separated by commas, and the meaning of each field depends on the card type. For example, a *NODE card has the form:
*NODE
$# nid x y z
1 0.000000 0.000000 0.000000
2 100.000000 0.000000 0.000000
Comment lines begin with $ and must be skipped. Blank lines separate blocks but carry no data. The parser must handle these conventions consistently across all card types.
Building the Core Parser
The parsing strategy is straightforward: read the file line by line, detect keyword headers, and dispatch each block to a dedicated handler. A clean implementation uses a single pass with a state variable tracking the current keyword.
import re
def parse_kwd(filepath):
data = {"NODE": {}, "ELEMENT": {}, "PART": {}, "MAT": {}, "SECTION": {}}
current = None
with open(filepath, "r", encoding="utf-8", errors="ignore") as f:
for line in f:
line = line.rstrip("\n")
if not line or line.startswith("$"):
continue
if line.startswith("*"):
current = line[1:].strip().upper()
continue
if current == "NODE":
_parse_node(line, data)
elif current == "ELEMENT":
_parse_element(line, data)
elif current == "PART":
_parse_part(line, data)
elif current and current.startswith("MAT_"):
_parse_mat(line, current, data)
elif current == "SECTION":
_parse_section(line, data)
return data
This dispatcher keeps the main loop readable and lets each handler focus on its own field layout. The current variable resets whenever a new * line appears, so blocks are processed independently.
Parsing *NODE Cards
Node cards contain four fields: node ID, X, Y, and Z coordinates. The handler splits on commas, strips whitespace, and stores the result in a dictionary keyed by node ID for O(1) lookup.
def _parse_node(line, data):
parts = [p.strip() for p in line.split(",")]
if len(parts) < 4:
return
try:
nid = int(parts[0])
x, y, z = float(parts[1]), float(parts[2]), float(parts[3])
except ValueError:
return
data["NODE"][nid] = (x, y, z)
After parsing, data["NODE"][1001] returns a tuple such as (250.0, 0.0, 480.0). This structure supports fast coordinate queries during model validation.
Parsing *ELEMENT Cards
Element cards vary by element type. Solid elements (*ELEMENT_SOLID) list eight node IDs, while shell elements (*ELEMENT_SHELL) list four. The parser should capture the element ID, part ID, and the node connectivity list.
def _parse_element(line, data):
parts = [p.strip() for p in line.split(",")]
if len(parts) < 3:
return
try:
eid = int(parts[0])
pid = int(parts[1])
nodes = [int(p) for p in parts[2:] if p]
except ValueError:
return
data["ELEMENT"][eid] = {"pid": pid, "nodes": nodes}
Storing connectivity as a list preserves the original node ordering, which matters for normal vector calculations and element quality checks.
Parsing *PART, *MAT, and *SECTION Cards
These three card types are tightly coupled: a part references a section ID and a material ID. Parsing them together lets you build a complete part-level view of the model.
def _parse_part(line, data):
parts = [p.strip() for p in line.split(",")]
if len(parts) < 4:
return
try:
pid = int(parts[0])
secid = int(parts[1])
mid = int(parts[2])
except ValueError:
return
data["PART"][pid] = {"secid": secid, "mid": mid}
def _parse_mat(line, keyword, data):
parts = [p.strip() for p in line.split(",")]
if not parts or not parts[0]:
return
try:
mid = int(parts[0])
except ValueError:
return
data["MAT"].setdefault(mid, {"type": keyword, "params": []})
data["MAT"][mid]["params"].append(parts[1:])
def _parse_section(line, data):
parts = [p.strip() for p in line.split(",")]
if not parts or not parts[0]:
return
try:
secid = int(parts[0])
except ValueError:
return
data["SECTION"][secid] = parts[1:]
Material cards are handled generically because *MAT_ has dozens of variants (*MAT_ELASTIC, *MAT_PLASTIC_KINEMATIC, *MAT_JOHNSON_COOK, etc.). The parser records the material type and stores all parameter fields as a list, which downstream code can interpret based on the type string.
Cross-Referencing Parts, Materials, and Sections
Once all blocks are parsed, you can resolve the relationships between them. The following function builds a consolidated part table that joins part, section, and material data:
def build_part_table(data):
table = {}
for pid, part in data["PART"].items():
sec = data["SECTION"].get(part["secid"], [])
mat = data["MAT"].get(part["mid"], {})
table[pid] = {
"secid": part["secid"],
"mid": part["mid"],
"mat_type": mat.get("type", "UNKNOWN"),
"section_params": sec,
}
return table
This table is the foundation for automated checks such as detecting parts with missing material definitions or sections with zero thickness.
Practical Validation and Common Pitfalls
After parsing, run a few sanity checks. Count the number of nodes, elements, and parts; compare against the model's expected totals. Verify that every element's part ID exists in the part dictionary, and that every part's material and section IDs resolve. A missing material reference typically indicates a truncated file or a keyword block that was not recognized.
- Fixed-width vs. comma-delimited: Some legacy files use fixed-width fields without commas. Detect this by checking whether a line contains commas; if not, fall back to fixed-width slicing.
- Continuation lines: Long material cards may span multiple lines. The
*MAThandler above appends each line's fields to a list, preserving order. - Case sensitivity: Keyword names are case-insensitive in LS-DYNA but Python string comparisons are not. Always normalize with
.upper(). - Include files:
*INCLUDEdirectives reference external files. A complete parser must resolve these recursively or at least log them for manual handling.
Extending the Parser
The same dispatcher pattern extends naturally to other card types. To add *CONSTRAINED_NODAL_RIGID_BODY or *SET_NODE, write a handler that follows the same signature and register it in the dispatch chain. For large models, consider using pandas.DataFrame for node and element tables to accelerate vectorized queries, or numpy arrays for coordinate operations. The dictionary-based approach shown here remains the clearest starting point and integrates directly with downstream automation scripts that generate reports, modify parameters, or export to other solvers.
numpy.loadtxt for node blocks or use pandas.read_csv with appropriate skiprows and comment='$' settings.