3. Extract Part and Material Metadata from a Crash Model
Why part-level metadata extraction is the right unit of work
In a full vehicle crash deck, the part is the natural join key for nearly every downstream ML feature. A part carries a name, a material reference, a section reference, and a mass; those four attributes are exactly what a surrogate model needs to reason about stiffness, inertia, and failure mode. The extraction routine in this case therefore produces one normalized row per *PART, resolving the cross-references to *MAT_* and *SECTION_* cards so that the output table is self-contained and does not require the original deck to be re-parsed later.
The critical design decision is that extraction must be reference-resolving, not merely card-dumping. A naive parser that emits the raw *PART card gives you MID and SECID integers, which are meaningless outside the deck. The routine below dereferences those IDs into material model type, density, and section thickness, and computes part mass from the section and material data when the deck does not provide it explicitly.
Inputs and the extraction contract
The routine consumes a keyword deck and emits a CSV with a fixed schema. The contract is deliberately narrow so that downstream feature engineering (material card tables, unit normalization) can rely on stable column names.
| Column | Source | Type | Notes |
|---|---|---|---|
part_id | *PART PID | int | Primary key, unique per deck |
part_name | *PART title | str | Trimmed; empty string if absent |
material_id | *PART MID | int | Resolved to material card |
material_model | *MAT_* keyword | str | e.g. MAT_024 |
density | *MAT_* RO | float | Deck units, unmodified |
section_id | *PART SECID | int | Resolved to section card |
section_type | *SECTION_* keyword | str | e.g. SECTION_SHELL |
thickness | *SECTION_* T1 | float | Shell thickness; null for solids |
part_mass | computed or *ELEMENT_MASS | float | See mass resolution below |
Reference resolution and mass computation
LS-DYNA decks are fixed-format, comma-delimited, and frequently contain comment lines beginning with $. The parser must skip comments, tolerate blank lines, and treat the first non-comment line after a keyword as the card header. The following routine uses a two-pass approach: pass one indexes all *MAT_* and *SECTION_* cards by ID; pass two walks *PART cards and joins against those indexes.
import csv, re
from collections import defaultdict
def parse_deck(path):
"""Yield (keyword, [card_lines]) blocks, skipping $ comments."""
blocks, current, lines = [], None, []
with open(path, "r", errors="replace") as fh:
for raw in fh:
line = raw.rstrip("\n")
if line.startswith("$") or not line.strip():
continue
if line.startswith("*"):
if current:
blocks.append((current, lines))
current, lines = line.strip().upper(), []
else:
lines.append(line)
if current:
blocks.append((current, lines))
return blocks
def split_card(line):
return [f.strip() for f in line.split(",")]
def index_materials(blocks):
mats = {}
for kw, lines in blocks:
if not kw.startswith("*MAT_"):
continue
if not lines:
continue
head = split_card(lines[0])
mid = int(head[0])
ro = float(head[1]) if len(head) > 1 and head[1] else None
mats[mid] = {"model": kw.lstrip("*"), "density": ro}
return mats
def index_sections(blocks):
secs = {}
for kw, lines in blocks:
if not kw.startswith("*SECTION_"):
continue
if not lines:
continue
head = split_card(lines[0])
secid = int(head[0])
t1 = float(head[1]) if len(head) > 1 and head[1] else None
secs[secid] = {"type": kw.lstrip("*"), "thickness": t1}
return secs
def extract_parts(blocks, mats, secs):
rows = []
for kw, lines in blocks:
if kw != "*PART":
continue
if not lines:
continue
head = split_card(lines[0])
pid = int(head[0])
title = head[7] if len(head) > 7 else ""
mid = int(head[1]) if len(head) > 1 and head[1] else None
secid = int(head[2]) if len(head) > 2 and head[2] else None
mat = mats.get(mid, {})
sec = secs.get(secid, {})
rows.append({
"part_id": pid,
"part_name": title,
"material_id": mid,
"material_model": mat.get("model"),
"density": mat.get("density"),
"section_id": secid,
"section_type": sec.get("type"),
"thickness": sec.get("thickness"),
"part_mass": None, # filled by mass resolver
})
return rows
def write_csv(rows, out_path):
fields = ["part_id","part_name","material_id","material_model",
"density","section_id","section_type","thickness","part_mass"]
with open(out_path, "w", newline="") as fh:
w = csv.DictWriter(fh, fieldnames=fields)
w.writeheader()
w.writerows(rows)
if __name__ == "__main__":
blocks = parse_deck("vehicle_crash.k")
mats = index_materials(blocks)
secs = index_sections(blocks)
rows = extract_parts(blocks, mats, secs)
write_csv(rows, "parts_metadata.csv")
print(f"extracted {len(rows)} parts")
Mass is the one field that cannot always be read directly. Three cases occur in practice: (1) the deck contains an explicit *ELEMENT_MASS or *PART_MASS card for the part; (2) the part is a shell with a known thickness and area, so mass = density × thickness × area; (3) the part is a solid with element volumes. The routine above leaves part_mass as None and expects a resolver pass. For the shell case, the resolver sums element areas from the *ELEMENT_SHELL block and multiplies by density * thickness. For solids, it sums element volumes from *ELEMENT_SOLID and multiplies by density. When neither is available, the field stays null and the row is flagged downstream rather than silently zero-filled.
What the extraction produces
The following SVG shows the reference-resolution path for a single part, from the *PART card through the material and section indexes to the normalized output row. It is the mental model the routine implements.
Verification and failure symptoms
Run the routine against a known deck and check three invariants before trusting the output. First, the row count must equal the number of *PART cards in the deck; a mismatch means the block splitter is mis-handling a keyword that appears inside a card body. Second, every non-null material_id must have a non-null material_model; a null model with a valid ID indicates the material index missed a card, usually because the *MAT_* keyword was written with a trailing space or a non-standard suffix. Third, part_mass should be positive for every part that has both a density and a section; a zero or negative value points to a unit mismatch between density and thickness, or to an element block that was not indexed.
Common failure symptoms and their causes:
- All
part_namevalues empty. The title field is not at column 8 of the*PARTcard. Some decks place the title on a continuation line; check the card layout before assuming the field index. material_modelshowsMAT_with no suffix. The keyword line was split on whitespace instead of being read whole; LS-DYNA keywords may contain underscores and digits that a naive tokenizer drops.- Duplicate
part_idrows. The deck contains a*PARTcard inside a*INCLUDEthat was not expanded, or the same part is defined twice. Deduplicate bypart_idand log the collision rather than silently overwriting. part_massnull for most parts. The element blocks use a different keyword variant (e.g.*ELEMENT_SHELL_THICKNESS) that the area resolver does not recognize; extend the resolver's keyword set rather than falling back to zero.
Deliverable: extraction checklist
The following checklist is the acceptance gate for this routine on any new deck. It is intentionally short so it can be run in a few minutes per deck.
| Check | Pass condition | Action on failure |
|---|---|---|
Row count vs. *PART count | Equal | Inspect block splitter for nested keywords |
| Material resolution | No null material_model where material_id is set | Re-index *MAT_* with full keyword line |
| Section resolution | No null section_type where section_id is set | Check *SECTION_* keyword variants |
| Mass positivity | All non-null part_mass > 0 | Check density/thickness units and element block coverage |
| Duplicate IDs | None | Deduplicate and log collisions |
| Title presence | Non-empty for ≥ 95% of parts | Verify title column index in *PART card |
With this routine in place, the output parts_metadata.csv is a stable, deck-independent table that later cases can join against material feature tables and unit-normalized decks without re-parsing the original keyword file.