4. Parsing and modifying LS-DYNA keyword files with Python
LS-DYNA input decks are not a single file but a tree of keyword files stitched together by *INCLUDE statements, parameterized by *PARAMETER definitions, and abstracted through *DEFINE cards such as *DEFINE_CURVE, *DEFINE_TABLE, and *DEFINE_FUNCTION. For a DOE workflow, the Python layer must be able to read this structure reliably, resolve what a given card actually means at solve time, and write back modified variants without corrupting fixed-format fields. This section covers the parsing model, the three card families that matter most for DOE, and a practical editing pattern.
The keyword file as a record stream
A keyword file is a sequence of cards. Each card begins with a * keyword line (case-insensitive, up to 80 characters), optionally followed by a title line, then one or more fixed-format data lines. The critical constraint is that LS-DYNA data lines are column-position sensitive: a field occupying columns 11–20 is a different field from one occupying columns 21–30, even if both contain the same digits. Any parser that splits on whitespace and rejoins with single spaces will silently shift fields and produce decks that run but compute the wrong thing.
The robust approach is to preserve each data line as a raw string and only slice it when a field is actually needed. A minimal record model looks like this:
from dataclasses import dataclass, field
@dataclass
class Card:
keyword: str # e.g. "*DEFINE_CURVE"
title: str = "" # optional title line
lines: list[str] = field(default_factory=list) # raw data lines
def fields(self, line_index: int, width: int = 10, count: int = 8):
line = self.lines[line_index].ljust(width * count)
return [line[i*width:(i+1)*width] for i in range(count)]
Reading the deck is then a small state machine: a line starting with * closes the current card and opens a new one; every other line is appended to the current card. Blank lines and comment lines beginning with $ are dropped or retained separately, but never merged into data lines.
Resolving *INCLUDE_TRANSFORM
*INCLUDE_TRANSFORM is the DOE engineer's friend and the parser's main complication. It pulls in an external file and applies a rigid-body transform, an ID offset, and optionally a parameter substitution to everything inside it. A typical card looks like:
*INCLUDE_TRANSFORM
$ filename
bumper_left.key
$ IDOFF IDOFF2 IDOFF3 ...
1000000
$ TX TY TZ RX RY RZ ...
0.0 0.0 0.0 0.0 0.0 0.0
For DOE purposes you rarely want to rewrite the included file itself. Instead, treat the include as a node in a dependency graph: record the resolved path (relative to the parent deck), the ID offsets, and the transform. When a design variable changes the position of the bumper, you regenerate only the transform line and leave the included geometry untouched. This keeps variants small and diffable.
Reading and overriding *PARAMETER
*PARAMETER cards define named scalars and expressions that other cards reference as &name. They are the natural hook for DOE: instead of editing dozens of cards, you override a handful of parameters. Parsing them requires handling two forms — local parameters (*PARAMETER) and global parameters (*PARAMETER_GLOBAL) — and evaluating expressions that may reference earlier parameters.
*PARAMETER
thickness 0.0012
velocity 15.0
mass_scale &thickness*7800.0
A safe evaluator resolves references in definition order and refuses to evaluate anything containing function calls or conditionals unless you explicitly allow it. For DOE, the common pattern is to parse the parameter block into a dictionary, apply the design-variable overrides, then re-emit the block with the same names and formatting. Never reorder parameters: later expressions depend on earlier definitions.
eval() on parameter expressions from an untrusted deck. A deck is executable input. Restrict evaluation to arithmetic on already-resolved names, or use a small AST walker that permits only BinOp, Num, and Name nodes.Handling *DEFINE cards
*DEFINE_CURVE and *DEFINE_TABLE carry the material and loading data that DOE most often perturbs. A curve card has a header line with LCID, SIDR, SFA, SFO, and offsets, followed by abscissa/ordinate pairs. The parser must keep the header fields separate from the point list, because a DOE sweep typically scales SFA/SFO or replaces the point list wholesale.
*DEFINE_CURVE
$ lcid sidr sfa sfo offa offo
101 0 1.0 1.0 0.0 0.0
$ abscissa ordinate
0.0 0.0
0.001 250.0
0.002 310.0
When you modify a curve, write the header back with the original column widths and only the changed numeric fields replaced. A helper that formats a float into a fixed-width field (right-justified, 10 characters, scientific notation when needed) prevents the most common corruption: a value like 1.23456789e-05 overflowing its column and pushing the next field.
An end-to-end editing pattern
The workflow that scales to a DOE campaign is: parse once into a card list, build an index from keyword to card positions, apply a set of named mutations, and serialize. The mutations are the design variables. For example, a sweep over bumper thickness and impact velocity becomes:
deck = parse_keyword_file("main.key")
deck.set_parameter("thickness", 0.0014)
deck.set_parameter("velocity", 18.0)
deck.scale_curve(lcid=101, sfa=1.1)
deck.write("run_0042/main.key")
Each mutation records what it changed so the DOE driver can log the exact variant. After writing, re-parse the output and assert that the card count, keyword sequence, and parameter dictionary match expectations. This round-trip check catches field-shift bugs before they reach the solver.
Failure symptoms and how to read them
Three symptoms dominate when this pipeline goes wrong. First, the solver terminates with a keyword error pointing at a card you never touched — almost always a field shift caused by a value overflowing its column during serialization. Second, the run completes but results are identical across variants — the mutation wrote to a card that was later overridden by a *PARAMETER reference, or the include was resolved from the wrong directory and the original file was used. Third, parameter expressions evaluate to zero — a reference was resolved before its definition because the parameter block was reordered. Each of these is caught by the round-trip check and by logging the resolved parameter dictionary alongside every variant.
Keep the parser deliberately dumb: it should not interpret physics, only structure. Keep the mutation layer deliberately explicit: every change is a named operation with a logged before/after value. Between those two rules, the deck stays auditable, the DOE variants stay reproducible, and the injury metrics extracted downstream can be traced back to a specific, verifiable input file.