Skip to content

visualdynamics.io.exodus

exodus

Importer for Exodus finite element files (.exo/.e).

Exodus is netCDF underneath; we read it directly with netCDF4. Imports nodes and element blocks (no tracelines — exodus has none). The format carries no units; length_unit may be given to declare them at import, otherwise the geometry arrives unit-less.

Beyond the mesh, exodus carries results: time_whole is a step axis, nodal variables are (steps x nodes) records, global variables scalars per step. What the step axis means the file cannot say — a modal run stores one mode per step with frequency as time, a transient stores time, and spectral conventions store frequency — so load(steps=...) declares it rather than guessing, the same principle as declaring units. See "Exodus beyond the mesh" in PLAN.md.

Functions:

Name Description
load

Read an exodus file: the mesh, and whatever results it carries.

save

Write a geometry, or mode shapes, as exodus.

result_summary

What the file's results are, cheaply — the import dialog's facts.

node_set_nodes

The node ids one node set holds — what load(nodes=...) wants.

Classes

Functions:

load

load(
    path: str | PathLike,
    length_unit: str | None = None,
    blocks: dict[int, str] | None = None,
    steps: str | None = None,
    nodes: Any | None = None,
) -> Any

Read an exodus file: the mesh, and whatever results it carries.

steps declares what the file's step axis means, because the file cannot say: 'modes' (the default) reads displacement variables as a mode per step with frequency as the step's time; 'time' reads every nodal and global variable as a TimeHistory; 'frequency' reads them as a Spectrum, with _RE/_IM variable pairs merged into complex records. nodes limits the result records to those node ids — a large transient is records x steps for every node, and nobody wants all of a 200k-node mesh as channels.

Source code in src/visualdynamics/io/exodus.py
def load(path: str | os.PathLike, length_unit: str | None = None,
         blocks: dict[int, str] | None = None, steps: str | None = None,
         nodes: Any | None = None) -> Any:
    """Read an exodus file: the mesh, and whatever results it carries.

    `steps` declares what the file's step axis means, because the file
    cannot say: `'modes'` (the default) reads displacement variables as
    a mode per step with frequency as the step's time; `'time'` reads
    every nodal and global variable as a `TimeHistory`; `'frequency'`
    reads them as a `Spectrum`, with `_RE`/`_IM` variable pairs merged
    into complex records. `nodes` limits the result records to those
    node ids — a large transient is records x steps for every node, and
    nobody wants all of a 200k-node mesh as channels.
    """
    import netCDF4

    if steps not in (None, 'modes', 'time', 'frequency'):
        raise ValueError(
            f"steps={steps!r}: choose 'modes' (one mode per step, the "
            "default), 'time' or 'frequency'")

    scale = 1.0 if length_unit is None else si_factor(length_unit, 'length')

    with netCDF4.Dataset(path) as ds:
        v = ds.variables
        if 'coord' in v:
            xyz = np.asarray(v['coord'][()]).T
        else:
            xyz = np.column_stack([np.asarray(v[c][()])
                                   for c in ('coordx', 'coordy', 'coordz') if c in v])
        num_nodes = xyz.shape[0]
        node_ids = (np.asarray(v['node_num_map'][()])
                    if 'node_num_map' in v
                    else np.arange(1, num_nodes + 1))

        block_ids = (np.asarray(v['eb_prop1'][()])
                     if 'eb_prop1' in v else [])
        # eb_status 0 is the format's own 'not really here' — the one
        # block an element-less file writes as a placeholder has no
        # connect variable to read
        status = (np.asarray(v['eb_status'][()]) if 'eb_status' in v
                  else np.ones(len(block_ids)))
        names = _block_names(v, len(block_ids))
        elem_type, elem_conn, elem_block = [], [], []
        kept_blocks, kept_names = [], []
        for i, block_id in enumerate(block_ids, start=1):
            if not status[i - 1]:
                continue
            if blocks is not None and block_id not in blocks:
                continue
            conn_var = v[f'connect{i}']
            type_name = str(conn_var.elem_type).upper()
            try:
                code = _ELEM_TYPES[type_name]
            except KeyError:
                raise ValueError(
                    f"Unsupported exodus element type {type_name!r} "
                    f"in block {block_id} of {path}")
            conn = np.asarray(conn_var[()], dtype=np.int64)  # 1-based local indices
            kept_blocks.append(int(block_id))
            kept_names.append(names[i - 1])
            for row in conn:
                elem_type.append(code)
                elem_conn.append(node_ids[row - 1])
                elem_block.append(int(block_id))

        elem_ids = (np.asarray(v['elem_num_map'][()])
                    if 'elem_num_map' in v and blocks is None
                    else np.arange(1, len(elem_conn) + 1))
        systems = _read_frames(v, scale, path, num_nodes)
        _read_assignments(v, systems)

    geometry = Geometry(
        node_id=node_ids,
        node_xyz=xyz * scale,
        elem_id=elem_ids,
        elem_type=elem_type,
        elem_conn=elem_conn,
        elem_block=elem_block or None,
        block_id=kept_blocks or None,
        block_name=kept_names or None,
        length_unit=length_unit,
        **systems,
    )
    if steps in ('time', 'frequency'):
        result = {'geometry': geometry}
        result.update(_read_results(path, node_ids, steps, nodes))
        return result
    shapes = _read_shapes(path, node_ids)
    if shapes is None:
        return geometry
    return {'geometry': geometry, 'shapes': shapes}

save

save(
    obj: Any,
    path: str | PathLike,
    title: str = "visualdynamics geometry",
    tracelines_as_beams: bool = True,
    geometry: Geometry | None = None,
    unit_system: UnitSystem | None = None,
) -> None

Write a geometry, or mode shapes, as exodus.

Source code in src/visualdynamics/io/exodus.py
def save(obj: Any, path: str | os.PathLike, title: str = 'visualdynamics geometry',
         tracelines_as_beams: bool = True, geometry: Geometry | None = None,
         unit_system: UnitSystem | None = None) -> None:
    """Write a geometry, or mode shapes, as exodus."""
    from ..core.data import Spectrum, TimeHistory
    from ..core.shapes import ShapeSet

    if isinstance(obj, ShapeSet):
        if geometry is None:
            raise ValueError(
                'Exporting mode shapes to exodus needs a geometry: the '
                'file is a mesh with results, and shapes alone give '
                'ParaView no mesh to draw or animate')
        return _save_geometry(geometry, path, title=title,
                              tracelines_as_beams=tracelines_as_beams,
                              unit_system=unit_system, shapes=obj)
    if isinstance(obj, (TimeHistory, Spectrum)):
        if geometry is None:
            raise ValueError(
                'Exporting data to exodus needs a geometry: the file is '
                'a mesh with results, and records alone give it no mesh '
                'to sit on')
        return _save_geometry(geometry, path, title=title,
                              tracelines_as_beams=tracelines_as_beams,
                              unit_system=unit_system, data=obj)
    return _save_geometry(obj, path, title=title,
                          tracelines_as_beams=tracelines_as_beams,
                          unit_system=unit_system)

result_summary

result_summary(path: str | PathLike) -> dict[str, Any]

What the file's results are, cheaply — the import dialog's facts.

{'nodal': [names], 'global': [names], 'steps': n, 'nodes': n, 'node_sets': [(id, name, count)]} — everything empty or zero for a bare mesh, which is how a caller knows there is nothing to ask about. Names only; no values are read.

Source code in src/visualdynamics/io/exodus.py
def result_summary(path: str | os.PathLike) -> dict[str, Any]:
    """What the file's results are, cheaply — the import dialog's facts.

    {'nodal': [names], 'global': [names], 'steps': n, 'nodes': n,
    'node_sets': [(id, name, count)]} — everything empty or zero for a
    bare mesh, which is how a caller knows there is nothing to ask
    about. Names only; no values are read.
    """
    import netCDF4

    with netCDF4.Dataset(path) as ds:
        v = ds.variables
        nodal = (_char_names(v['name_nod_var'])
                 if 'name_nod_var' in v else [])
        globals_ = (_char_names(v['name_glo_var'])
                    if 'name_glo_var' in v else [])
        steps = len(v['time_whole']) if 'time_whole' in v else 0
        nodes = (ds.dimensions['num_nodes'].size
                 if 'num_nodes' in ds.dimensions else 0)
        sets = []
        if 'ns_prop1' in v:
            ids = np.asarray(v['ns_prop1'][()])
            names = (_char_names(v['ns_names'])
                     if 'ns_names' in v else [''] * len(ids))
            for i, set_id in enumerate(ids, start=1):
                if i - 1 < len(names) and _ASSIGNMENT_SET.match(names[i - 1]):
                    continue        # a coordinate-system marker, not nodes
                count = (ds.dimensions[f'num_nod_ns{i}'].size
                         if f'num_nod_ns{i}' in ds.dimensions else 0)
                sets.append((int(set_id),
                             names[i - 1] if i - 1 < len(names) else '',
                             count))
    return {'nodal': nodal, 'global': globals_, 'steps': steps,
            'nodes': nodes, 'node_sets': sets}

node_set_nodes

node_set_nodes(
    path: str | PathLike, set_id: int
) -> ndarray

The node ids one node set holds — what load(nodes=...) wants.

Exodus stores set members as 1-based local indices; they come back mapped through node_num_map into the ids everything else speaks.

Source code in src/visualdynamics/io/exodus.py
def node_set_nodes(path: str | os.PathLike, set_id: int) -> np.ndarray:
    """The node ids one node set holds — what `load(nodes=...)` wants.

    Exodus stores set members as 1-based local indices; they come back
    mapped through `node_num_map` into the ids everything else speaks.
    """
    import netCDF4

    with netCDF4.Dataset(path) as ds:
        v = ds.variables
        ids = np.asarray(v['ns_prop1'][()]) if 'ns_prop1' in v else []
        for i, found in enumerate(ids, start=1):
            if int(found) == int(set_id):
                local = np.asarray(v[f'node_ns{i}'][()], dtype=np.int64)
                node_ids = (np.asarray(v['node_num_map'][()])
                            if 'node_num_map' in v
                            else np.arange(1, ds.dimensions[
                                'num_nodes'].size + 1))
                return np.asarray(node_ids)[local - 1]
    raise ValueError(f'{path} has no node set {set_id}')