2. Relational database fundamentals for simulation data

An LS-DYNA run produces a heterogeneous collection of entities: parts, nodes, solid and shell elements, time steps, and the result fields sampled at each step. Storing these in flat CSV files works for a single run but collapses quickly once you need to compare variants, trace a node across thousands of states, or answer "which elements belong to the part that failed?" A relational model gives you a stable structure for those questions. This section maps LS-DYNA entities onto tables, keys, and relationships, and shows where normalization helps and where deliberate denormalization is the right engineering call.

From keyword deck to table structure

The LS-DYNA keyword deck already implies a schema. *PART defines a part with a part ID and a section/ material reference; *NODE lists node IDs and coordinates; *ELEMENT_SOLID and *ELEMENT_SHELL connect element IDs to their node connectivity and part ID; *DATABASE_BINARY_D3PLOT controls how often state data is written. A relational schema should mirror these boundaries rather than flatten them. A practical starting point:

CREATE TABLE run (
  run_id        INTEGER PRIMARY KEY,
  case_name     TEXT NOT NULL,
  solver_version TEXT,
  started_at    TIMESTAMP
);

CREATE TABLE part (
  run_id   INTEGER NOT NULL REFERENCES run(run_id),
  part_id  INTEGER NOT NULL,
  title    TEXT,
  material TEXT,
  PRIMARY KEY (run_id, part_id)
);

CREATE TABLE node (
  run_id  INTEGER NOT NULL REFERENCES run(run_id),
  node_id INTEGER NOT NULL,
  x REAL, y REAL, z REAL,
  PRIMARY KEY (run_id, node_id)
);

CREATE TABLE element (
  run_id     INTEGER NOT NULL REFERENCES run(run_id),
  elem_id    INTEGER NOT NULL,
  part_id    INTEGER NOT NULL,
  elem_type  TEXT NOT NULL,          -- 'solid', 'shell', 'beam'
  PRIMARY KEY (run_id, elem_id),
  FOREIGN KEY (run_id, part_id) REFERENCES part(run_id, part_id)
);

CREATE TABLE state (
  run_id    INTEGER NOT NULL REFERENCES run(run_id),
  state_no  INTEGER NOT NULL,        -- d3plot state index
  sim_time  REAL NOT NULL,           -- simulation time, seconds
  PRIMARY KEY (run_id, state_no)
);

Two design decisions deserve attention. First, every entity table carries run_id as part of its primary key. LS-DYNA IDs are only unique within a deck; the same part_id = 100 can appear in a baseline and a modified run with different geometry. A composite key (run_id, part_id) preserves that distinction and makes cross-run comparison a simple join predicate. Second, the element table stores part_id as a foreign key rather than duplicating part metadata. This is the first normal form decision: part title and material live once in part, and every element that references it stays consistent when the part definition changes.

Keys and relationships in practice

The schema above encodes three relationship types that matter for simulation data:

  • One-to-many (run → part, run → node, run → state). A run owns many parts, nodes, and states. The foreign key on the child table enforces that no orphan part can exist without a parent run.
  • One-to-many (part → element). Each element belongs to exactly one part; a part contains many elements. This is the join path used to aggregate stress or erosion by part.
  • Many-to-many (element ↔ node). A solid element references 8 nodes; a node is shared by every element around it. This cannot be modeled with a single foreign key. The standard solution is a connectivity table:
CREATE TABLE element_node (
  run_id   INTEGER NOT NULL,
  elem_id  INTEGER NOT NULL,
  node_id  INTEGER NOT NULL,
  local_idx INTEGER NOT NULL,        -- 1..8 for hex, 1..4 for shell
  PRIMARY KEY (run_id, elem_id, local_idx),
  FOREIGN KEY (run_id, elem_id) REFERENCES element(run_id, elem_id),
  FOREIGN KEY (run_id, node_id) REFERENCES node(run_id, node_id)
);

The local_idx column is not cosmetic. LS-DYNA element connectivity is ordered: node 1 through 4 form the bottom face of a hex, 5 through 8 the top. Preserving that order lets you reconstruct element geometry, compute face normals, or map integration-point results back to nodes without guessing. Dropping local_idx and storing an unordered set of node IDs destroys information the solver relied on.

LS-DYNA Entity Schema: Keys and Relationships run PK run_id case_name solver_version part PK (run_id, part_id) title, material FK run_id → run element PK (run_id, elem_id) elem_type FK (run_id, part_id) → part node PK (run_id, node_id) x, y, z FK run_id → run element_node PK (run_id, elem_id, local_idx) FK elem_id → element FK node_id → node state PK (run_id, state_no) sim_time FK run_id → run 1 : N 1 : N 1 : N 1 : N N : M via element_node local_idx preserves order

Normalization applied to result data

Time-history results are where naive schemas break down. A tempting design is one wide table with a column per state: node_id, x, y, z, disp_x_001, disp_y_001, ..., stress_100. This violates first normal form (repeating groups) and makes every new output interval a schema migration. The normalized alternative separates the state definition from the measurement:

CREATE TABLE node_result (
  run_id    INTEGER NOT NULL,
  node_id   INTEGER NOT NULL,
  state_no  INTEGER NOT NULL,
  disp_x    REAL, disp_y REAL, disp_z REAL,
  von_mises REAL,
  PRIMARY KEY (run_id, node_id, state_no),
  FOREIGN KEY (run_id, node_id) REFERENCES node(run_id, node_id),
  FOREIGN KEY (run_id, state_no) REFERENCES state(run_id, state_no)
);

This is third normal form: sim_time depends only on (run_id, state_no) and lives in state, not repeated on every node row. The storage saving is substantial — a 200,000-node model with 500 states would otherwise carry the timestamp 100 million times. More importantly, a query like "peak von Mises per part over time" becomes a clean three-table join:

SELECT p.part_id, p.title, MAX(r.von_mises) AS peak_vm
FROM node_result r
JOIN element_node en ON en.run_id = r.run_id AND en.node_id = r.node_id
JOIN element e       ON e.run_id = en.run_id AND e.elem_id = en.elem_id
JOIN part p          ON p.run_id = e.run_id AND p.part_id = e.part_id
WHERE r.run_id = 1
GROUP BY p.part_id, p.title
ORDER BY peak_vm DESC;

Where to denormalize deliberately

Normalization is a default, not a dogma. Two cases justify stepping away from it. First, element-level results (effective plastic strain, erosion status) are often stored per element per state; if you query them constantly for contour playback, a materialized summary table keyed by (run_id, part_id, state_no) holding max/mean values avoids scanning the full result table on every frame. Second, connectivity is read far more often than written; keeping element_node as a narrow, index-only table with a clustered primary key on (run_id, elem_id, local_idx) is itself a performance-oriented choice that does not compromise correctness. The rule of thumb: normalize the source of truth, denormalize derived aggregates that have a clear refresh policy.

When importing a d3plot or binout export, load run, part, node, element, and element_node first, then bulk-insert state and node_result. Deferring foreign key checks during the bulk load and validating afterward catches connectivity errors — such as an element referencing a node ID absent from *NODE — before they silently corrupt aggregate queries.
Do not use LS-DYNA IDs as global primary keys. Part 100 in a baseline run and part 100 in a thickness-modified run are different physical objects. Always scope entity keys by run_id; otherwise a join across runs will merge unrelated geometry and produce plausible but wrong peak values.

The schema in this section is the foundation for everything that follows: result ingestion, cross-run comparison, and query optimization all assume that entities are keyed correctly and that relationships are enforced rather than implied. Getting the keys and the many-to-many connectivity right at the start is cheaper than repairing a flattened export later.