4. Data extraction from LS-DYNA binary and ASCII outputs

LS-DYNA produces two fundamentally different families of result files, and the way you extract data from each determines how cleanly that data can be loaded into a relational schema. The d3plot family stores time-history state data for the whole model — nodal coordinates, displacements, velocities, stresses, strains, and element state — in a compact binary format. The binout file stores scalar and vector time histories for a curated set of entities: interface forces, rigid wall forces, nodal accelerations, section forces, and similar channels. ASCII outputs such as nodout, elout, glstat, and rcforc carry the same information in text form, which is easier to parse but far larger and slower to read. A robust ingestion pipeline treats these as three distinct source adapters that converge on one normalized staging format.

Understanding the d3plot binary layout

A d3plot file is a sequence of records, each beginning with a control block of 64-bit words. The first control block identifies the file as a d3plot, gives the LS-DYNA version, and encodes the number of nodes, solid elements, beam elements, shell elements, and thick-shell elements. Subsequent control blocks describe the state data: the number of states written, the time value of each state, and the word size (single or double precision). The geometry is written once after the header, followed by one state block per output time. If the model is large, LS-DYNA splits the output across d3plot01, d3plot02, and so on; the family must be read as a single logical dataset.

Because the format is binary and version-dependent, hand-rolled parsers are fragile. The practical approach is to use a maintained reader library. In Python, lasso-python (the LS-DYNA API from DYNAmore/Ansys) exposes d3plot reading directly, and qd from LSTC provides C/Python access. A minimal extraction of nodal displacement history looks like this:

import lasso
from lasso.dyna import D3plot, ArrayType

d3 = D3plot("run_01/d3plot")
times = d3.arrays[ArrayType.global_timesteps]          # shape (n_states,)
node_ids = d3.arrays[ArrayType.node_ids]               # shape (n_nodes,)
disp = d3.arrays[ArrayType.node_displacement]          # (n_states, n_nodes, 3)

# Flatten to long format for SQL staging
import numpy as np
state_idx, node_idx = np.meshgrid(
    np.arange(times.size), np.arange(node_ids.size), indexing="ij")
rows = np.column_stack([
    times[state_idx.ravel()],
    node_ids[node_idx.ravel()],
    disp[..., 0].ravel(),
    disp[..., 1].ravel(),
    disp[..., 2].ravel(),
])

The key engineering decision here is granularity. Writing one row per node per state for a 500,000-node model over 200 states produces 100 million rows — technically loadable but painful to query. Most simulation-result warehouses instead store a reduced set: selected node sets (e.g., a gauge cluster or a bracket mounting face), or aggregated values such as maximum displacement per part per state. Decide the reduction before extraction, because it changes both the staging schema and the load strategy.

Parsing binout channels

The binout file is a directory-like container. Each top-level entry is a "channel group" such as rcforc, nodout, glstat, secforc, or sleout. Under each group are named channels with their own time base. The lasso reader exposes this as a nested dictionary:

from lasso.dyna import Binout

bo = Binout("run_01/binout")
print(bo.read())                       # list channel groups
t = bo.read("rcforc", "time")          # interface force time base
fx = bo.read("rcforc", "x_force")      # per-interface force component
ids = bo.read("rcforc", "ids")         # interface IDs

Two subtleties matter for SQL ingestion. First, channels within a group may share a time base but not always — nodout written at a different output interval than rcforc will have a different length. Never assume a common time vector across groups; carry the time value with each measurement row. Second, binout stores data column-major per entity, so a channel read returns shape (n_times, n_entities). Transpose and flatten deliberately, and record the entity ID alongside each value so the row is self-describing.

ASCII outputs and their parsing traps

ASCII files are line-oriented and human-readable, which makes them tempting to parse with naive splitting. In practice, LS-DYNA ASCII output uses fixed-width columns and occasionally wraps long records. A glstat file, for example, prints a header block followed by rows of energy quantities at each time step. Parsing with pandas.read_fwf against the documented column widths is more reliable than whitespace splitting, because negative numbers and scientific notation can abut adjacent fields.

import pandas as pd

# glstat: skip the variable header, read fixed-width numeric block
gl = pd.read_fwf(
    "run_01/glstat",
    skiprows=header_lines,
    widths=[12, 14, 14, 14, 14, 14],
    names=["time", "kinetic", "internal", "total", "hourglass", "sliding"],
)
gl = gl.dropna(subset=["time"])

Always validate the parsed row count against the number of states reported in the d3plot header or the binout time vector. A mismatch almost always means the header offset was wrong or a wrapped line was dropped — a silent corruption that will surface later as a gap in a time-series query.

Normalizing to a staging schema

Regardless of source, the extracted data should land in a narrow, typed staging table before it reaches the analytical schema. A workable pattern is a single long-format table with explicit provenance:

ColumnTypePurpose
run_idINTForeign key to the simulation run registry
sourceVARCHARd3plot, binout, or ascii
channelVARCHARe.g. node_displacement, rcforc.x_force
entity_idINTNode, element, interface, or part ID
timeDOUBLESimulation time in the model's unit system
componentVARCHARx, y, z, magnitude, or scalar
valueDOUBLEMeasured quantity

This shape is deliberately boring: it accepts every source without schema churn, and it makes the load step a straightforward bulk insert. The cost is row count, so partition the staging table by run_id and load with COPY (PostgreSQL) or LOAD DATA (MySQL) rather than row-by-row inserts. For a 100-million-row d3plot extraction, a CSV intermediate plus COPY ... FROM PROGRAM typically completes in minutes where an ORM insert loop would take hours.

Record the LS-DYNA version, precision (single/double), and unit system in the run registry at extraction time. These are not recoverable from the staging rows alone, and they determine how values must be interpreted downstream.

Extraction flow at a glance

LS-DYNA Output Extraction to SQL Staging d3plot family binary state blocks d3plot, d3plot01… binout channel groups rcforc, nodout, glstat ASCII outputs fixed-width text glstat, nodout, elout Source adapters lasso D3plot lasso Binout pandas read_fwf validate row count vs. state count reduce to node sets or per-part maxima Staging table run_id source / channel entity_id time / component value Bulk load COPY / LOAD DATA partitioned Provenance captured at extraction: LS-DYNA version, precision, unit system, state count — required to interpret values correctly downstream.

Failure symptoms and checks

Three failure modes account for most bad extractions. A state-count mismatch between the d3plot header and the parsed time vector usually means a split d3plot family was read only partially — check that every d3plotNN file is present and passed to the reader. A unit inconsistency shows up as physically implausible magnitudes (displacements in the thousands when the model is in millimeters and the expected peak is a few millimeters); confirm the unit system recorded in the run registry against the model's *CONTROL_UNITS or the keyword deck. A silent truncation in ASCII parsing appears as a time series that stops early or has a gap; compare the last parsed time against the final state time in the d3plot header, and if they differ, re-inspect the header offset and any wrapped lines near the truncation point.

With extraction validated and staged, the data is in a uniform long-format shape that the analytical schema in the following chapters can consume without further source-specific logic. The extraction layer's job is to be boring, repeatable, and provenance-complete — every downstream query depends on it.