3. Core Python for CAE automation
Automotive CAE workflows rarely fail because the solver is wrong. They fail because a script cannot find the deck, reads the wrong load case, or silently parses a keyword it does not understand. Before any automation can be trusted, the tool must reliably answer three questions: Where is the file? What is inside it? How do I turn that content into structured data I can act on? This chapter builds that foundation using only the Python standard library, so the resulting utilities run identically on a Windows workstation, a Linux HPC login node, and inside a batch scheduler job.
Path handling with pathlib: stop concatenating strings
The classic failure mode is building paths with "C:/projects/" + name + "/model.k". This breaks on mixed separators, on trailing slashes, and on case-sensitive filesystems. pathlib.Path treats a path as an object with operators, not as text.
from pathlib import Path
root = Path(r"D:\CAE\projects\BIW_2024")
case = root / "crash" / "frontal_50kph"
deck = case / "model.k"
print(deck.name) # model.k
print(deck.stem) # model
print(deck.suffix) # .k
print(deck.parent) # ...\crash\frontal_50kph
print(deck.exists()) # True / False
Three habits pay off immediately. First, always resolve the project root once and derive everything from it, so a script never depends on the current working directory. Second, use Path.glob() or Path.rglob() instead of shelling out to dir or ls:
for d3plot in case.rglob("d3plot*"):
print(d3plot.name, d3plot.stat().st_size)
Third, when a path must be handed to a solver or a subprocess, convert it explicitly with str(deck) and quote it. On Windows, Path normalizes separators automatically; on Linux, remember that BIW_2024 and biw_2024 are different directories, so never assume case-insensitive matching in a cross-platform tool.
Path(__file__).resolve().parent to anchor a script to its own location. This makes the tool relocatable and removes an entire class of "works on my machine" bugs.Reading and writing text safely
CAE input decks are text, but they are not always UTF-8. Legacy LS-DYNA decks, Nastran bulk data, and solver logs frequently contain Latin-1 characters or Windows line endings. Always specify the encoding and handle newlines explicitly.
with open(deck, "r", encoding="utf-8", errors="replace") as f:
lines = f.readlines()
with open(case / "summary.txt", "w", encoding="utf-8", newline="\n") as f:
f.write("case,status\n")
f.write(f"{case.name},parsed\n")
The with block guarantees the file handle is closed even if parsing raises. errors="replace" prevents a single stray byte from aborting a 200 MB log scan. For very large files, iterate line by line rather than calling readlines(), which loads everything into memory:
with open(logfile, "r", encoding="utf-8", errors="replace") as f:
for lineno, line in enumerate(f, start=1):
if "ERROR" in line:
print(lineno, line.rstrip())
Parsing structured data: three tiers
Not all structured data deserves the same tool. Choose the lightest parser that fits the format.
| Format | Typical CAE use | Recommended approach |
|---|---|---|
| CSV / TSV | Result curves, DOE matrices, sensor channels | csv.DictReader |
| JSON / YAML | Tool configuration, case manifests | json; yaml.safe_load if available |
| Fixed-column text | Nastran BDF, LS-DYNA keyword cards | Manual slicing by column index |
For CSV, DictReader gives named access and survives column reordering:
import csv
with open(case / "curves.csv", newline="", encoding="utf-8") as f:
reader = csv.DictReader(f)
peak = max(float(row["force_kN"]) for row in reader)
print(f"peak force = {peak:.1f} kN")
For configuration, JSON is the safest default because it is in the standard library and has no ambiguity about types:
import json
with open(root / "config.json", encoding="utf-8") as f:
cfg = json.load(f)
solver = cfg["solver"]["executable"]
ncpu = cfg.get("solver", {}).get("ncpu", 4)
Fixed-column formats are where most CAE parsing effort goes. A Nastran GRID entry, for example, places the node ID in columns 9–16 and coordinates in 24–31, 32–39, and 40–47. Python string slicing is one-based in the spec but zero-based in code, so subtract one:
def parse_grid(line):
# columns: 1-8 card, 9-16 nid, 24-31 x, 32-39 y, 40-47 z
nid = int(line[8:16])
x = float(line[23:31])
y = float(line[31:39])
z = float(line[39:47])
return nid, (x, y, z)
Two defensive rules apply to every parser. First, strip and skip blank or comment lines before slicing, because a short line will raise IndexError or, worse, silently produce a wrong value. Second, validate the card name before interpreting the rest of the line:
for line in lines:
if not line.strip() or line.lstrip().startswith("$"):
continue
card = line[:8].strip().upper()
if card == "GRID":
nodes.append(parse_grid(line))
From parsed records to a usable data model
Raw tuples become unmaintainable once a tool grows. Promote them to a dataclass, which gives named fields, type hints, and a readable repr at almost no cost:
from dataclasses import dataclass
@dataclass
class Node:
nid: int
x: float
y: float
z: float
nodes = [Node(*parse_grid(line)) for line in grid_lines]
With a list of Node objects, downstream logic reads naturally: compute a bounding box, find nodes inside a region of interest, or export a subset back to a deck. This is the point where file I/O stops being plumbing and starts being an engineering tool.
Putting it together: a minimal deck inventory tool
The following script scans a project tree, finds every LS-DYNA keyword deck, counts the *NODE entries, and writes a CSV inventory. It is short enough to read in one pass and demonstrates every technique above.
from pathlib import Path
import csv
def count_nodes(deck: Path) -> int:
count = 0
in_node_block = False
with open(deck, "r", encoding="utf-8", errors="replace") as f:
for line in f:
if line.startswith("*"):
in_node_block = line.strip().upper().startswith("*NODE")
continue
if in_node_block and line.strip():
count += 1
return count
root = Path(r"D:\CAE\projects\BIW_2024")
rows = []
for deck in root.rglob("*.k"):
rows.append({"deck": str(deck.relative_to(root)),
"nodes": count_nodes(deck)})
with open(root / "inventory.csv", "w", encoding="utf-8", newline="") as f:
writer = csv.DictWriter(f, fieldnames=["deck", "nodes"])
writer.writeheader()
writer.writerows(rows)
Run it against a known case and verify the node count against the solver's own echo in the .out file. If the numbers disagree, the usual causes are a *NODE block terminated by a blank line that the parser treats as data, or a deck that uses *INCLUDE to pull in nodes from a separate file. Both are real conditions in production models and both must be handled explicitly rather than assumed away.
rglob unless you exclude them. A tool that writes inventory.csv and then re-scans will pick up its own output on the next run and produce duplicated or corrupted results.What to carry forward
Path handling, safe text I/O, and format-appropriate parsing are the three load-bearing skills for every CAE automation tool that follows. Use pathlib for all path construction, always declare encoding and error handling on open(), choose the lightest parser that matches the format, and promote parsed records into dataclasses before writing any engineering logic. When a parser fails, the failure is almost always at a boundary — a missing file, an unexpected encoding, a short line, or an off-by-one column slice — so validate at those boundaries and let the error surface with enough context to diagnose it.