3. Python scripting for LS-DYNA automation
LS-DYNA decks are plain-text keyword files, which makes them simultaneously easy to read and dangerous to edit by hand at scale. A parametric study with 40 thickness variants, 12 impact angles, and 3 material cards quickly becomes 1,440 files that must be generated, submitted, monitored, and post-processed consistently. Python is the natural automation layer here: it can parse keyword structure, render templates, patch specific cards, and hand the resulting decks to the solver without ever touching a GUI. This chapter focuses on the concrete mechanics of that layer — which libraries to use, how to read and write decks safely, how to build a template engine that survives real keyword syntax, and how to manipulate decks programmatically without corrupting them.
Choosing the right libraries
The standard library covers most of the work. pathlib handles paths and directory trees portably; re handles keyword-line matching; string.Template or jinja2 handles substitution; subprocess handles solver invocation; csv and json handle parameter sweeps and result summaries. For numerical post-processing, numpy and pandas are the workhorses, and lasso-python (the LS-DYNA API from Ansys) or qd-ldyna style readers can parse d3plot and binout output when you need more than text. Avoid heavyweight parsers unless you actually need them: a 200-line keyword file is faster to patch with regex than to round-trip through a full AST.
A practical rule: use jinja2 when the deck has many variable slots and conditional blocks (e.g., optional contact definitions, optional initial velocity cards); use direct line-level patching when only a handful of numeric fields change. Mixing both is common — render the skeleton with Jinja, then patch specific *MAT_ or *SECTION cards with a targeted function.
Reading and writing decks safely
LS-DYNA keyword files are line-oriented and fixed-format in places. Two rules prevent most corruption: preserve line endings and preserve the exact column layout of fixed-format cards. Read with newline='' and write with the same, and never strip trailing whitespace from lines you did not intend to modify.
from pathlib import Path
def read_deck(path):
p = Path(path)
with p.open("r", newline="") as f:
return f.read().splitlines(keepends=True)
def write_deck(lines, path):
Path(path).write_text("".join(lines), newline="")
For include-heavy decks, resolve *INCLUDE paths relative to the master deck, not the current working directory. A small resolver that walks includes and returns a flat list of (file, line_number, text) tuples makes later patching traceable and lets you report exactly which file a card came from.
Template engines: Jinja2 for parametric decks
Jinja2 is the pragmatic choice for deck templating because it supports loops, conditionals, filters, and custom delimiters. LS-DYNA uses * and $ in keyword names, so set delimiters that do not collide:
from jinja2 import Environment, FileSystemLoader
env = Environment(
loader=FileSystemLoader("templates"),
variable_start_string="{{",
variable_end_string="}}",
block_start_string="{%",
block_end_string="%}",
comment_start_string="{#",
comment_end_string="#}",
keep_trailing_newline=True,
)
tpl = env.get_template("plate_impact.k")
deck = tpl.render(thickness=2.5, velocity=8.0, mat_id=101)
A minimal template for a shell plate with a prescribed initial velocity looks like this:
*KEYWORD
*NODE
{% for n in nodes %}
{{ "%8d%16.6f%16.6f%16.6f"|format(n.id, n.x, n.y, n.z) }}
{% endfor %}
*ELEMENT_SHELL
{% for e in elements %}
{{ "%8d%8d%8d%8d%8d"|format(e.id, e.pid, e.n1, e.n2, e.n3) }}
{% endfor %}
*PART
plate
1 1 1
*SECTION_SHELL
1 2 {{ "%8.3f"|format(thickness) }}
*MAT_PIECEWISE_LINEAR_PLASTICITY
1 7.85e-9 210000.0 0.300
*INITIAL_VELOCITY_GENERATION
1 0 {{ "%8.3f"|format(velocity) }}
Two details matter. First, format numeric fields explicitly with %8.3f or %16.6f so the output stays column-aligned; LS-DYNA tolerates free format in most cards but fixed-format cards (some contact and control cards) will silently misread misaligned fields. Second, keep the template's *KEYWORD header and trailing *END in the template itself, not in Python, so the rendered file is always a complete deck.
Deck manipulation techniques
When you must modify an existing deck rather than render a new one, work at the card level. A robust pattern is to split the deck into blocks keyed by keyword, then operate on the block you care about:
import re
KEYWORD_RE = re.compile(r"^\*(?P<kw>[A-Z_]+)")
def split_blocks(lines):
blocks, current = [], None
for i, line in enumerate(lines):
m = KEYWORD_RE.match(line)
if m:
current = {"keyword": m.group("kw"), "start": i, "lines": [line]}
blocks.append(current)
elif current is not None:
current["lines"].append(line)
return blocks
def patch_section_thickness(blocks, pid, new_t):
for b in blocks:
if b["keyword"] == "SECTION_SHELL":
for j, line in enumerate(b["lines"][1:], start=1):
fields = line.split()
if fields and int(fields[0]) == pid:
fields[2] = f"{new_t:8.3f}"
b["lines"][j] = "".join(f"{f:>8}" for f in fields) + "\n"
return True
return False
This approach keeps the rest of the deck byte-identical, which is exactly what you want for regression comparisons. For *DEFINE_CURVE and *MAT_* cards, the same pattern applies: locate the block, locate the row by ID, rewrite only the changed fields, and re-emit the line with consistent column widths.
*INCLUDE path resolves. A second check is to run ls-dyna i=deck.k memory=20m termination=1 (or the equivalent -termination flag) to let the solver parse the deck and exit; a parse error surfaces in seconds instead of after a long queue wait.End-to-end example: a thickness sweep
Consider a 40-variant sweep over plate thickness from 1.0 mm to 4.0 mm. The driver reads a CSV of parameters, renders each deck from the Jinja template, writes it to runs/t_1.00/deck.k, and records a manifest:
import csv, json
from pathlib import Path
rows = list(csv.DictReader(open("sweep.csv")))
manifest = []
for r in rows:
t = float(r["thickness"])
run_dir = Path("runs") / f"t_{t:.2f}"
run_dir.mkdir(parents=True, exist_ok=True)
deck = tpl.render(thickness=t, velocity=float(r["velocity"]), mat_id=101)
(run_dir / "deck.k").write_text(deck, newline="")
manifest.append({"dir": str(run_dir), "thickness": t,
"velocity": float(r["velocity"])})
Path("manifest.json").write_text(json.dumps(manifest, indent=2))
The manifest is the contract between generation and post-processing: every downstream script reads it, so results are always joined back to the exact parameters that produced them. This is the single most valuable habit in LS-DYNA automation — never infer parameters from directory names alone.
Common failure modes
Three problems account for most broken automated decks. First, line-ending drift: writing with default newline translation on Windows produces \r\n in some files and \n in others, which breaks diff-based regression checks. Second, column misalignment: replacing a field with a shorter string shifts subsequent fields and silently changes values in fixed-format cards. Third, stale includes: a template that references ../materials.k works from one directory and fails from another. Address all three with the read/write helpers above, explicit field formatting, and an include resolver that reports unresolved paths before submission.
The diagram above shows the full path from parameter CSV and template to a validated, submitted deck, with the manifest as the join key for post-processing. The failure-mode box on the left is deliberately placed next to the generation stage, because that is where all three defects originate — not at submission time.
With these primitives in place — safe read/write helpers, a Jinja2 template with explicit numeric formatting, a block-level patcher, and a manifest — you can generate and modify hundreds of LS-DYNA decks reproducibly. The next layer of automation builds on this foundation: submitting those decks to an HPC scheduler, monitoring job state, and collecting results back into the manifest for analysis.