3. Schema design for LS-DYNA results
An LS-DYNA run produces several distinct result families, and each has a different natural shape: time histories are narrow and long (one value per state per entity), nodal and elemental fields are wide and dense (many components per node or element per state), and material state data is sparse and irregular (only certain elements, only certain integration points, only when a material model exposes it). A schema that treats all three as "results in a table" will either explode in row count or collapse into unqueryable blobs. The goal of this chapter is a normalized core with deliberate denormalization at the hot path, keyed so that the most common access pattern — "give me channel X for entity Y across all states" — is a single index range scan.
Establishing the identity model first
Before any result table exists, fix the identity of the simulation and its discretization. LS-DYNA identifiers are stable within a run but not across runs, and node/element IDs are user-assigned, so they must never be used as surrogate keys. Use a simulation table with a surrogate simulation_id, and a mesh_version child that captures the discretization (node count, element count, LS-DYNA version, keyword deck hash). Result rows reference mesh_version_id, not simulation_id, because a mesh can be re-generated while the load case stays the same — and mixing results from two meshes under one key is the most common source of silently wrong plots.
CREATE TABLE simulation (
simulation_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name TEXT NOT NULL,
dyna_version TEXT NOT NULL,
deck_sha256 CHAR(64) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE mesh_version (
mesh_version_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
simulation_id BIGINT NOT NULL REFERENCES simulation(simulation_id),
node_count INTEGER NOT NULL,
elem_count INTEGER NOT NULL,
UNIQUE (simulation_id, node_count, elem_count)
);
Time: one state table, referenced by everything
Every result family shares the same time axis, so store it once. LS-DYNA writes state times in the d3plot control block; those times are the canonical states. A state table keyed by (mesh_version_id, state_index) with the physical time_value lets every downstream table join on a compact integer instead of a float, which matters because float equality joins on time are both slow and fragile.
CREATE TABLE state (
mesh_version_id BIGINT NOT NULL REFERENCES mesh_version(mesh_version_id),
state_index INTEGER NOT NULL, -- 0-based, as written by LS-DYNA
time_value DOUBLE PRECISION NOT NULL, -- simulation time, seconds
PRIMARY KEY (mesh_version_id, state_index)
);
Keep state_index as the join key and time_value as a lookup attribute. Queries that need "the state nearest t = 0.012 s" resolve the index once via a small index on (mesh_version_id, time_value), then use the integer everywhere else.
Time histories: narrow, keyed by channel
Nodal time histories (from *DATABASE_HISTORY_NODE) and elemental histories (from *DATABASE_HISTORY_SOLID, _SHELL, _BEAM) are best modeled as a channel registry plus a narrow fact table. The registry holds one row per (entity, quantity, component), so a node's X displacement and its resultant velocity are separate channels with separate IDs. The fact table is then (channel_id, state_index, value) — three columns, no nullable component columns, and a primary key that is exactly the access path.
CREATE TABLE history_channel (
channel_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
mesh_version_id BIGINT NOT NULL REFERENCES mesh_version(mesh_version_id),
entity_kind TEXT NOT NULL CHECK (entity_kind IN ('NODE','SOLID','SHELL','BEAM')),
entity_id INTEGER NOT NULL, -- LS-DYNA node/element ID
quantity TEXT NOT NULL, -- 'displacement','velocity','stress'
component TEXT NOT NULL, -- 'x','y','z','von_mises','ip1_xx'
UNIQUE (mesh_version_id, entity_kind, entity_id, quantity, component)
);
CREATE TABLE history_value (
channel_id BIGINT NOT NULL REFERENCES history_channel(channel_id),
state_index INTEGER NOT NULL,
value DOUBLE PRECISION NOT NULL,
PRIMARY KEY (channel_id, state_index)
);
The primary key (channel_id, state_index) is a clustered range: fetching one channel's full history is a single sequential read. For a run with 500 states and 200 channels, that is 100,000 rows — trivial. The same data stored as one row per state with 200 columns would be 500 rows of 200 floats, which is faster to read whole but impossible to extend when a new quantity appears. The narrow model wins because LS-DYNA result sets grow by adding channels, not by adding states.
Nodal and elemental fields: state-partitioned wide rows
Full-field output (*DATABASE_NODOUT, *DATABASE_ELOUT) is the opposite case: every node has every component at every state, and the row count is nodes × states. Here the narrow model is a liability — 2 million nodes × 500 states × 6 components is 6 billion rows. Store one row per (entity, state) with the components as columns, and partition by state_index range so that a time-window query touches only the relevant partitions.
CREATE TABLE nodal_field (
mesh_version_id BIGINT NOT NULL,
state_index INTEGER NOT NULL,
node_id INTEGER NOT NULL,
ux DOUBLE PRECISION, uy DOUBLE PRECISION, uz DOUBLE PRECISION,
vx DOUBLE PRECISION, vy DOUBLE PRECISION, vz DOUBLE PRECISION,
PRIMARY KEY (mesh_version_id, state_index, node_id)
) PARTITION BY RANGE (state_index);
Note the primary key order: state_index before node_id. LS-DYNA post-processing almost always asks for "all nodes at state N" (animation frames) or "one node across states" (probe plots). The first is served by the partition and a range scan; the second needs a secondary index on (mesh_version_id, node_id, state_index). Create that index explicitly — do not assume the primary key covers it.
ip in the primary key: (mesh_version_id, state_index, elem_id, ip). Shell through-thickness output with 5 integration points multiplies row count by 5, so partition granularity should account for it.Material states: sparse, versioned, and nullable by design
Material state variables (damage, effective plastic strain, porosity, user-defined history variables from *DATABASE_ELOUT with MAT_ADD_EROSION or user materials) are only meaningful for elements whose material model exposes them, and often only for a subset of integration points. Do not force them into the elemental field table with dozens of nullable columns. Use a separate sparse table keyed by (mesh_version_id, state_index, elem_id, ip, var_name), or a compact material_var registry plus a fact table if the variable set is stable. The registry approach keeps the fact table narrow and lets you add a new user variable without an ALTER TABLE on a billion-row table.
CREATE TABLE material_var (
var_id INTEGER PRIMARY KEY, -- stable, assigned at ingest
name TEXT NOT NULL UNIQUE, -- e.g. 'eff_plas_strain','damage'
units TEXT
);
CREATE TABLE material_state (
mesh_version_id BIGINT NOT NULL,
state_index INTEGER NOT NULL,
elem_id INTEGER NOT NULL,
ip SMALLINT NOT NULL,
var_id INTEGER NOT NULL REFERENCES material_var(var_id),
value DOUBLE PRECISION NOT NULL,
PRIMARY KEY (mesh_version_id, state_index, elem_id, ip, var_id)
);
Because this table is sparse, its row count is driven by the number of elements that actually carry state, not by the mesh size. That is the point: a model with 50,000 solid elements where only 800 are in the damage zone produces 800 × states × vars rows, not 50,000 × states × vars.
Referential integrity and ingest order
Load in dependency order: simulation → mesh_version → state → history_channel → fact tables. Foreign keys on the fact tables are optional at scale — validating 6 billion rows against state is expensive — but the state and mesh_version keys should be enforced at the partition boundary or via a post-load NOT VALID constraint that you validate once. The history_channel registry, being small, should always carry a real foreign key.
Practical verification after ingest
Three checks catch most schema mistakes before they reach a plot. First, confirm state coverage: SELECT count(*) FROM state WHERE mesh_version_id = :mv must equal the number of states LS-DYNA reported in the d3plot header. Second, confirm channel completeness: for each history_channel, the count of history_value rows should equal the state count; a short channel means the history was requested after the run started or the entity was deleted mid-run. Third, confirm field density: count(DISTINCT node_id) in nodal_field for a given state should equal mesh_version.node_count; a mismatch indicates a partial write or a partition that was never loaded.
time_value as the join key between result tables. LS-DYNA state times are written as floats and can differ in the last bit between the d3plot and the ASCII history files. Joining on state_index and resolving time through the state table eliminates that class of mismatch entirely.The resulting layout gives one small identity chain, one shared time axis, and three result tables whose primary keys match their dominant query shape. That is the whole design principle: the key order is the access pattern, and the table shape follows the density of the data.