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.

Always validate a patched deck before submission. A cheap check is to re-read the file, confirm the keyword count matches the original, and confirm every *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.

Deck generation and patching pipeline Parameters sweep.csv thickness, velocity mat_id Template plate_impact.k Jinja2 slots {{ thickness }} Base deck existing .k file *SECTION_SHELL *MAT_024 Render or patch Jinja2 render() or split_blocks() + field rewrite %8.3f formatting Rendered deck runs/t_2.50/deck.k complete *KEYWORD … *END byte-stable Validate keyword count include paths parse-only run Submit ls-dyna i=deck.k manifest.json Manifest dir ↔ parameters join key for results Failure modes line-ending drift column misalignment

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.