Skip to main content

dynars

Documentation · Rust API · Python API

An extremely fast LS-DYNA keyword file parser, written in Rust with Python bindings.

The purpose of dynars is speed, and speed alone.

If you need to...

  • Check millions of nodes for ID ranges
  • Traverse through GBs of decks for *INCLUDEs
  • See if your coworker used that one *MAT card you don't like.
  • Calculate HIC on a 5-second 10kHz signal
  • Get the maximum Von-Mises stress of a part

and do it fast dynars is the tool for you. dynars' primary goal is to be the fastest LS-DYNA parser publicly available.

[!IMPORTANT] While dynars strives to be as complete as possible, you should also check out the other great LS-DYNA packages such as lasso and pyDyna.

Highlights

  • Fast. The *INCLUDE scanner runs at about 15 GB/s per core (SIMD memchr) and spreads cross-file work over every core. Node marshalling parses around 73 M nodes/s on 10 cores.
  • Zero-copy to numpy. Numeric columns (node coordinates, element connectivity) cross into Python as numpy arrays without a copy.
  • Handles the awkward formats. Fixed-width (8-col), long (*KEYWORD LONG), and free (comma-separated) cards, plus Fortran floats like 1.5D+3 and 1.234-5.
  • Callable from C and Fortran over a C ABI (opt-in ffi feature).
  • Batch-aware. A Workspace parses and validates many decks that share includes against one cache, so a common mesh is read and indexed once instead of once per deck (up to about 12x over a 32-deck batch).

Performance

Measured on a 10-core Apple Silicon machine, 386 MB single-file deck, warm cache. Methodology and scaling curves are under Benchmarks.

Operation Throughput
*INCLUDE scan, single large file (macOS, fault-bound) ~15 GB/s
*INCLUDE scan, many warm files (mmap, no copy) ~45 GB/s
Block index (mmap + split) ~15 GB/s
Node parse, #[derive(Keyword)] (specialized) ~70 M nodes/s
Node parse, builder / Python (interpreted) ~57-64 M nodes/s

Reading *NODE into typed arrays (the path behind deck.table("NODE")) puts 100 M nodes under a second and holds about 110 M nodes/s until it hits the memory ceiling.

*NODE marshalling throughput vs node count

Reading a deck runs 100-200x faster than pyDYNA, and authoring one runs about 25x faster. Numbers and caveats are under Versus pyDYNA.

Install

pip install dynars     # Python package (pulls in numpy)
cargo add dynars       # Rust library
cargo install dynars   # the `dynars` CLI

Command line

dynars parse root.k          # print the include tree and throughput
dynars parse root.k --json   # the same, as JSON (pipe to jq)
dynars generate --depth 6 --breadth 4 --nodes 100000 --output test_output

Python

Include tree

import dynars

root = dynars.parse_include_tree("root.k")
print(root.total_files(), root.total_bytes())
for child in root.children:
    print(child.path, child.kind, child.byte_count)

Edit a single field, in place

Change one field and write the deck back byte-identical everywhere else — comments and $# rulers kept, columns unmoved, include-aware. Find the field with the navigation you already use, then set_field:

import dynars

deck = dynars.parse_deck("root.k")

deck.material(72).set_field("e", 2.1e11)                            # by id, schema-aware
deck.keywords("CONTROL_TERMINATION")[0].set_field("endtim", 0.02)  # by name
deck.file("modcontacts.k").keywords("CONTACT_TIED_SHELL_EDGE_TO_SURFACE_BEAM_OFFSET")[0] \
    .set_field("fs", 0.1)                                           # scoped to one *INCLUDE

for f in deck.files():        # edits are a write-time overlay — realise them
    if f.dirty:
        f.write(f.path)

set_field returns "in_place", or "reflowed" if the value was too wide for its fixed column (that one card re-emitted in free format — no other line moves). For a standalone single file (no *INCLUDE graph), parse_keyword_file gives a KeywordFile with the same lossless round-trip and block-level editing:

kf = dynars.parse_keyword_file("deck.k")
nodes = dynars.parse_keyword(kf, "NODE")   # {"nid": int64[N], "x": ..., ...}
i = kf.block_names().index("MAT_ELASTIC")
cards = kf.keyword(i)["cards"]; cards[0][2] = "70000.0"
kf.set_keyword(i, "MAT_ELASTIC", cards)
kf.write("deck_edited.k")

Author a keyword file from arrays

write_keyword is the reverse of table: numpy columns go straight into Rust, no per-row Python objects.

import numpy as np, dynars

n = 1_000_000
dynars.write_keyword("mesh.k", "NODE", {
    "nid": np.arange(1, n + 1, dtype=np.int64),
    "x": xs, "y": ys, "z": zs,
})

Navigate and bulk-read off one handle

parse_deck reads the root and every *INCLUDE once. The Deck handles both navigation (by id, following references) and bulk columnar reads, and it spans every file.

import dynars

deck = dynars.parse_deck("root.k")

nodes = deck.table("NODE")             # columns across the whole deck
shells = deck.table("ELEMENT_SHELL")

part = deck.part(1)
mat = part.material()                  # follow *PART.mid -> *MAT
print(part.field("secid"), mat.id if mat else None)

Rust does the same over one Deck:

use dynars::deck::parse_deck;

let deck = parse_deck(std::path::Path::new("root.k")).unwrap();

let nodes = deck.table("NODE").unwrap();
let ids = nodes.column("nid").unwrap().as_int().unwrap();

if let Some(part) = deck.part(1) {
    let secid = part.field("secid").and_then(|f| f.as_i64());
    let mat_id = part.material().and_then(|m| m.id());
    let _ = (ids, secid, mat_id);
}

Validation

There's no default rule set. You pass the checks you want and get back findings, each with a clickable file:line.

import dynars
from dynars import Rule, Predicate, Cmp, Severity

deck = dynars.parse_deck("root.k")

report = deck.validate([
    Rule.references_resolve(),                                # ids resolve
    Rule.duplicate_ids(),                                     # no id collisions
    Rule.unreferenced_entities(),                             # dead defs (warns)
    Rule.field_forbidden_values("MAT_ELASTIC", "PR", [0.5]),  # PR may not be 0.5
    Rule.field_required(                                      # ELFORM==2 -> NIP>0
        "SECTION_SHELL",
        require=Predicate.field("NIP", Cmp.Gt, 0),
        when=Predicate.field("ELFORM", Cmp.Eq, 2),
    ),
    Rule.keyword_forbidden("MAT_ADD_EROSION").only_in(["submodel/"]),
])

print(report.is_clean(), report.count(Severity.Error))
for f in report.findings:
    print(f.severity, f.rule, f.location(), f.message)

The built-in rules are references_resolve (and references_resolve_with_connectivity, which also checks that every element's nodes exist), duplicate_ids, unreferenced_entities, rigid_context, include_missing, field_forbidden_values, field_required, and keyword_forbidden. Every rule takes .only_in([...]) / .except_in([...]) file scopes and .with_severity(...); compose predicates with Predicate.all_ / any_ / not_.

Rust runs the same rules, and adds a custom Check for anything the built-ins don't cover:

use dynars::validate::{Check, Deck, Finding, Rule, Severity};

struct DensityPositive;
impl Check for DensityPositive {
    fn name(&self) -> String { "density_positive".into() }
    fn run(&self, deck: &Deck) -> Vec<Finding> {
        deck.keywords("MAT_ELASTIC").filter_map(|m| {
            let ro = m.field("RO")?.as_f64()?;
            (ro <= 0.0).then(|| Finding {
                rule: self.name(), severity: Severity::Warning,
                keyword: "MAT_ELASTIC".into(), file: m.file().to_path_buf(),
                line: m.line(), message: format!("RO = {ro} must be positive"),
            })
        }).collect()
    }
}
let _ = deck.validate([Rule::references_resolve(), Rule::custom(DensityPositive)]);

Workspace: many decks that share includes

Load-case or run variants of one model usually *INCLUDE the same big files. A Workspace reads and indexes each shared file once across the batch, then validates the decks in parallel. The decks it returns are ordinary Decks, so you can navigate or validate them one at a time and still reuse the cache.

import dynars

ws = dynars.Workspace()
decks = ws.parse_decks(["variant_a/main.k", "variant_b/main.k", "variant_c/main.k"])

reports = ws.validate_decks(decks, [
    dynars.Rule.references_resolve_with_connectivity(),
    dynars.Rule.duplicate_ids(),
])
print(ws.stats())  # files_parsed vs files_reused, indices built once

The shared work is paid once, so the workspace total stays roughly flat as decks are added while the per-deck approach grows linearly. Over a 28 MB shared mesh (500k nodes, 500k shells):

decks naive total workspace total speedup
4 240 ms 122 ms 2.0x
8 481 ms 126 ms 3.8x
16 969 ms 150 ms 6.5x
32 1944 ms 157 ms 12.4x

A missing *INCLUDE is never parsed, so nothing phantom leaks into the deck. Add Rule.include_missing() to catch it. Don't rely on references_resolve alone: if the missing file was the only source of an entity kind, references to that kind stay unflagged. See examples/batch_validate.rs and examples/batch_demo.py.

Result post-processing

Channels from a binout or d3plot come back as numpy arrays, so they feed straight into signal processing and occupant injury criteria. These live in the Rust core and are verified bit-exact against SciPy.

import numpy as np, dynars
from dynars import signal, injury

b = dynars.parse_binout("binout*")

# `read(branch, var)` aggregates a variable across all output states; `id=`
# returns one node's contiguous [T] history (like lasso, but zero-copy).
node = 1000001
t  = b.read("nodout", "time")                # [T]
dt = t[1] - t[0]
ax = b.read("nodout", "x_acceleration", id=node)   # [T]
ay = b.read("nodout", "y_acceleration", id=node)
az = b.read("nodout", "z_acceleration", id=node)

ax_cfc = signal.cfc(ax, 1000.0, dt)          # CFC1000
vel    = signal.integrate(ax_cfc, dt)
low    = signal.butterworth(ax_cfc, 4, 300.0, 1 / dt, "low")

a_res = injury.resultant(ax, ay, az)         # sqrt(x^2 + y^2 + z^2)
hic36 = injury.hic36(a_res, dt)              # also hic15, hic
a3ms  = injury.clip(a_res, dt)               # 3 ms clip
csi   = injury.severity_index(a_res, dt)     # Gadd severity index

(read("nodout", "x_acceleration") returns the full [T, nodes] matrix; id= decodes just that node's column. read_states is the structured form — {time, values, ids} in one call.)

Filtering (cfc, filtfilt, butterworth) is behind the signal feature, which the published wheels include. CFC and the injury criteria are always available. Generic array math (FFT, resampling) is left to numpy and SciPy.

Custom keywords

Decks carry vendor, rare, or newer-than-our-snapshot keywords. Describe one with a schema and it becomes first-class on the deck: columns, typed fields, and (in Rust) reference checking. The declaration is data, run by the Rust hot loop, so it never calls back into Python per card.

In Python, a keyword is a class. Fields on the class are one card; a cards list composes several.

from dynars import keyword, Card, Int, Float, IntArray, parse_keyword

@keyword("NODE")                      # one card, repeats over the block
class Node(Card):
    nid = Int(8)
    x = Float(16); y = Float(16); z = Float(16)

@keyword("ELEMENT_SHELL")
class ElementShell(Card):
    eid = Int(8); pid = Int(8)
    nodes = IntArray(4, width=8)      # one (N, 4) column

cols = parse_keyword(kf, Node)        # {"nid": int64[N], "x": float64[N], ...}

In Rust the equivalent is #[derive(Keyword)]. The field types imply Int/Float/Str, so you only annotate widths.

use dynars::Keyword;

#[derive(Keyword)]
#[keyword("NODE")]                    // repeat defaults to true
struct Node {
    #[field(8)]  nid: i64,            // i64 -> Int, f64 -> Float, String -> Str
    #[field(16)] x: f64,
    #[field(16)] y: f64,
    #[field(16)] z: f64,
}

let nodes = Node::parse(&parsed);
let ids = nodes.column("nid").unwrap().as_int().unwrap();

To register a keyword on a whole Deck (so navigation, table_with, and the rules see it), pass a runtime schema. In Rust a ref_to field also declares a reference, so references_resolve() dangling-checks it.

use dynars::schema::{Schema, Card};
use dynars::keywords::EntityKind;

deck.register_schema(Schema::new("VENDOR_WIDGET").card(
    Card::new()
        .int("wid", 8)
        .float("mass", 8)
        .ref_to("mat", 8, EntityKind::Material),   // id references a *MAT
));
cards = [[("wid", "int", 8, 1), ("mass", "float", 8, 1)]]
deck.register_schema("VENDOR_WIDGET", cards)
cols = deck.table_with("VENDOR_WIDGET", cards)

Runnable examples: examples/schema_demo.{rs,py}, examples/derive_demo.rs, and examples/builtin_demo.{rs,py}.

Built-in keyword library

You don't have to declare the common keywords. dynars ships schemas for about 3,170 LS-DYNA keywords, generated from the pyDYNA field database (codegen/), plus hand-written *NODE and *PART that pyDYNA omits. Pass a name and it resolves from the library:

nodes = dynars.parse_keyword(kf, "NODE")
mats  = dynars.parse_keyword(kf, "MAT_ELASTIC")   # no declaration needed

A @keyword class or #[derive(Keyword)] with the same name overrides the built-in. To avoid magic strings, every name is also a constant: dynars.kw.MAT_ELASTIC in Python, dynars::keywords::names::MAT_ELASTIC in Rust. The opt-in typed-keywords feature generates a typed struct per keyword.

The library covers each keyword's static card layout. Conditional or count-driven cards (for example *DEFINE_CURVE) parse their base layout and stay in the generic Keyword model.

C / Fortran

The parse and validate path is exposed over a C ABI behind the opt-in ffi feature. Fortran binds the same ABI through iso_c_binding. Marshalling, navigation, and the result readers are Rust and Python only.

cargo build --release --features ffi
# target/release/libdynars.{dylib,so,a}
#include "dynars.h"

DynarsDeck *deck = dynars_parse_deck("root.k");   // NULL on error
DynarsRuleSet *rules = dynars_ruleset_new();
dynars_ruleset_add_references_resolve(rules);
dynars_ruleset_add_include_missing(rules);

DynarsReport *report = dynars_deck_validate(deck, rules);
for (size_t i = 0; i < dynars_report_len(report); i++)
    printf("%s:%zu  %s\n", dynars_report_finding_file(report, i),
                           dynars_report_finding_line(report, i),
                           dynars_report_finding_message(report, i));

dynars_report_free(report);
dynars_ruleset_free(rules);
dynars_deck_free(deck);

Every handle is caller-owned and freed with its matching *_free; fallible calls return NULL/-1 and set a thread-local message read via dynars_last_error(). The header is examples/ffi/dynars.h, with runnable C and Fortran examples and a Makefile in examples/ffi/.

Design

The two capabilities are separate, and marshalling is additive: the include-tree path is unchanged and pays nothing for the marshalling features.

  • Scanner (parser::parse_file_from_path): memory-maps the file and scans for * at line starts with SIMD memchr. Files 8 MB and up are scanned in parallel over line-aligned chunks; the mapping is contiguous, so a chunk that finds a keyword near its end reads forward for the filename and needs no overlap buffer. Across files, a work-stealing pool parallelizes by file.
  • Block index (parser::parse_file_blocks): memory-maps the file and splits it into keyword blocks that tile the source exactly. That's the lossless round-trip guarantee: re-emitting every block reproduces the input. Edits are an overlay keyed by block index.
  • Tokenizer (Field, split_fields, CardIter): lazy, format-aware field splitting. Nothing is parsed until read.
  • Schemas (schema, dynars-derive): the single columnar path. A declarative card layout is parsed into Tables, parallelized with rayon over line-aligned chunks, using lexical for fast conversion and mapping straight onto numpy. Three front ends lower to one Schema: the Rust builder, #[derive(Keyword)] structs, and the Python @keyword classes.

Fixed-width is the default. Long format is detected from *KEYWORD LONG=Y|S. Free format is decided per line: a line switches to comma-splitting the moment it contains a comma, matching LS-DYNA's own rule.

Parallel mmap scanning scales on Linux, where page faults resolve concurrently. On macOS minor faults serialize, so single-file scans there run near single-thread speed, though eliminating the copy still helps.

Benchmarks

The throughput table was measured on a 10-core Apple Silicon machine, 386 MB single-file deck (5 M nodes), warm cache. A few notes:

  • #[derive(Keyword)] emits monomorphized code (offsets known at compile time), so its parse() runs about 20% faster than the interpreted builder and Python path. Both do tens of millions of entities per second.
  • The multi-file scan number roughly doubled after switching from read() to mmap, which removes a copy of every file.
  • Cold decks larger than RAM are limited by disk bandwidth (about 2 GB/s sustained NVMe), not CPU. The scanner is about 7x faster than the disk can deliver bytes.

Versus pyDYNA

pyDYNA (ansys-dyna-core) is a deck-authoring API in pure Python and pandas that also reads keyword files. Same machine, same decks, both going between arrays and a deck (pyDYNA 0.12.1):

dynars vs pyDYNA, reading and authoring LS-DYNA keyword data

Task, 1 M entities dynars pyDYNA speedup
Read *NODE into (N, 3) coords 22 ms 2.9 s ~130x
Read *ELEMENT_SHELL into (M, 4) connectivity 24 ms 3.6 s ~150x
Root + 8 *INCLUDE into all node coords 20 ms 2.6 s ~130x
Author a *NODE deck and write .k 50 ms 1.3 s ~26x

Reading stays 150-180x ahead at 5 M entities; authoring stays around 26x. Two things to be fair about. pyDYNA builds a pandas DataFrame per keyword, which is its data model. And it doesn't follow *INCLUDE, so that row compares dynars' native parse_deck against a recursive loader written over pyDYNA. Authoring uses dynars' write_keyword; the older per-card path was about 25x slower, which is why the columnar writer exists.

pip install ansys-dyna-core
python examples/compare_pydyna.py     # writes assets/bench_pydyna.csv
python scripts/plot_bench.py          # writes assets/perf_pydyna.png

Scaling across include layouts

Every pipeline stage is linear in deck size and parallel across include files. The figure sweeps deck size for three include layouts: one monolithic file, a wide flat tree (256 leaves), and a deep tree (216 leaves, 3 levels). Shared log-log axes.

time per stage vs deck size, per include layout

At the top point, a 5 M-keyword, 1.1 GB deck:

  • Every stage is linear on log-log across about 1.6 decades.
  • Spreading a deck over include files fans work across cores. The reference plus connectivity check drops from 1.39 s to 0.23 s (1 file to 256), the reference check from 0.99 s to 0.16 s, and reading the deck from 160 ms to 46 ms.
  • Include depth is nearly free: the flat and 3-deep trees track each other.
  • The field-value check is the one sequential stage, since a keyword's occurrences are scanned in order.
cargo run --release --example bench_scaling   # assets/bench_scaling.csv
cargo run --release --example marshal_bench   # assets/bench_marshal.csv
python scripts/plot_bench.py                  # renders assets/perf_*.png

Development

cargo test                    # Rust unit and integration tests
cargo check --features python # type-check the pyo3 bindings

# Regenerate the Python type stub after changing the API.
maturin generate-stubs --features python --out python/dynars

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

dynars-1.1.0.tar.gz (2.2 MB view details)

Uploaded Source

Built Distributions

If you're not sure about the file name format, learn more about wheel file names.

dynars-1.1.0-cp39-abi3-win_amd64.whl (1.1 MB view details)

Uploaded CPython 3.9+Windows x86-64

dynars-1.1.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (1.2 MB view details)

Uploaded CPython 3.9+manylinux: glibc 2.17+ x86-64

dynars-1.1.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl (1.3 MB view details)

Uploaded CPython 3.9+manylinux: glibc 2.17+ ARM64

File details

Details for the file dynars-1.1.0.tar.gz.

File metadata

  • Download URL: dynars-1.1.0.tar.gz
  • Upload date:
  • Size: 2.2 MB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for dynars-1.1.0.tar.gz
Algorithm Hash digest
SHA256 4fd5713490a0fb5e5ff2fb15a565f40b3e8da272c8a8bf93ba26b4497a741506
MD5 dac484e1a053a426b2f42479f4f7d23f
BLAKE2b-256 92f336ad73949f3dfb51e6ddef02f6a3b53594f44a8be70535f33ccd36dd3a49

See more details on using hashes here.

Provenance

The following attestation bundles were made for dynars-1.1.0.tar.gz:

Publisher: release.yml on osullivryan/dynars

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file dynars-1.1.0-cp39-abi3-win_amd64.whl.

File metadata

  • Download URL: dynars-1.1.0-cp39-abi3-win_amd64.whl
  • Upload date:
  • Size: 1.1 MB
  • Tags: CPython 3.9+, Windows x86-64
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for dynars-1.1.0-cp39-abi3-win_amd64.whl
Algorithm Hash digest
SHA256 3b33bc83d7af36c0816bdcbdc6e96b5c4bc6a688205407aa99600ad75b19738b
MD5 1b2049b3b9b1c18f6bfdf13b9fae49a7
BLAKE2b-256 97ff358f75058244d6efa38beb665940334fcf4986ba59d448422d0e1d4e187e

See more details on using hashes here.

Provenance

The following attestation bundles were made for dynars-1.1.0-cp39-abi3-win_amd64.whl:

Publisher: release.yml on osullivryan/dynars

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file dynars-1.1.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.

File metadata

File hashes

Hashes for dynars-1.1.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
Algorithm Hash digest
SHA256 32fce3fcacf6cdc0b69178c6c6d3c0c11b6de9092dcfd706921886af0110cb8c
MD5 e99cfdbd64d298616eec8920f9b0af9c
BLAKE2b-256 359866a4d764812b57f348d298020b0adab31eee21b2f2839555394bdb9bfa36

See more details on using hashes here.

Provenance

The following attestation bundles were made for dynars-1.1.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl:

Publisher: release.yml on osullivryan/dynars

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file dynars-1.1.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl.

File metadata

File hashes

Hashes for dynars-1.1.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
Algorithm Hash digest
SHA256 316a26fb0a33dec45f74851cc43bc38939e8a00ecf6c6461391b2a399f5b0ed0
MD5 0b305ea98827f391995b1c1023ad69a4
BLAKE2b-256 97d2124aaa71fc0ed53611f0cad1d811f29cd85cdb56f9a0667aee80ad9545d1

See more details on using hashes here.

Provenance

The following attestation bundles were made for dynars-1.1.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl:

Publisher: release.yml on osullivryan/dynars

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

This release

1.1.0 This release

4 files

1.0.0

4 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page