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.

ColumnSourceTypeNotes
part_id*PART PIDintPrimary key, unique per deck
part_name*PART titlestrTrimmed; empty string if absent
material_id*PART MIDintResolved to material card
material_model*MAT_* keywordstre.g. MAT_024
density*MAT_* ROfloatDeck units, unmodified
section_id*PART SECIDintResolved to section card
section_type*SECTION_* keywordstre.g. SECTION_SHELL
thickness*SECTION_* T1floatShell thickness; null for solids
part_masscomputed or *ELEMENT_MASSfloatSee 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.

Keep the deck's native units in the output. Unit normalization is a separate, later concern; mixing it into extraction makes the routine non-reproducible across decks that use different unit systems.

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.

Part metadata resolution path *PART PID = 1001 MID = 24 SECID = 7 title = "B-Pillar" *MAT_024 (MID 24) RO = 7.85e-9 E = 210000 model = MAT_024 *SECTION_SHELL (SECID 7) T1 = 1.2 type = SECTION_SHELL area = 0.42 (from elements) Normalized row part_id = 1001 part_name = "B-Pillar" material_model = MAT_024 density = 7.85e-9 section_type = SECTION_SHELL thickness = 1.2 part_mass = 3.96e-3 Mass resolution (shell case) part_mass = density × thickness × Σ(element areas) = 7.85e-9 × 1.2 × 0.42 = 3.96e-3 (deck units) If no element block or *ELEMENT_MASS exists → leave null, flag row

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_name values empty. The title field is not at column 8 of the *PART card. Some decks place the title on a continuation line; check the card layout before assuming the field index.
  • material_model shows MAT_ 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_id rows. The deck contains a *PART card inside a *INCLUDE that was not expanded, or the same part is defined twice. Deduplicate by part_id and log the collision rather than silently overwriting.
  • part_mass null 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.
Do not fill missing masses with zero. A zero mass silently corrupts any inertia-based feature and is indistinguishable from a genuinely massless part. Null is the correct sentinel, and downstream feature engineering should handle it explicitly.

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.

CheckPass conditionAction on failure
Row count vs. *PART countEqualInspect block splitter for nested keywords
Material resolutionNo null material_model where material_id is setRe-index *MAT_* with full keyword line
Section resolutionNo null section_type where section_id is setCheck *SECTION_* keyword variants
Mass positivityAll non-null part_mass > 0Check density/thickness units and element block coverage
Duplicate IDsNoneDeduplicate and log collisions
Title presenceNon-empty for ≥ 95% of partsVerify 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.