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.

Use 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.

FormatTypical CAE useRecommended approach
CSV / TSVResult curves, DOE matrices, sensor channelscsv.DictReader
JSON / YAMLTool configuration, case manifestsjson; yaml.safe_load if available
Fixed-column textNastran BDF, LS-DYNA keyword cardsManual 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.

CAE File I/O and Parsing Pipeline 1. Path Resolution Path(root) / case .rglob("*.k") .exists() check → absolute Path objects 2. Safe Read open(..., encoding=) errors="replace" line-by-line iterate → raw text lines 3. Parse skip blank / $ lines card = line[:8].strip() column slicing → typed records 4. Data Model @dataclass Node list[Node] bounding box → engineering logic Common Failure Points • FileNotFoundError → wrong root or case name • UnicodeDecodeError → missing encoding= argument • IndexError on short line → no blank-line guard • Silent wrong value → off-by-one column slice

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.

Never write output files into the same directory you are scanning with 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.