3. Parsing keyword-based simulation input d

Parsing keyword-based simulation input decks 学习卡片

LS-DYNA, Abaqus/Explicit, and similar explicit solvers consume input decks organized as a flat sequence of keyword cards. Each card begins with an asterisk (*), and the data lines that follow belong to that card until the next keyword appears. Before any meaningful crash-simulation analysis can happen — computing intrusion, tracking energy, or extracting section forces — you must first turn this text into structured Python objects. This chapter focuses on the three cards that define the geometry backbone of nearly every model: *NODE, *ELEMENT, and *PART.

Anatomy of a Keyword Card

A keyword card has a header line and zero or more data lines. The header may carry parameters after a comma, and the data lines are typically fixed-width or comma-separated. Consider a minimal fragment:

*NODE
       101,     0.000000,     0.000000,     0.000000
       102,    25.400000,     0.000000,     0.000000
*ELEMENT_SHELL
       201,       101,       102,       104,       103
*PART
front_rail
         1,         1,         1

Three parsing rules govern almost everything:

  • A line whose first non-whitespace character is * starts a new card. Everything until the next such line is that card's body.
  • Blank lines and comment lines (often starting with $) must be skipped, not treated as data.
  • Field values are separated by commas, but LS-DYNA also accepts fixed-width columns. A robust parser should split on commas and strip whitespace, falling back to fixed-width slicing only when needed.

Reading *NODE Cards

The *NODE card maps a node ID to three coordinates. The first field is the integer node ID; the next three are X, Y, Z in the model's length unit. A compact reader stores nodes in a dictionary keyed by ID:

def parse_nodes(lines):
    nodes = {}
    for line in lines:
        if not line.strip() or line.lstrip().startswith(('$', '*')):
            continue
        f = [p.strip() for p in line.split(',')]
        nid = int(f[0])
        nodes[nid] = (float(f[1]), float(f[2]), float(f[3]))
    return nodes

Two practical concerns arise immediately. First, some decks use scientific notation such as 1.0E+01; Python's float() handles this natively, so no special casing is required. Second, node IDs are not guaranteed to be contiguous or sorted, which is exactly why a dictionary — not a list — is the correct container. If you later need spatial queries, convert to a NumPy array once and keep a separate ID-to-index map.

Reading *ELEMENT Cards

Element cards are trickier because the connectivity width depends on the element type. A *ELEMENT_SHELL line carries an element ID followed by four node IDs; a *ELEMENT_SOLID line carries eight. The keyword name itself tells you the expected count:

ELEMENT_WIDTH = {
    'ELEMENT_SHELL': 4,
    'ELEMENT_SOLID': 8,
    'ELEMENT_BEAM': 2,
}

def parse_elements(card_name, lines):
    n = ELEMENT_WIDTH[card_name]
    elems = {}
    for line in lines:
        if not line.strip() or line.lstrip().startswith(('$', '*')):
            continue
        f = [p.strip() for p in line.split(',')]
        eid = int(f[0])
        elems[eid] = tuple(int(x) for x in f[1:1 + n])
    return elems

Note the slice f[1:1+n]: it deliberately ignores trailing fields that some decks append (for example, a part ID or thickness flag on shell cards). Silently dropping extra columns is safer than assuming a fixed total width, because LS-DYNA allows optional trailing fields.

A common failure mode is a shell card written with only three nodes (a degenerate triangle) or a solid card with a missing node. Guard against this by asserting len(connectivity) == n and logging the offending element ID rather than letting a later analysis crash with an opaque KeyError.

Reading *PART Cards

The *PART card links a human-readable part name to a section ID and a material ID. Its structure differs from the previous two: the first data line is often the part title (a string), and the following line holds the numeric fields.

*PART
front_rail
         1,         1,         1

Here part ID 1 uses section 1 and material 1, and its title is front_rail. A parser must therefore treat the title line and the numeric line separately:

def parse_part(lines):
    title = None
    pid = secid = mid = None
    for line in lines:
        s = line.strip()
        if not s or s.startswith(('$', '*')):
            continue
        if title is None and not s[0].isdigit():
            title = s
            continue
        f = [p.strip() for p in s.split(',')]
        pid, secid, mid = int(f[0]), int(f[1]), int(f[2])
    return {'pid': pid, 'title': title, 'secid': secid, 'mid': mid}

The heuristic not s[0].isdigit() distinguishes a title from a numeric line. It is not bulletproof — a title could begin with a digit — so production parsers often rely on the known field count instead. For teaching decks, the heuristic is adequate and easy to reason about.

Putting the Cards Together

Once nodes, elements, and parts are parsed, you can build the relationships that later analysis depends on. The diagram below shows how the three cards connect: nodes feed element connectivity, elements belong to parts, and parts reference sections and materials.

Keyword Card Relationships *NODE id, x, y, z *ELEMENT id, n1..n4/n8 *PART pid, secid, mid connectivity belongs to Section thickness, integration Material density, E, yield references

With these three dictionaries in hand, a typical validation step is to confirm that every node ID referenced by an element actually exists. That single check catches the majority of hand-edited deck errors before they reach the solver.

A Reproducible Parsing Workflow

The following workflow is worth committing to a reusable module, because every later chapter — mass scaling checks, energy extraction, section cuts — will reuse it.

  1. Read the entire deck into memory with open(path).read().splitlines(). Crash decks are rarely larger than a few hundred megabytes, so streaming is unnecessary at this stage.
  2. Walk the lines once, accumulating a list of (card_name, body_lines) tuples. This single pass separates structure from content.
  3. Dispatch each card to its dedicated parser using a dictionary of functions keyed by card name.
  4. After parsing, run cross-reference validation: element-to-node, part-to-section, part-to-material.
def split_cards(lines):
    cards, name, body = [], None, []
    for line in lines:
        if line.lstrip().startswith('*'):
            if name:
                cards.append((name, body))
            name = line.strip().lstrip('*').split(',')[0].upper()
            body = []
        else:
            body.append(line)
    if name:
        cards.append((name, body))
    return cards

Running this on the sample fragment yields three cards: NODE, ELEMENT_SHELL, and PART. Feeding each into its parser produces a node dictionary with two entries, an element dictionary with one four-node shell, and a part record titled front_rail.

Keep the raw card name (for example ELEMENT_SHELL) rather than normalizing it to ELEMENT. The suffix carries the connectivity width, and discarding it forces you to re-inspect the deck later.

Verification and Common Pitfalls

After parsing, print a short summary and compare it against the deck header. A healthy result for the sample looks like: 2 nodes, 1 shell element, 1 part. If the node count is zero, the most likely cause is that comment lines beginning with $ were not skipped. If element connectivity contains a None, a data line was blank but not filtered. If part titles are missing, the title-detection heuristic misclassified a numeric line.

These three cards form the minimum viable model representation. Every downstream extraction task — nodal displacement history, element stress, part-level kinetic energy — resolves back to the IDs parsed here. Getting the parsing layer correct and validated early prevents a whole class of silent errors in the analysis stage.