Skip to content

visualdynamics.io.rattlesnake_specification

rattlesnake_specification

Rattlesnake's random-vibration specification file, both ways.

The controller's own target for a random test is not its .nc4 (that is the recording of a run, and stays read-only here) but a small file its Random environment loads before the test: f, the frequency lines in Hz; cpsd, the full control-channel cross-spectral matrix at each line, (lines, n, n) complex; optionally warning_upper, warning_lower, abort_upper, abort_lower, (lines, n) real bands on the autospectra; and optionally coordinate, (n, n, 2) node and direction pairs the controller uses to put the matrix in its own channel order. Numpy .npz or MATLAB .mat; this module writes and reads the .npz. (Brandon, 2026-09-05: a specification edited in Visual Dynamics should come out as something Rattlesnake can read.)

The controller reads a whole matrix, and a specification here more often than not holds autospectra alone — that is how most random tests are run (Brandon, 2026-09-05). So a pair the specification does not hold is written as the controller's own targets carry it, a zero, and the file says which those were: held, an (n, n) mask of the cross terms the specification actually held, which the controller ignores and this reader honours, so a zero that was never stated does not come back as a statement of independence. A Hermitian half is completed, as the transform allows. The file has nowhere to record units, so values go out in the unit system asked for, the coherent one, and the status line names it; reading takes the unit as a parameter, as sdynpy's format does.

The file also carries dof, the channel names as Visual Dynamics writes them, which the controller ignores and this reader prefers: coordinate is written only when every channel is a node and a direction, since a modal coordinate (M3) has neither, and the strings are what bring the specification back exactly.

Functions:

Name Description
save

Write a specification as the controller's target file.

load

Read the controller's target file as a Specification.

Classes

Functions:

save

save(
    spec: Specification,
    path: str | PathLike,
    unit_system: UnitSystem | None = None,
) -> None

Write a specification as the controller's target file.

Values go out in unit_system's coherent system, or as stored without one; the file records no units. A pair the specification does not hold is written as a zero, the controller's own placeholder, and held records that it was one.

Source code in src/visualdynamics/io/rattlesnake_specification.py
def save(spec: Specification, path: str | os.PathLike,
         unit_system: UnitSystem | None = None) -> None:
    """Write a specification as the controller's target file.

    Values go out in `unit_system`'s coherent system, or as stored
    without one; the file records no units. A pair the specification
    does not hold is written as a zero, the controller's own
    placeholder, and `held` records that it was one.
    """
    from .exporters import data_values

    channels = _channels(spec)
    references = spec.reference_dof or list(spec.response_dof)
    index = {name: k for k, name in enumerate(channels)}
    held = {}
    for i in range(spec.num_records):
        a, b = spec.response_dof[i], references[i]
        if a not in index or b not in index:
            raise ValueError(f'{a}/{b} is a cross term of a channel with no '
                             'autospectrum in the specification')
        held[(a, b)] = i
    frequencies, ordinate = data_values(spec, unit_system)
    n, lines = len(channels), len(frequencies)
    cpsd = np.zeros((lines, n, n), dtype=np.complex128)
    stated = np.eye(n, dtype=bool)
    for (a, b), i in held.items():
        cpsd[:, index[a], index[b]] = ordinate[i]
        stated[index[a], index[b]] = True
    for a in channels:
        for b in channels:
            if (a, b) not in held and (b, a) in held:
                # the Hermitian half the file needs and the object did
                # not hold: the conjugate of the one it did
                cpsd[:, index[a], index[b]] = np.conj(cpsd[:, index[b], index[a]])
                stated[index[a], index[b]] = True
    out: dict[str, Any] = {'f': np.asarray(frequencies, dtype=np.float64),
                           'cpsd': cpsd,
                           'dof': np.asarray(channels, dtype=str),
                           'held': stated}
    for name in BANDS:
        if name not in spec.limits:
            continue
        values = (spec.display_limit(name, unit_system.coherent)
                  if unit_system is not None and spec.units_defined
                  else spec.limits[name])
        out[name] = np.stack([np.real(values[held[(c, c)]])
                              for c in channels], axis=1)
    parsed = [parse_dof(c) for c in channels]
    if all(node is not None and direction for node, direction in parsed):
        coordinate = np.zeros((n, n, 2), dtype=_COORDINATE)
        for i, (node_i, dir_i) in enumerate(parsed):
            for j, (node_j, dir_j) in enumerate(parsed):
                coordinate[i, j, 0] = (node_i, direction_code(dir_i))
                coordinate[i, j, 1] = (node_j, direction_code(dir_j))
        out['coordinate'] = coordinate
    np.savez(str(path), **out)

load

load(
    path: str | PathLike, ordinate_unit: str | None = None
) -> Specification

Read the controller's target file as a Specification.

Channels come from dof when the file has it, else from the coordinate diagonal; a file with neither names nothing, and is refused rather than given invented names. A file with held was written here, and that mask says which cross terms were stated: those come back, zeros included, and the rest were placeholders. Any other file is the controller's or sdynpy's, and its cross terms are kept only when one is a meaningful number, as the .nc4 reader does — an off-diagonal all NaN or zero is their placeholder for autospectra alone. ordinate_unit declares what the values are in — the file cannot say.

Source code in src/visualdynamics/io/rattlesnake_specification.py
def load(path: str | os.PathLike,
         ordinate_unit: str | None = None) -> Specification:
    """Read the controller's target file as a Specification.

    Channels come from `dof` when the file has it, else from the
    `coordinate` diagonal; a file with neither names nothing, and is
    refused rather than given invented names. A file with `held` was
    written here, and that mask says which cross terms were stated:
    those come back, zeros included, and the rest were placeholders.
    Any other file is the controller's or sdynpy's, and its cross
    terms are kept only when one is a meaningful number, as the
    `.nc4` reader does — an off-diagonal all NaN or zero is their
    placeholder for autospectra alone. `ordinate_unit` declares what
    the values are in — the file cannot say.
    """
    with np.load(path, allow_pickle=False) as d:
        frequencies = np.asarray(d['f'], dtype=np.float64).reshape(-1)
        cpsd = np.asarray(d['cpsd'])
        if cpsd.ndim != 3 or cpsd.shape[1] != cpsd.shape[2] \
                or cpsd.shape[0] != len(frequencies):
            raise ValueError(f'{os.path.basename(str(path))}: cpsd is '
                             f'{cpsd.shape}, not (lines, n, n) over '
                             f'{len(frequencies)} lines')
        n = cpsd.shape[1]
        held = (np.asarray(d['held'], dtype=bool) if 'held' in d.files
                else None)
        if 'dof' in d.files:
            channels = [str(c) for c in d['dof']]
        elif 'coordinate' in d.files:
            diagonal = np.asarray(d['coordinate'])
            channels = [dof_string(int(diagonal[i, i, 0]['node']),
                                   int(diagonal[i, i, 0]['direction']))
                        for i in range(n)]
        else:
            raise ValueError(f'{os.path.basename(str(path))} names no '
                             'channels — neither a dof list nor a '
                             'coordinate array — and the controller matches '
                             'it to its channel table by order alone')
        if len(channels) != n:
            raise ValueError(f'{len(channels)} channel names for a '
                             f'{n}-channel matrix')
        bands = {name: np.asarray(d[name], dtype=np.float64)
                 for name in BANDS if name in d.files}
    off = ~np.eye(n, dtype=bool)
    if held is None:
        meaningful = bool(np.any(np.isfinite(cpsd[:, off])
                                 & (cpsd[:, off] != 0)))
        held = np.eye(n, dtype=bool) | (
            meaningful & np.isfinite(cpsd).any(axis=0))
    rows, resp, refs, limits = [], [], [], {name: [] for name in bands}
    blank = np.full(len(frequencies), np.nan)
    for i in range(n):
        for j in range(n):
            if not held[i, j]:
                continue
            rows.append(cpsd[:, i, j])
            resp.append(channels[i])
            refs.append(channels[j])
            for name, values in bands.items():
                limits[name].append(values[:, i] if i == j else blank)
    spec = Specification(
        frequencies, np.asarray(rows, dtype=np.complex128),
        response_dof=resp, reference_dof=refs,
        comment=f'Rattlesnake specification {os.path.basename(str(path))}',
        **{name: np.asarray(values) for name, values in limits.items()})
    if ordinate_unit is not None:
        spec.define_units(ordinate_unit)
    return spec