4. Programmatic generation of LS-DYNA input decks
Hand-editing a *KEYWORD deck for every design variant is slow, error-prone, and impossible to audit. A parametric study with 40 runs, each differing only in shell thickness and material curve, should not require 40 manual edits of the same file. This chapter shows how to build a Python generator that turns a single Jinja2 template plus a parameter set into a complete, syntactically valid LS-DYNA input deck.
Why Jinja2 Fits Keyword Files
LS-DYNA keyword files are line-oriented, fixed-format in places, and heavily repetitive: the same *PART, *SECTION_SHELL, and *MAT_* blocks repeat with different IDs and values. Jinja2 gives you three primitives that map directly onto this structure:
- Variable substitution —
{{ thickness }}replaces a literal value. - Loops —
{% for part in parts %}emits one keyword block per part. - Conditionals —
{% if contact_enabled %}includes or omits an entire keyword card.
The critical discipline is that the template must remain a valid LS-DYNA deck when rendered with a known-good parameter set. Treat the template as the single source of truth for card structure; treat the parameter file as the only thing that changes between runs.
Template Structure and Card Alignment
LS-DYNA is sensitive to column positions on many cards. When you substitute a value, the rendered line must still satisfy the field widths. The safest approach is to keep the template's whitespace fixed and substitute only the numeric token, letting Python format the value to the correct width before rendering.
*KEYWORD
*TITLE
{{ title }}
*CONTROL_TERMINATION
{{ "%10.3f"|format(termination_time) }}
*CONTROL_TIMESTEP
{{ "%10.3f"|format(dt_init) }} {{ "%10.3f"|format(tssfac) }}
*DATABASE_BINARY_D3PLOT
{{ "%10.3f"|format(d3plot_interval) }}
*PART
part_{{ part.id }}
{{ "%10d"|format(part.secid) }} {{ "%10d"|format(part.mid) }} {{ "%10d"|format(part.eosid) }}
*SECTION_SHELL
{{ "%10d"|format(part.secid) }}
{{ "%10d"|format(part.elform) }} {{ "%10.3f"|format(part.shear) }} {{ "%10.3f"|format(part.nip) }} {{ "%10.3f"|format(part.propt) }}
{{ "%10.3f"|format(part.t1) }} {{ "%10.3f"|format(part.t2) }} {{ "%10.3f"|format(part.t3) }} {{ "%10.3f"|format(part.t4) }}
*MAT_PIECEWISE_LINEAR_PLASTICITY
{{ "%10d"|format(part.mid) }} {{ "%10.3f"|format(part.rho) }} {{ "%10.3f"|format(part.e) }} {{ "%10.3f"|format(part.pr) }} {{ "%10.3f"|format(part.sigy) }} {{ "%10.3f"|format(part.etan) }} {{ "%10.3f"|format(part.fail) }} {{ "%10.3f"|format(part.tdel) }}
{% for lc in part.lc %}{{ "%10.3f"|format(lc.strain) }} {{ "%10.3f"|format(lc.stress) }}
{% endfor %}{% endfor %}
*END
Note the use of |format(...) filters rather than raw {{ value }}. This guarantees that a thickness of 1.2 renders as 1.200 and not 1.2, which would shift every subsequent field on the card. This single habit eliminates the most common class of generated-deck errors.
Parameter Substitution from a Data Source
Parameters should live outside the template. A YAML or JSON file is the natural choice because it is human-readable and diff-friendly. The generator loads it, validates required keys, and passes the resulting dictionary into template.render().
import yaml
from jinja2 import Environment, FileSystemLoader
env = Environment(
loader=FileSystemLoader("templates"),
trim_blocks=True,
lstrip_blocks=True,
)
tmpl = env.get_template("deck.j2")
with open("params/run_017.yaml") as f:
params = yaml.safe_load(f)
deck = tmpl.render(**params)
with open("runs/run_017/main.k", "w") as f:
f.write(deck)
The trim_blocks and lstrip_blocks options are not cosmetic. Without them, Jinja2 leaves blank lines where control tags appear, and LS-DYNA will reject a deck containing stray empty lines between keyword cards. Enable both from the start.
Conditional Keyword Blocks
Real studies toggle features: a contact definition, an initial velocity card, a *MAT_ADD_EROSION block, or a restart segment. Conditionals keep one template serving many configurations.
{% if contact.enabled %}
*CONTACT_AUTOMATIC_SINGLE_SURFACE
{{ "%10d"|format(contact.ssid) }} {{ "%10d"|format(contact.msid) }} {{ "%10d"|format(contact.sstyp) }} {{ "%10d"|format(contact.mstyp) }}
{{ "%10.3f"|format(contact.fs) }} {{ "%10.3f"|format(contact.fd) }} {{ "%10.3f"|format(contact.dc) }} {{ "%10.3f"|format(contact.vc) }}
{% endif %}
{% if initial_velocity is defined %}
*INITIAL_VELOCITY_GENERATION
{{ "%10d"|format(initial_velocity.nsid) }} {{ "%10d"|format(initial_velocity.styp) }} {{ "%10.3f"|format(initial_velocity.omega) }} {{ "%10.3f"|format(initial_velocity.vx) }} {{ "%10.3f"|format(initial_velocity.vy) }} {{ "%10.3f"|format(initial_velocity.vz) }}
{% endif %}
Use is defined for optional blocks and an explicit boolean for feature toggles. Mixing the two — for example, relying on a missing key to disable contact — makes the parameter file ambiguous and hard to validate.
render(). A generator that silently emits a deck with thickness = 0 wastes an entire solver run.
Workflow Overview
Verifying the Generated Deck
Never submit a generated deck directly to the solver without a structural check. Three verification layers catch nearly all generator bugs:
- Keyword balance. Count
*KEYWORDand*ENDoccurrences; the deck must open and close exactly once. A missing*ENDusually means a conditional swallowed the terminator. - Card field count. For each keyword block, confirm the number of whitespace-separated tokens per line matches the expected card width. A shifted field is the signature of a formatting filter that was omitted.
- Diff against a reference. Render the template with a known-good parameter set and diff the output against a hand-verified deck. Any difference is either an intended parameter change or a template defect — there is no third possibility.
trim_blocks/lstrip_blocks first, then verify every numeric substitution uses a width-preserving format filter.
Practical Deliverable: Parameter Schema
The following schema documents the minimum parameter set for the template above. It doubles as the validator's contract and as the interface for anyone running the study.
| Key | Type | Constraint | Maps to |
|---|---|---|---|
title | string | non-empty | *TITLE |
termination_time | float | > 0 | *CONTROL_TERMINATION |
dt_init | float | > 0 | *CONTROL_TIMESTEP |
d3plot_interval | float | > 0 | *DATABASE_BINARY_D3PLOT |
parts[].id | int | unique | *PART name |
parts[].t1..t4 | float | > 0 | *SECTION_SHELL |
parts[].lc[] | list | strain ascending | *MAT_PIECEWISE_LINEAR_PLASTICITY |
contact.enabled | bool | — | conditional block |
initial_velocity | object | optional | conditional block |
With this structure in place, generating 40 variants is a loop over 40 YAML files. The template stays fixed, the parameter files are the experimental record, and every generated deck is reproducible from its inputs. That reproducibility is the real payoff: when a run produces an unexpected result, you can regenerate the exact deck that was solved and inspect it line by line.