Read and write Rattlesnake's random vibration specification file.
The controller's target for a random test is a small file its Random
environment loads before the test (its .nc4 is the recording of a
run, and is only read here). The file holds f, the frequency lines in
Hz; cpsd, the full cross-spectral matrix of the control channels at
each line, (lines, n, n) complex; optionally warning_upper,
warning_lower, abort_upper and 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. The controller reads it as NumPy .npz or MATLAB
.mat; this module writes and reads the .npz.
The controller reads a whole matrix, and a specification here usually
holds autospectra alone, since that is how most random tests are run.
So a pair the specification does not hold is written as a zero, the way
the controller's own targets have it, and the file records which pairs
were held in held, an (n, n) boolean mask. The controller ignores
held; this reader uses it, so a zero that was never specified does not
read back as a specified zero cross term. A missing Hermitian half is
filled with the conjugate of the half that was held.
The file has no place for units. Values are written in the unit system
asked for, g included: the controller reads the target in the unit of
each channel of its own channel table, which for an accelerometer is
usually g, so the file holds what the plot shows. Reading takes the unit
as a parameter.
The file also has 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; the names are
what bring the specification back exactly.
Functions:
| Name |
Description |
save |
Write a specification as the controller's target file (.npz).
|
load |
Read the controller's target file as a Specification.
|
Classes
Functions:
save
Write a specification as the controller's target file (.npz).
Values, bands included, are written in unit_system itself, g
included, or as stored when there is none or the units are not
defined; 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 which pairs were held.
Raises ValueError when the specification holds no autospectrum, or
holds a cross term of a channel that has no autospectrum.
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 (`.npz`).
Values, bands included, are written in `unit_system` itself, g
included, or as stored when there is none or the units are not
defined; 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 which pairs were held.
Raises ValueError when the specification holds no autospectrum, or
holds a cross term of a channel that has no autospectrum.
"""
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
# The display system itself, not its coherent form: the band-average
# BARC's response specification, exported with in-slinch-lbf-s (g),
# went out in (in/s^2)^2/Hz, 149064 times g^2/Hz, and the controller
# reads it in its channels' g (2026-10-31). The coherent rule is for
# files whose units must agree with one another, a model's; a target
# is read channel by channel
converting = unit_system is not None and spec.units_defined
frequencies = (spec.display_abscissa(unit_system) if converting
else spec.abscissa)
ordinate = (spec.display_ordinate(unit_system) if converting
else spec.ordinate)
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) if converting
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, otherwise from the
diagonal of coordinate. A file with neither, which is what the
controller itself writes, is matched to its channel table by order
alone, so its channels are named in that order, '1', '2' and so on,
as the sine specification reader names them and as a run through a
response transformation records them. A file with held was written by Visual Dynamics, and
the mask says which cross terms were held: those come back, zeros
included, and the rest are left out. In a file without held, the
cross terms are kept only when at least one off-diagonal value is
finite and not zero, as the .nc4 reader does, because an
off-diagonal that is all NaN or zero is how other writers mark a
specification of autospectra alone. ordinate_unit declares the unit
of the values, which the file cannot state.
Raises ValueError when cpsd is not (lines, n, n) over the
frequency lines, or the channel names do not match its size.
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, otherwise from the
diagonal of `coordinate`. A file with neither, which is what the
controller itself writes, is matched to its channel table by order
alone, so its channels are named in that order, '1', '2' and so on,
as the sine specification reader names them and as a run through a
response transformation records them. A file with `held` was written by Visual Dynamics, and
the mask says which cross terms were held: those come back, zeros
included, and the rest are left out. In a file without `held`, the
cross terms are kept only when at least one off-diagonal value is
finite and not zero, as the `.nc4` reader does, because an
off-diagonal that is all NaN or zero is how other writers mark a
specification of autospectra alone. `ordinate_unit` declares the unit
of the values, which the file cannot state.
Raises ValueError when `cpsd` is not `(lines, n, n)` over the
frequency lines, or the channel names do not match its size.
"""
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:
# Refused until Brandon loaded one into a project
# (2026-10-16): the controller's own files name no channels,
# so the format's commonest file could not be read at all.
channels = [str(k + 1) for k in range(n)]
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)
spec.interpolation = Specification.reading_of(frequencies)
return spec
|