2. Parse LS-DYNA Keyword Blocks into Structured Records
Why a Card-Aware Parser Beats Line Splitting
An LS-DYNA keyword deck is not a flat text file. It is a sequence of keyword blocks, each opened by a *KEYWORD line, optionally followed by comment lines beginning with $, then one or more fixed-format cards whose field widths depend on the keyword. A naive split() on whitespace destroys this structure: *ELEMENT_SHELL cards are 8-character fixed fields, *MAT_024 cards mix 10-character and 8-character fields, and blank fields are meaningful (they mean "use default" or "inherit"). For dataset preparation we need a parser that preserves three things simultaneously: the keyword identity, the owning part ID where applicable, and the original card order plus comment lines so any extracted feature can be traced back to a source line.
This case builds that parser as a small, self-contained Python module. The output is a list of typed records — one per card — each carrying keyword, part_id (or None), card_index, fields (a dict of typed values), and source_lines (the raw text plus any preceding comments). That record list is the substrate every later extraction step in this course will consume.
Deck Structure the Parser Must Respect
Three structural rules drive the design. First, a keyword line starts with * at column 0 and may carry a title on the same line after a space (e.g. *PART followed by a name). Second, comment lines start with $ and must be attached to the next card, not discarded, because analysts frequently annotate material cards with unit notes or provenance. Third, card field widths are keyword-specific, so the parser needs a small schema table rather than a universal splitter.
The schema below covers the four keywords that dominate crash decks and that this case's parser must handle correctly. Widths are in characters; None means "skip this field" (LS-DYNA reserves some columns).
| Keyword | Card | Field widths (chars) | Typed fields produced |
|---|---|---|---|
*PART | card 1 | 10, 10, 10 | pid:int, secid:int, mid:int |
*PART | card 2 | 10, 10 | hgid:int, grav:int |
*SECTION_SHELL | card 1 | 10, 10, 10, 10 | secid:int, elform:int, shrf:float, nip:int |
*MAT_024 | card 1 | 10, 10, 10, 10 | mid:int, ro:float, e:float, pr:float |
*ELEMENT_SHELL | card 1 | 8, 8, 8, 8, 8 | eid:int, pid:int, n1:int, n2:int, n3:int |
Note the deliberate asymmetry: *ELEMENT_SHELL uses 8-character fields while the others use 10. A parser that assumes one width will silently misalign every shell element in the deck — the most common failure mode when teams first attempt this task.
Parser Implementation
The implementation is a single-pass state machine. It tracks the current keyword, buffers comment lines, and emits a record for each data card it can type. Fields that fail to parse as int or float are kept as raw strings and flagged, so downstream code can decide whether to drop or repair them rather than crashing mid-deck.
import re
from dataclasses import dataclass, field
from typing import Optional
# (keyword, card_index) -> list of (field_name, width, type)
SCHEMA = {
("*PART", 0): [("pid", 10, int), ("secid", 10, int), ("mid", 10, int)],
("*PART", 1): [("hgid", 10, int), ("grav", 10, int)],
("*SECTION_SHELL", 0): [("secid", 10, int), ("elform", 10, int),
("shrf", 10, float), ("nip", 10, int)],
("*MAT_024", 0): [("mid", 10, int), ("ro", 10, float),
("e", 10, float), ("pr", 10, float)],
("*ELEMENT_SHELL", 0): [("eid", 8, int), ("pid", 8, int),
("n1", 8, int), ("n2", 8, int), ("n3", 8, int)],
}
@dataclass
class CardRecord:
keyword: str
card_index: int
part_id: Optional[int]
fields: dict
source_lines: list = field(default_factory=list)
parse_errors: list = field(default_factory=list)
def _slice(line, widths):
out, pos = [], 0
for w in widths:
out.append(line[pos:pos + w].strip())
pos += w
return out
def _coerce(raw, typ):
if raw == "":
return None
try:
return typ(raw)
except ValueError:
return raw # keep raw, caller flags it
def parse_deck(path):
records, comments = [], []
keyword, card_idx = None, 0
with open(path, "r", encoding="utf-8", errors="replace") as fh:
for lineno, raw in enumerate(fh, 1):
line = raw.rstrip("\n")
if line.startswith("$"):
comments.append(line)
continue
if line.startswith("*"):
keyword = line.split()[0].upper()
card_idx = 0
comments = []
continue
if keyword is None or line.strip() == "":
continue
spec = SCHEMA.get((keyword, card_idx))
if spec is None:
card_idx += 1
comments = []
continue
widths = [w for _, w, _ in spec]
raws = _slice(line, widths)
fields, errs = {}, []
for (name, _, typ), r in zip(spec, raws):
v = _coerce(r, typ)
if isinstance(v, str):
errs.append(f"{name}={r!r} not {typ.__name__}")
fields[name] = v
part_id = fields.get("pid")
records.append(CardRecord(keyword, card_idx, part_id,
fields, comments + [line], errs))
card_idx += 1
comments = []
return records
Two design choices matter for traceability. source_lines stores the buffered comments plus the card line itself, so a record can be printed back to the analyst exactly as it appeared. And part_id is lifted to a top-level attribute only when the card actually carries a pid field — for *MAT_024 and *SECTION_SHELL the part linkage is indirect (via *PART), so those records keep part_id=None and the join happens in the next case. This keeps the parser honest: it does not invent relationships the deck does not state.
How Records Flow Through the Parser
The diagram below shows the state transitions and the record shape that emerges. It is the mental model to hold when debugging a misaligned field: every card passes through the same three gates — keyword detection, schema lookup, and typed coercion — and the only branch that changes record content is whether the schema knows the keyword.
Running It on a Minimal Deck
Save the following as mini.k and run parse_deck("mini.k"). The deck is intentionally tiny but exercises every branch: a comment before a material card, a shell element with 8-char fields, and a part card whose second card is present.
$ units: mm-ton-s-N
*MAT_024
1 7.85e-9 2.10e+5 0.3
*SECTION_SHELL
2 2 0.8 5
*PART
3 2 1
0 0
*ELEMENT_SHELL
10 3 1 2 3
Expected observable output (printed as keyword, card_index, part_id, fields):
*MAT_024, 0, None, {mid:1, ro:7.85e-09, e:210000.0, pr:0.3}—source_linescontains the$ unitscomment.*SECTION_SHELL, 0, None, {secid:2, elform:2, shrf:0.8, nip:5}*PART, 0, 3, {pid:3, secid:2, mid:1}*PART, 1, None, {hgid:0, grav:0}— second card has nopid, sopart_idisNone.*ELEMENT_SHELL, 0, 3, {eid:10, pid:3, n1:1, n2:2, n3:3}
eid=10 and pid=3 match the deck. If you see eid=103 or pid=None on the shell card, the parser is using 10-char widths where 8 are required — the classic misalignment symptom.Failure Modes and How to Read Them
Three symptoms account for nearly all parser bugs on real decks. Field drift (values shifted one column) means the schema width for that keyword is wrong; confirm against the LS-DYNA Keyword User's Manual for the exact card, not against a neighboring keyword. Missing records usually mean the keyword line has trailing text the parser did not strip, or the keyword is not in SCHEMA and was silently skipped — add a counter for skipped keywords during development. Spurious parse_errors on numeric fields typically indicate scientific notation with a leading + or a Fortran-style D exponent (2.1D5); normalize those before coercion if your decks come from legacy preprocessors.
*PART card 2 and optional material fields), whitespace splitting collapses columns and corrupts every subsequent field on the card.Reusable Checklist for This Parser
| Check | Pass condition |
|---|---|
| Keyword coverage | Every * line in the deck maps to a SCHEMA entry or is logged as skipped |
| Width fidelity | Shell element fields use 8 chars; part/section/material use 10 |
| Comment retention | Each record's source_lines includes preceding $ lines |
| Order preservation | card_index increments monotonically within a keyword block |
| Type safety | Numeric fields are int/float; failures appear in parse_errors, not exceptions |
| Part linkage | part_id set only where the card carries pid; no invented joins |
The record list produced here is the input contract for the next step: joining *PART, *SECTION_SHELL, and *MAT_024 records into one row per part. Because every record carries its source_lines and parse_errors, any downstream feature can be audited back to the exact deck line that produced it — which is the whole point of parsing rather than scraping.