Skip to content

visualdynamics.io.exodus

exodus

Read and write Exodus finite element files (.exo, .e, .exo2, .g, .gen).

Exodus is netCDF, and both directions go through netCDF4. Reading gives the nodes, the element blocks, the file's coordinate frames (as coordinate systems), the node sets that record which node is placed or measured in which frame, and the results. Writing (save) gives a geometry as named blocks, its coordinate systems as frames and its assignments as node sets, and mode shapes or data records on that geometry as nodal and global variables. Damping and modal mass are written as the global variables Damping and ModalMass, and the imaginary part of a complex value as <name>_IM beside the real part, so a modal model reads back with all of them. A drawn line is a block of two-node line elements and is written as beam elements. The format has no units: length_unit declares the unit of the positions on import, and without it the geometry is unit-less.

Beyond the mesh, an Exodus file holds results: time_whole is a step axis, nodal variables are (steps x nodes) records, and global variables are one value per step. The file cannot say what the step axis means. A modal run stores one mode per step with the frequency as the time, a transient stores time, and spectral results store frequency, so load(steps=...) declares it, in the same way units are declared.

Functions:

Name Description
load

Read an Exodus file: the mesh, and the results it holds.

save

Write a geometry, mode shapes, or a time history or spectrum, as

result_summary

The names and counts of the file's results, read without the

node_set_nodes

The node ids in one node set, in the form load(nodes=...)

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 the results it holds.

length_unit declares the unit of the positions; without it the geometry is unit-less. blocks keeps only the element blocks whose ids are among its keys (and then numbers the elements from 1).

steps declares what the file's step axis means, because the file cannot say. 'modes' (the default) reads the displacement and rotation variables as one mode per step with the step's time as the frequency, and returns the geometry alone, or a dict keyed 'geometry' and 'shapes' when there are modes. 'time' reads every nodal and global variable as a TimeHistory, returned in a dict keyed 'geometry', 'time' and 'global'. 'frequency' reads them as a Spectrum, with _RE/_IM variable pairs merged into complex records, keyed 'geometry', 'spectra' and 'global spectra'. A key is left out when there is nothing for it.

nodes limits the result records to those node ids, since a large transient is a record for every node and direction, which for a 200,000-node mesh is far more channels than anyone wants. node_set_nodes gives the ids of a node set.

Raises ValueError for an unknown steps, an element type with no match, a node in nodes the file does not have, or, with 'time' or 'frequency', a file with no step axis or no variables.

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 the results it holds.

    `length_unit` declares the unit of the positions; without it the
    geometry is unit-less. `blocks` keeps only the element blocks whose
    ids are among its keys (and then numbers the elements from 1).

    `steps` declares what the file's step axis means, because the file
    cannot say. `'modes'` (the default) reads the displacement and
    rotation variables as one mode per step with the step's time as the
    frequency, and returns the geometry alone, or a dict keyed
    'geometry' and 'shapes' when there are modes. `'time'` reads every
    nodal and global variable as a `TimeHistory`, returned in a dict
    keyed 'geometry', 'time' and 'global'. `'frequency'` reads them as a
    `Spectrum`, with `_RE`/`_IM` variable pairs merged into complex
    records, keyed 'geometry', 'spectra' and 'global spectra'. A key is
    left out when there is nothing for it.

    `nodes` limits the result records to those node ids, since a large
    transient is a record for every node and direction, which for a
    200,000-node mesh is far more channels than anyone wants.
    `node_set_nodes` gives the ids of a node set.

    Raises ValueError for an unknown `steps`, an element type with no
    match, a node in `nodes` the file does not have, or, with `'time'`
    or `'frequency'`, a file with no step axis or no variables.
    """
    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))

        group_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(group_ids)))
        names = _block_names(v, len(group_ids))
        elem_type, elem_conn, elem_group = [], [], []
        kept_blocks, kept_names = [], []
        for i, group_id in enumerate(group_ids, start=1):
            if not status[i - 1]:
                continue
            if blocks is not None and group_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 {group_id} of {path}")
            conn = np.asarray(conn_var[()], dtype=np.int64)  # 1-based local indices
            if code == 136 and conn.ndim == 2 and conn.shape[1] == 1:
                code = 138           # a SPRING of one node is to ground
            kept_blocks.append(int(group_id))
            kept_names.append(names[i - 1])
            for row in conn:
                elem_type.append(code)
                elem_conn.append(node_ids[row - 1])
                elem_group.append(int(group_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_group=elem_group or None,
        group_id=kept_blocks or None,
        group_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', geometry: Geometry | None = None, unit_system: UnitSystem | None = None) -> None

Write a geometry, mode shapes, or a time history or spectrum, as Exodus.

Shapes and data need geometry, the mesh they are written on, and are refused without it. Each mode is written as one time step with its frequency as the time, and each data record as a nodal variable over time_whole (or a global variable when it has no DOF). Values at a node with a local displacement system are rotated into the global frame, because an Exodus nodal variable is always global. Positions and values are written in unit_system (its coherent form) where their units are defined, and as stored otherwise. title is the file's title.

Source code in src/visualdynamics/io/exodus.py
def save(obj: Any, path: str | os.PathLike, title: str = 'visualdynamics geometry',
         geometry: Geometry | None = None,
         unit_system: UnitSystem | None = None) -> None:
    """Write a geometry, mode shapes, or a time history or spectrum, as
    Exodus.

    Shapes and data need `geometry`, the mesh they are written on, and
    are refused without it. Each mode is written as one time step with
    its frequency as the time, and each data record as a nodal variable
    over `time_whole` (or a global variable when it has no DOF). Values
    at a node with a local displacement system are rotated into the
    global frame, because an Exodus nodal variable is always global.
    Positions and values are written in `unit_system` (its coherent form)
    where their units are defined, and as stored otherwise. `title` is
    the file's title.
    """
    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,
                              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,
                              unit_system=unit_system, data=obj)
    return _save_geometry(obj, path, title=title,
                          unit_system=unit_system)

result_summary

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

The names and counts of the file's results, read without the values, for the import dialog.

Returns {'nodal': [names], 'global': [names], 'steps': n, 'nodes': n, 'node_sets': [(id, name, count)]}. For a mesh with no results the lists are empty and the step count is zero. The node sets that record coordinate system assignments are not listed.

Source code in src/visualdynamics/io/exodus.py
def result_summary(path: str | os.PathLike) -> dict[str, Any]:
    """The names and counts of the file's results, read without the
    values, for the import dialog.

    Returns {'nodal': [names], 'global': [names], 'steps': n, 'nodes':
    n, 'node_sets': [(id, name, count)]}. For a mesh with no results the
    lists are empty and the step count is zero. The node sets that
    record coordinate system assignments are not listed.
    """
    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 in one node set, in the form load(nodes=...) takes.

Exodus stores set members as 1-based local indices; they are mapped through node_num_map to node ids. Raises ValueError when the file has no node set set_id.

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

    Exodus stores set members as 1-based local indices; they are mapped
    through `node_num_map` to node ids. Raises ValueError when the file
    has no node set `set_id`.
    """
    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}')