Skip to content

visualdynamics.io.unv

unv

Importer for Universal Files (.unv/.uff), per the SDRL specifications (https://www.ceas3.uc.edu/sdrluff/ — local copies in docs/uff_spec).

Geometry datasets supported:

  • 164 units: length/force/temperature factors; per spec, file values are DIVIDED by the factors to get SI, so a 164 makes the import unit-aware
  • 15 nodes (old single-precision form, one line per node)
  • 2411 nodes (double precision: 4I10 record + 3D25.16 record per node)
  • 82 tracelines: node list where 0 means pen-up (move without drawing); pen-up runs are split into separate polylines
  • 2412 elements: FE descriptor codes match visualdynamics's element vocabulary; beam- class descriptors carry an extra orientation record before the nodes
  • 58b the binary form of 58: the same eleven ASCII header records, then the values as raw floats. Read in either byte order at either precision, and written little-endian IEEE 754 doubles when save(..., binary=True) is asked for. Only IEEE 754 floats are decoded — DEC VMS and IBM 5/370 floats are refused by name rather than read as IEEE, which would return wrong numbers instead of failing.
  • 58 functions at nodal DOFs (time histories, spectra, FRFs, PSDs). The ordinate scale to SI comes from the axis data-type unit exponents combined with the 164 factors; without a 164, unit-bearing data imports unit-less unless ordinate_unit is declared. Functions are grouped by type into one visualdynamics data object per type; unsupported function types (coherence, etc.) are skipped.

A file with several objects (e.g. geometry + FRFs) returns a dict keyed 'geometry', 'time', 'spectrum', 'frf', 'psd'; a file with one object returns it directly. Unrecognized datasets are skipped (a UNV file is a stream of independent datasets delimited by '-1' lines).

Classes:

Name Description
Binary

The binary half of a dataset 58b: how to read it, and it.

Functions:

Name Description
iter_datasets

Yield (dataset_number, record_lines, payload) for each dataset.

handles_binary

Only function data, because only dataset 58 has a binary form.

save

Write a geometry or a data array as a universal file.

save_binary

save in the binary form — the registry's entry for 58b.

Classes

Binary dataclass

Binary(order: int, fp_format: int, data: bytes)

The binary half of a dataset 58b: how to read it, and it.

order and fp_format are the marker line's own fields, carried with the bytes rather than read again from somewhere else — a blob whose byte order has been separated from it is a blob that will one day be decoded by the wrong one.

Functions:

iter_datasets

iter_datasets(
    data: str | bytes,
) -> Iterator[tuple[int | None, list[str], bytes]]

Yield (dataset_number, record_lines, payload) for each dataset.

payload is None for an ordinary ASCII dataset and a Binary for a binary one (dataset 58b), whose eleven header records stay ASCII and come back in record_lines like any other.

Bytes rather than text, and a cursor rather than splitlines, because a binary payload is not text: it contains newlines that are float bytes, and it can contain the byte pattern of a '-1' delimiter line. Splitting first and interpreting afterwards would cut a dataset in half at a number that happened to look like a delimiter. The payload is therefore taken by the byte count the marker line declares, and never scanned.

Source code in src/visualdynamics/io/unv.py
def iter_datasets(
        data: str | bytes) -> Iterator[tuple[int | None, list[str], bytes]]:
    """Yield (dataset_number, record_lines, payload) for each dataset.

    `payload` is None for an ordinary ASCII dataset and a `Binary` for
    a binary one (dataset 58b), whose eleven header records stay ASCII
    and come back in `record_lines` like any other.

    Bytes rather than text, and a cursor rather than `splitlines`,
    because a binary payload is not text: it contains newlines that are
    float bytes, and it can contain the byte pattern of a '-1' delimiter
    line. Splitting first and interpreting afterwards would cut a
    dataset in half at a number that happened to look like a delimiter.
    The payload is therefore taken by the byte count the marker line
    declares, and never scanned.
    """
    if isinstance(data, str):
        data = data.encode('utf-8', 'surrogateescape')
    position, number, records, state = 0, None, [], 'between'
    payload = None
    while position < len(data):
        stop = data.find(b'\n', position)
        if stop == -1:
            stop = len(data)
        line = data[position:stop].decode('utf-8', 'replace').rstrip('\r')
        position = stop + 1

        if line.strip() == '-1':
            if state == 'in' and number is not None:
                yield number, records, payload
            number, records, payload = None, [], None
            state = 'delim' if state == 'between' else 'between'
            continue
        if state == 'delim':
            state = 'in'
            binary = _BINARY_MARKER.match(line)
            if binary:
                number = int(binary.group(1))
                order, fp, ascii_lines, count = (int(binary.group(i))
                                                 for i in (2, 3, 4, 5))
                for _ in range(ascii_lines):
                    stop = data.find(b'\n', position)
                    if stop == -1:
                        stop = len(data)
                    records.append(
                        data[position:stop].decode('utf-8', 'replace')
                        .rstrip('\r'))
                    position = stop + 1
                blob = data[position:position + count]
                position += count
                # a writer may or may not end the blob with a newline;
                # skipping one keeps an empty record out of the list
                if data[position:position + 1] == b'\n':
                    position += 1
                payload = Binary(order, fp, blob)
                continue
            try:
                number = int(line.strip())
            except ValueError:
                number = None
        elif state == 'in':
            records.append(line)
    if state == 'in' and number is not None:
        yield number, records, payload

handles_binary

handles_binary(obj: Any) -> bool

Only function data, because only dataset 58 has a binary form.

A geometry or a mode shape offered as "binary" would write exactly the ASCII file the other exporter writes, under a name promising something else — 2411, 82, 2412 and 55 have no b variant, and inventing one would produce a file nothing else reads. Caught by test_shapes_can_be_written_to_every_format_that_holds_them, which already knew what a ShapeSet may be written as (2026-08-25).

Source code in src/visualdynamics/io/unv.py
def handles_binary(obj: Any) -> bool:
    """Only function data, because only dataset 58 has a binary form.

    A geometry or a mode shape offered as "binary" would write exactly
    the ASCII file the other exporter writes, under a name promising
    something else — 2411, 82, 2412 and 55 have no `b` variant, and
    inventing one would produce a file nothing else reads. Caught by
    `test_shapes_can_be_written_to_every_format_that_holds_them`, which
    already knew what a ShapeSet may be written as (2026-08-25).
    """
    from ..core.data import DataArray

    return isinstance(obj, DataArray)

save

save(
    obj: Any,
    path: str | PathLike,
    unit_system: UnitSystem | None = None,
    binary: bool = False,
) -> None

Write a geometry or a data array as a universal file.

Geometry goes out as 2420 coordinate systems, 2411 nodes, 82 tracelines and 2412 elements; data as one dataset 58 per record; mode shapes as one dataset 55 per mode.

Shape values stay in each node's own displacement system, which is what dataset 55 means by them — the coordinate systems written alongside are what make that readable.

A dataset 164 declaring SI is written when the object's units have been defined, and left out when they have not. Stored values are SI either way, but only in the first case is that a fact about the data rather than an assumption — and a file that says so reads back with its units, where one that does not reads back unit-less.

binary writes the functions as dataset 58b instead of 58: the same eleven ASCII header records, and then the values as raw IEEE 754 doubles rather than as 13.5E text. It is roughly a third of the size and it is exact — a written-and-read float comes back the float that went in, where five decimal places do not. Only the functions change form; geometry, mode shapes and the units dataset have no binary variant and go out as they always do.

Source code in src/visualdynamics/io/unv.py
def save(obj: Any, path: str | os.PathLike,
         unit_system: UnitSystem | None = None, binary: bool = False) -> None:
    """Write a geometry or a data array as a universal file.

    Geometry goes out as 2420 coordinate systems, 2411 nodes, 82 tracelines
    and 2412 elements; data as one dataset 58 per record; mode shapes as
    one dataset 55 per mode.

    Shape values stay in each node's own displacement system, which is what
    dataset 55 means by them — the coordinate systems written alongside are
    what make that readable.

    A dataset 164 declaring SI is written when the object's units have been
    defined, and left out when they have not. Stored values are SI either
    way, but only in the first case is that a fact about the data rather
    than an assumption — and a file that says so reads back with its units,
    where one that does not reads back unit-less.

    `binary` writes the functions as dataset **58b** instead of 58: the
    same eleven ASCII header records, and then the values as raw IEEE
    754 doubles rather than as `13.5E` text. It is roughly a third of
    the size and it is exact — a written-and-read float comes back the
    float that went in, where five decimal places do not. Only the
    functions change form; geometry, mode shapes and the units dataset
    have no binary variant and go out as they always do.
    """
    from ..core.geometry import Geometry

    blocks = []
    if getattr(obj, 'units_defined', False):
        blocks.append(_units_dataset(unit_system))
    from ..core.shapes import ShapeSet

    if isinstance(obj, Geometry):
        blocks += _geometry_datasets(obj, unit_system)
    elif isinstance(obj, ShapeSet):
        blocks += _shape_datasets(obj, unit_system)
    else:
        blocks += [_58_dataset(obj, i, unit_system, binary)
                   for i in range(obj.num_records)]
    # bytes throughout: a 58b block is bytes, the rest are text, and the
    # file is one stream of both
    with open(path, 'wb') as handle:
        handle.writelines(block if isinstance(block, bytes)
                          else block.encode() for block in blocks)

save_binary

save_binary(
    obj: Any,
    path: str | PathLike,
    unit_system: UnitSystem | None = None,
) -> None

save in the binary form — the registry's entry for 58b.

A separate exporter rather than a checkbox on the other one, because the Export menu is a list of formats and this is one: a file another tool will or will not read. Choosing by suffix still gets the ASCII form, which is the one to hand a stranger.

Source code in src/visualdynamics/io/unv.py
def save_binary(obj: Any, path: str | os.PathLike,
                unit_system: UnitSystem | None = None) -> None:
    """`save` in the binary form — the registry's entry for 58b.

    A separate exporter rather than a checkbox on the other one,
    because the Export menu is a list of formats and this is one: a
    file another tool will or will not read. Choosing by suffix still
    gets the ASCII form, which is the one to hand a stranger.
    """
    save(obj, path, unit_system, binary=True)