Skip to content

visualdynamics.io.matlab

matlab

MATLAB .mat files: the project file in MATLAB's container.

A .mat written here has the .vdyn schema and names (docs/vdyn-format.md), so a MATLAB user gets load('modal.mat') and structs with the right shapes and complex numbers assembled, rather than HDF5 datasets to read one at a time with h5read (which a .vdyn already allows). Everything a project holds has a MATLAB form, so the reader reads its own files back completely.

The layout is implemented once, and not in this module. The writer runs the native .vdyn writer into an HDF5 file held in memory and converts that tree into .mat variables; the reader rebuilds an in-memory HDF5 tree from the .mat and passes it to the native loader. A field added to the schema is carried with no change here, and the two file types cannot drift apart, because neither this module nor native.py knows two layouts (principle 9). It costs one in-memory HDF5 round trip per file, which is fast.

MATLAB holds three things differently from HDF5, and the same rules convert them in both directions:

  • Ragged arrays, the <name>_flat / <name>_offsets pairs (element connectivity), are one cell array <name> with one row vector per entry, which is how MATLAB holds a ragged list.
  • Numbered groups, the project's objects/0000, 0001, … and a sine specification's tones/0, 1, …, are a cell array of structs in the same order (p.objects{3}.name); each object struct keeps the name and kind its group's attributes held.
  • Vectors are columns. A one-dimensional dataset is written N×1, MATLAB's own vector orientation, so it reads back one-dimensional; a two-dimensional array (ordinate, records × samples) stays as it is even with one row. MATLAB has no one-dimensional array, so a matrix with one column (a sine tone's amplitude over one channel) would read back as a vector. A struct holding one has a matrix_fields cellstr naming it, written only in that case and read if present.

HDF5 attributes and datasets both become plain fields (frf.function_type beside frf.ordinate), because a MATLAB struct has only one kind of member and a hidden marker would be clutter. On the way back the native loader asks for each field as the kind it expects, so the rebuild writes every field as a dataset and, where it is small enough, as an attribute too; the loader reads the one it wants. Strings are char arrays, lists of strings are cellstr, and '' (an undeclared unit) is MATLAB's empty char.

Values are in the display units, and every unit field names the unit its values are in. A .mat exported in a display system holds each record in that system's unit for its quantity, labeled with it: g values with an ordinate_unit of 'g', (in/s**2)/lbf with an FRF in those units, a geometry's coordinates in inches with length_unit 'in', shapes in slinch. The file names the system in a unit_system variable, and the reader converts each object back to SI from its own labels, so on reading the declared unit is the one it was written in. Some values stay SI in every system, because the project file defines them as SI: material and section properties, and the abscissa, which is seconds or hertz. A record whose units were never declared is written as it is, marked '', and is read back undeclared. Without a display system (Project.save to .mat, or a script that names none) the file holds exactly what the project file holds, in SI, and a file with no unit_system variable is read as SI.

The conversion is the core's own, run on a copy: each object is labeled with the display unit and undefine_units converts its values to it, the inverse of how a declaration converts a file's numbers to SI; the reader clears the labels and declares them again (_in_display, _from_display).

The stamp visualdynamics_schema is written as a variable, and the reader follows the .vdyn rule: a newer stamp is refused, and an older one always loads. A .mat with no stamp is accepted in one case only, a single struct in the documented layout of one object (a data array with abscissa, ordinate and data_class or function_type, a geometry with node_xyz, a shape set with shape_matrix, a channel table with column_order), which is a user's own struct built to that layout. Any other .mat is refused with a ValueError naming the variables it holds, because reading an arbitrary workspace would be guessing.

.mat version 5 (what save -v7 writes, and what scipy reads and writes) holds at most 4 GB per variable. A record larger than that is refused before anything is written, with the array named; version 7.3 files are HDF5 and are not read or written here yet.

Functions:

Name Description
handles

Whether obj can be written as a .mat: any object the project

sniff

Whether path ends in .mat, in any case (.MAT too).

save

Write an object, or a whole project, as a .mat file.

load

Read a .mat written here, or a single struct built to the

Functions:

handles

handles(obj: Any) -> bool

Whether obj can be written as a .mat: any object the project file holds, or a whole project.

Source code in src/visualdynamics/io/matlab.py
def handles(obj: Any) -> bool:
    """Whether `obj` can be written as a `.mat`: any object the project
    file holds, or a whole project."""
    from ..project import Project

    if isinstance(obj, Project):
        return True
    try:
        native._saver_for(obj)
    except TypeError:
        return False
    return True

sniff

sniff(path: str | PathLike) -> bool

Whether path ends in .mat, in any case (.MAT too).

The suffix is the only check. Whether the file was written here is decided by load, which raises a ValueError saying what is wrong; that is more useful than a message that no importer recognized the file.

Source code in src/visualdynamics/io/matlab.py
def sniff(path: str | os.PathLike) -> bool:
    """Whether `path` ends in `.mat`, in any case (`.MAT` too).

    The suffix is the only check. Whether the file was written here is
    decided by `load`, which raises a ValueError saying what is wrong;
    that is more useful than a message that no importer recognized the
    file."""
    return str(path).lower().endswith(SUFFIX)

save

save(obj: Any, path: str | PathLike, unit_system: Any = None, **_ignored: Any) -> None

Write an object, or a whole project, as a .mat file.

With a unit_system, the values are in that system's units and each unit field names them; with None, the values are SI, exactly as the project file holds them (see the module docstring). .mat is added to path when it does not end in it. Raises ValueError, before anything is written, when an array is larger than the 4 GB a version 5 file allows per variable.

Source code in src/visualdynamics/io/matlab.py
def save(obj: Any, path: str | os.PathLike, unit_system: Any = None,
         **_ignored: Any) -> None:
    """Write an object, or a whole project, as a `.mat` file.

    With a `unit_system`, the values are in that system's units and
    each unit field names them; with None, the values are SI, exactly
    as the project file holds them (see the module docstring). `.mat`
    is added to `path` when it does not end in it. Raises ValueError,
    before anything is written, when an array is larger than the 4 GB
    a version 5 file allows per variable.
    """
    from scipy.io import savemat

    from ..project import Project

    def shown(item):
        return item if unit_system is None else _in_display(item, unit_system)

    with _memory_file() as f:
        if isinstance(obj, Project):
            native.save_test_into(f, obj.name,
                                  {name: shown(item) for name, item in obj.items()},
                                  active_geometry=obj.active_geometry,
                                  project_type=obj.project_type,
                                  object_groups=obj.object_groups, provenance=obj.provenance)
        else:
            native.save_into(shown(obj), f)
        tree = _tree_out(f)
    if unit_system is not None:
        tree[SYSTEM] = unit_system.name
    _check_size(tree)
    path = str(path)
    if not path.endswith(SUFFIX):
        path += SUFFIX
    savemat(path, tree, do_compression=True, long_field_names=True,
            oned_as='column')

load

load(path: str | PathLike, **_ignored: Any) -> Any

Read a .mat written here, or a single struct built to the documented layout of one object.

Returns the object the file holds, or a Project for a whole project. Values written in a display system are converted back to SI. Raises ValueError for a version 7.3 file, for a schema stamp newer than this version reads, and for a file that has no visualdynamics_schema stamp and is not a single struct in the documented layout.

Source code in src/visualdynamics/io/matlab.py
def load(path: str | os.PathLike, **_ignored: Any) -> Any:
    """Read a `.mat` written here, or a single struct built to the
    documented layout of one object.

    Returns the object the file holds, or a `Project` for a whole
    project. Values written in a display system are converted
    back to SI. Raises ValueError for a version 7.3 file, for a schema
    stamp newer than this version reads, and for a file that has no
    `visualdynamics_schema` stamp and is not a single struct in the
    documented layout."""
    from scipy.io import loadmat

    path = str(path)
    try:
        raw = loadmat(path, squeeze_me=False, struct_as_record=False,
                      chars_as_strings=True)
    except NotImplementedError:
        raise ValueError(
            f'{path} is a MATLAB version 7.3 file, which is HDF5; save it '
            "with '-v7', or save the data as .vdyn") from None
    variables = {name: _value_in(value) for name, value in raw.items()
                 if not name.startswith('__')}
    if STAMP not in variables:
        bare = _bare_kind(variables)
        if bare is None:
            found = ', '.join(sorted(variables)) or 'nothing'
            raise ValueError(
                f'{path} is not a Visual Dynamics file (no {STAMP} '
                f'variable; it holds {found}). A struct built to the '
                'documented layout of one object is read too.')
        kind, fields = bare
        variables = {STAMP: native.SCHEMA_VERSION, kind: fields}
    shown = variables.pop(SYSTEM, None)
    with _memory_file() as f:
        _write_tree(f, variables)
        loaded = native.load_from(f, path=path)
    if shown is None:
        return loaded
    for item in (loaded.values() if isinstance(loaded, dict) else [loaded]):
        _from_display(item)
    return loaded