Skip to content

visualdynamics.core

core

The data model: what a test holds, with no Qt anywhere.

Geometry, the data arrays, shapes, channel tables, and the computations over them. Everything here works in a script, in a notebook and in the app equally, because none of it knows which it is in — the app is a view onto these objects, not the place they live. The guide's The objects maps every kind — what it stores, where it comes from, what can be done with it — and points back here for the signatures.

Classes:

Name Description
ChannelTable

Per-channel metadata, one fixed column set, each column typed.

DataArray

Base class; use a concrete subclass (TimeHistory, Spectrum, Frf, Psd).

Frf

Frequency response function: response per unit reference.

Psd

Power spectral density: the declared unit is the engineering unit

ShockSpecification

What a shock test was controlled to: an SRS, and its band.

Specification

What a random vibration test was controlled to: a PSD, and its band.

Spectrum

A linear spectrum: amplitude and phase at each frequency line.

Srs

A shock response spectrum: the peak an oscillator reached.

TimeHistory

A measurement against time: the record as it was acquired.

TransientSpecification

What a transient test was controlled to: a target time history.

Geometry

Nodes, coordinate systems, tracelines, elements and blocks.

ShapeSet

Mode shapes over a shared set of DOFs.

Classes

ChannelTable

ChannelTable(columns: Mapping[str, Any] | DataFrame)

Per-channel metadata, one fixed column set, each column typed.

Attributes: frame: The table, one row per channel, columns in SCHEMA order. Every cell is text except channel and node, which are integers — a serial number that happens to be all digits is not a number, and a column read back as int64 would lose its leading zero and come out of a round trip a different string.

Methods:

Name Description
set_cell

Write one cell; a value the column cannot hold is refused.

units_for

The units this channel could be in, given its declared type.

delete_channels

Remove the given rows in place; the last one is refused.

dof_strings

Each channel's degree of freedom, as '101Z+' strings.

rename_dof

Give the channel at coordinate old the coordinate new,

roles

Each channel's declared role, '' where undeclared.

controls

Which channels are control channels, as booleans.

types

What each channel measures, '' where undeclared.

sensitivities

mV per engineering unit, NaN where undeclared.

ranges

The instrumentation voltage limit, per channel.

save

Write the table to a file of its own.

Attributes:

Name Type Description
num_channels int

How many channels the table describes — one per row.

column_names list[str]

The table's column headings, in order.

Source code in src/visualdynamics/core/channel_table.py
def __init__(self, columns: Mapping[str, Any] | pd.DataFrame) -> None:
    import pandas as pd

    if isinstance(columns, pd.DataFrame):
        frame = columns.copy()
    else:
        lengths = {name: len(values) for name, values in columns.items()}
        if len(set(lengths.values())) > 1:
            first = next(iter(lengths.values()))
            bad = next(name for name, n in lengths.items() if n != first)
            raise ValueError(
                f'column {bad!r} has {lengths[bad]} entries, '
                f'expected {first}')
        frame = pd.DataFrame(dict(columns))
    frame = frame.rename(columns={name: canonical_name(name)
                                  for name in frame.columns})
    # a source naming one column two ways keeps the first
    frame = frame.loc[:, ~frame.columns.duplicated()]
    for name in self.CORE:
        if name not in frame.columns:
            raise ValueError(f'missing core column {name!r}')
    for name, spec in self.COLUMNS.items():
        if name not in frame.columns:
            frame[name] = spec.default
    # the schema and nothing else: an older file's extras, and a
    # controller's run settings, stop here
    self.frame: pd.DataFrame = self._typed(frame[list(self.SCHEMA)])
Attributes
num_channels property
num_channels: int

How many channels the table describes — one per row.

column_names property
column_names: list[str]

The table's column headings, in order.

Methods:
set_cell
set_cell(name: str, row: int, value: object) -> None

Write one cell; a value the column cannot hold is refused.

Strict where _typed is lenient: a person typing gets the reason, where a file gets the benefit of the doubt.

Parameters:

Name Type Description Default
name str

The column to write.

required
row int

Which channel.

required
value object

The value, refused if the column cannot hold it.

required

Returns:

Type Description
None
Source code in src/visualdynamics/core/channel_table.py
def set_cell(self, name: str, row: int, value: object) -> None:
    """Write one cell; a value the column cannot hold is refused.

    Strict where `_typed` is lenient: a person typing gets the
    reason, where a file gets the benefit of the doubt.

    Parameters
    ----------
    name : str
        The column to write.
    row : int
        Which channel.
    value : object
        The value, refused if the column cannot hold it.

    Returns
    -------
    None
    """
    if name not in self.COLUMNS:
        raise KeyError(f'no column {name!r}')
    spec = self.COLUMNS[name]
    coerced = self._coerce(name, value)
    if spec.unique and coerced != '':
        clash = np.flatnonzero(self[name] == coerced)
        if len(clash) and clash[0] != row:
            raise ValueError(
                f'{name} {coerced} already exists — it is the join key '
                'and must be unique')
    if name == 'role' and coerced != 'response' and self.controls()[row]:
        raise ValueError('this channel is marked Control, and a control '
                         'channel is a response — uncheck Control first')
    if name == 'control' and _parse_flag(str(coerced)) and str(
            self.frame.loc[row, 'role']) != 'response':
        raise ValueError('a control channel is a response — set the '
                         'role first')
    if name == 'unit' and coerced:
        self._agrees_with_type(row, str(coerced))
    self.frame.loc[row, name] = coerced
    if name == 'channel_type':
        # a unit of some other kind is no longer an answer here —
        # the same withdrawal the Imported Units pane makes
        unit = str(self.frame.loc[row, 'unit'])
        if unit and coerced:
            from ..units import dimension_of
            if dimension_of(unit) != coerced:
                self.frame.loc[row, 'unit'] = ''
units_for
units_for(row: int) -> list[str]

The units this channel could be in, given its declared type.

Parameters:

Name Type Description Default
row int

Which channel.

required

Returns:

Type Description
list of str

The units this channel could be in, given its declared type.

Source code in src/visualdynamics/core/channel_table.py
def units_for(self, row: int) -> list[str]:
    """The units this channel could be in, given its declared type.

    Parameters
    ----------
    row : int
        Which channel.

    Returns
    -------
    list of str
        The units this channel could be in, given its
        declared type.
    """
    from .unit_choices import ALL_ORDINATE_UNITS, ORDINATE_UNITS

    declared = str(self.frame.loc[row, 'channel_type'])
    return list(ORDINATE_UNITS.get(declared, ALL_ORDINATE_UNITS))
delete_channels
delete_channels(indices: Sequence[int]) -> None

Remove the given rows in place; the last one is refused.

Parameters:

Name Type Description Default
indices sequence of int

Which rows to remove.

required

Returns:

Type Description
None
Source code in src/visualdynamics/core/channel_table.py
def delete_channels(self, indices: Sequence[int]) -> None:
    """Remove the given rows in place; the last one is refused.

    Parameters
    ----------
    indices : sequence of int
        Which rows to remove.

    Returns
    -------
    None
    """
    doomed = {int(i) for i in indices}
    bad = [i for i in doomed if not 0 <= i < self.num_channels]
    if bad:
        raise IndexError(f'no such channels: {sorted(bad)}')
    keep = [i for i in range(self.num_channels) if i not in doomed]
    if not keep:
        raise ValueError('cannot delete every channel; delete the '
                         'object instead')
    self.frame = self.frame.iloc[keep].reset_index(drop=True)
dof_strings
dof_strings() -> list[str]

Each channel's degree of freedom, as '101Z+' strings.

Source code in src/visualdynamics/core/channel_table.py
def dof_strings(self) -> list[str]:
    """Each channel's degree of freedom, as '101Z+' strings."""
    return [f'{node}{direction}' for node, direction
            in zip(self.frame['node'], self.frame['direction'])]
rename_dof
rename_dof(
    old: str, new: str, quantity: str | None = None
) -> int

Give the channel at coordinate old the coordinate new, in place — its node and direction cells rewritten, since a DOF string is those two concatenated. The same correction a data array's rename_dof makes, for the table that names the channels (Brandon, 2026-09-06), and the same rule: the channel moves, not every channel at the point.

Parameters:

Name Type Description Default
old str

The coordinate as the table has it, '101Z+'.

required
new str

The coordinate to give it: a node number then a direction, normalised the way every DOF is, and refused when it is not one — a table row may lack a node or a direction, but a correction typed by a person is whole.

required
quantity str

Which channel at old, by the quantity its unit names ('acceleration', 'force' …; 'unknown' where the unit names none). Every channel at the coordinate when omitted.

None

Returns:

Type Description
int

How many channels changed.

Source code in src/visualdynamics/core/channel_table.py
def rename_dof(self, old: str, new: str,
               quantity: str | None = None) -> int:
    """Give the channel at coordinate `old` the coordinate `new`,
    in place — its node and direction cells rewritten, since a DOF
    string is those two concatenated. The same correction a data
    array's `rename_dof` makes, for the table that names the
    channels (Brandon, 2026-09-06), and the same rule: the channel
    moves, not every channel at the point.

    Parameters
    ----------
    old : str
        The coordinate as the table has it, '101Z+'.
    new : str
        The coordinate to give it: a node number then a direction,
        normalised the way every DOF is, and refused when it is not
        one — a table row may lack a node or a direction, but a
        correction typed by a person is whole.
    quantity : str, optional
        Which channel at `old`, by the quantity its unit names
        ('acceleration', 'force' …; 'unknown' where the unit names
        none). Every channel at the coordinate when omitted.

    Returns
    -------
    int
        How many channels changed.
    """
    from ..units import dimension_of
    from .data import parse_dof
    from .validate import dofs

    old = str(old).strip()
    (new,) = dofs([str(new)], 'DOF')
    node, direction = parse_dof(new)
    if node is None:
        raise ValueError(f'{new!r} has no node number')
    units = [str(u).strip() for u in self.frame['unit']]
    named = [(dimension_of(u) if u else None) or 'unknown' for u in units]
    rows = [i for i, dof in enumerate(self.dof_strings())
            if dof == old and quantity in (None, named[i])]
    if not rows:
        raise ValueError(f'no {quantity + " " if quantity else ""}channel '
                         f'at {old!r}')
    if new == old:
        return 0
    for i in rows:
        self.set_cell('node', i, node)
        self.set_cell('direction', i, direction)
    return len(rows)
roles
roles() -> list[str]

Each channel's declared role, '' where undeclared.

Source code in src/visualdynamics/core/channel_table.py
def roles(self) -> list[str]:
    """Each channel's declared role, '' where undeclared."""
    return [str(v) for v in self.frame['role']]
controls
controls() -> ndarray

Which channels are control channels, as booleans.

Source code in src/visualdynamics/core/channel_table.py
def controls(self) -> np.ndarray:
    """Which channels are control channels, as booleans."""
    return np.array([_parse_flag(str(v)) for v in self.frame['control']])
types
types() -> list[str]

What each channel measures, '' where undeclared.

Source code in src/visualdynamics/core/channel_table.py
def types(self) -> list[str]:
    """What each channel measures, '' where undeclared."""
    return [str(v) for v in self.frame['channel_type']]
sensitivities
sensitivities() -> ndarray

mV per engineering unit, NaN where undeclared.

Source code in src/visualdynamics/core/channel_table.py
def sensitivities(self) -> np.ndarray:
    """mV per engineering unit, NaN where undeclared."""
    return np.array([float(v) if str(v).strip() else np.nan
                     for v in self.frame['sensitivity']])
ranges
ranges() -> ndarray

The instrumentation voltage limit, per channel.

Source code in src/visualdynamics/core/channel_table.py
def ranges(self) -> np.ndarray:
    """The instrumentation voltage limit, per channel."""
    return np.array([int(v) for v in self.frame['range']])
save
save(path: str | PathLike) -> None

Write the table to a file of its own.

Parameters:

Name Type Description Default
path str or PathLike

Where to write it.

required

Returns:

Type Description
None
Source code in src/visualdynamics/core/channel_table.py
def save(self, path: str | os.PathLike) -> None:
    """Write the table to a file of its own.

    Parameters
    ----------
    path : str or os.PathLike
        Where to write it.

    Returns
    -------
    None
    """
    from ..io import native
    native.save(self, path)

DataArray

DataArray(
    abscissa: ArrayLike,
    ordinate: ArrayLike,
    response_dof: str | Sequence[str],
    reference_dof: str | Sequence[str] | None = None,
    ordinate_dim: str | Sequence[str] | None = None,
    comment: str | Sequence[str] | None = None,
    ordinate_unit: str | Sequence[str | None] | None = None,
    reference_unit: str
    | Sequence[str | None]
    | None = None,
    dimension_hint: str
    | Sequence[str | None]
    | None = None,
    block: str | Sequence[str] | None = None,
)

Base class; use a concrete subclass (TimeHistory, Spectrum, Frf, Psd).

One object holds many records — 36 accelerometer channels, or the 2 592 FRFs of a 36-by-72 matrix — sharing one abscissa. Everything that varies between records is a list of that length, so record i is ordinate[i] measured at response_dof[i], and there is no per-record object to go stale.

Values are stored in SI once their units are known. A record whose units were never declared keeps the file's raw numbers and reports ordinate_dim == 'unknown'; what the file said it was, without saying its scale, is kept beside it in dimension_hint.

Attributes: abscissa: The x axis, shared by every record — seconds for a time history, hertz for anything in the frequency domain. Stored as it arrived: uneven spacing and out-of-order samples are both allowed, because both are real and refusing them at the door would refuse real data. What needs an even step — anything with an FFT under it — asks for one at the point of use and says so when it cannot have it. ordinate: (records, len(abscissa)). Complex where the subclass says so (complex_ordinate), real otherwise. response_dof: What each record was measured at, as a DOF string ('101X+'). One per record. reference_dof: What each record was measured against, for the types that need one — the shaker on an FRF, the other channel of a cross spectrum. None where the type has no reference. block: Which repeat of the same measurement each record is: an average, a run, a shock. A short label, not a time. ordinate_dim: The quantity each record measures ('acceleration', 'force'), or 'unknown' while its units are undeclared. ordinate_unit: The unit its values are in — always the SI one while the dimension is known, since that is how they are stored. None means undeclared. reference_unit: The same for the reference of a ratio, so an FRF record knows both halves of m/s²/N. dimension_hint: What the file claimed a record measures without saying at what scale. Nothing is ever scaled by a hint: it narrows the units offered, labels an axis, and survives export. comment: Free text per record, as the source file carried it.

Methods:

Name Description
known_dim

What quantity record i holds, whether or not its unit is known.

rename_dof

Give a channel's coordinate a new name, in place.

delete_records

Remove records in place — by index, by DOF, or by capture.

define_units

Declare what the ordinate values are in, converting them to SI.

undefine_units

Take a declaration back, restoring the file's raw values.

column_keys

What tells one record from another besides its response.

record_label

A short label for one record, for a legend or an axis.

record_pair

The (response, reference) record i is between.

log_scaled

Whether this object's magnitude reads on a log axis.

display_abscissa

The abscissa converted into a unit system's own units.

display_ordinate

Ordinate in display units; undefined records pass through as-is.

save

Write this object to a .vdyn file of its own.

plot

Draw every record on one set of axes.

save_plot

Draw the records and write the figure to path.

plot_waterfall

The records spread along a depth axis, coloured by level —

Attributes:

Name Type Description
num_records int

How many records this object holds. One measurement per

units_defined bool

Whether every record knows what it measures. False while

undefined_records list[int]

Which records still have no declared dimension — the ones

Source code in src/visualdynamics/core/data.py
def __init__(self, abscissa: ArrayLike, ordinate: ArrayLike,
             response_dof: str | Sequence[str],
             reference_dof: str | Sequence[str] | None = None,
             ordinate_dim: str | Sequence[str] | None = None,
             comment: str | Sequence[str] | None = None,
             ordinate_unit: str | Sequence[str | None] | None = None,
             reference_unit: str | Sequence[str | None] | None = None,
             dimension_hint: str | Sequence[str | None] | None = None,
             block: str | Sequence[str] | None = None) -> None:
    self.abscissa: np.ndarray = np.asarray(abscissa, dtype=np.float64)
    ordinate = np.atleast_2d(np.asarray(
        ordinate, dtype=np.complex128 if self.complex_ordinate else np.float64))
    self.ordinate: np.ndarray = ordinate
    n = ordinate.shape[0]
    if self.abscissa.ndim != 1 or ordinate.shape[1] != len(self.abscissa):
        raise ValueError(
            f'ordinate shape {ordinate.shape} does not match '
            f'abscissa length {len(self.abscissa)}')
    # a channel whose node or direction was never recorded imports
    # as a DOF of '' — see `validate.dofs`
    self.response_dof: list[str] = _dofs(
        self._str_list(response_dof, n, 'response_dof'), 'response DOF',
        allow_unknown=True)
    self.reference_dof: list[str] | None
    if reference_dof is None:
        if self.needs_reference:
            raise ValueError(f'{type(self).__name__} requires reference_dof')
        self.reference_dof = None
    else:
        self.reference_dof = _dofs(
            self._str_list(reference_dof, n, 'reference_dof'),
            'reference DOF', allow_unknown=True)
    # A record is its response plus, sometimes, one more thing. For a
    # measurement against something else that is the reference DOF; for
    # the same measurement repeated it is which repeat — an average, a
    # run, a temperature. `block` holds the short label of that second
    # kind, and nothing about it is time-specific.
    self.block: list[str] | None = (None if block is None
                                    else self._str_list(block, n, 'block'))
    self.ordinate_dim: list[str] = self._str_list(
        UNKNOWN if ordinate_dim is None else ordinate_dim, n, 'ordinate_dim')
    for dim in set(self.ordinate_dim):
        parse_dimension(dim)  # validates
    self.ordinate_unit: list[str | None] = self._opt_list(
        ordinate_unit, n, 'ordinate_unit')
    self.reference_unit: list[str | None] = self._opt_list(
        reference_unit, n, 'reference_unit')
    self.comment: list[str] = self._str_list(
        comment if comment is not None else '', n, 'comment')
    self.dimension_hint: list[str | None] = self._opt_list(
        dimension_hint, n, 'dimension_hint')
    for hint in set(self.dimension_hint):
        if hint is not None:
            parse_dimension(hint)  # validates
    self._fill_si_units()
    self._drop_stale_hints()
Attributes
num_records property
num_records: int

How many records this object holds. One measurement per record, all sharing the object's single abscissa.

units_defined property
units_defined: bool

Whether every record knows what it measures. False while any record is still in the file's own unconverted numbers.

undefined_records property
undefined_records: list[int]

Which records still have no declared dimension — the ones holding the file's raw numbers, awaiting define_units.

Methods:
known_dim
known_dim(i: int) -> str

What quantity record i holds, whether or not its unit is known.

The dimension when the units are defined, otherwise the source's claim, otherwise 'unknown'. Never use this to scale anything — a hinted record's values are raw, and the scale is exactly what is missing.

Parameters:

Name Type Description Default
i int

Which record.

required

Returns:

Type Description
str

The quantity the record holds, falling back to its dimension hint when the unit is undeclared.

Source code in src/visualdynamics/core/data.py
def known_dim(self, i: int) -> str:
    """What quantity record `i` holds, whether or not its unit is known.

    The dimension when the units are defined, otherwise the source's
    claim, otherwise 'unknown'. Never use this to scale anything — a
    hinted record's values are raw, and the scale is exactly what is
    missing.

    Parameters
    ----------
    i : int
        Which record.

    Returns
    -------
    str
        The quantity the record holds, falling back to its
        dimension hint when the unit is undeclared.
    """
    if self.ordinate_dim[i] != UNKNOWN:
        return self.ordinate_dim[i]
    return self.dimension_hint[i] or UNKNOWN
rename_dof
rename_dof(
    old: str, new: str, quantity: str | None = None
) -> int

Give a channel's coordinate a new name, in place.

A channel is a coordinate and a quantity — a drive point carries a load cell and an accelerometer at one DOF — and the rename is the channel's: a force labelled at the wrong node moves without taking the accelerometer at that node with it, and the other is changed explicitly if it should be (Brandon, 2026-09-06). The channel moves wherever a record wears it, as a response and as a reference alike: a CPSD's accelerometer is on both sides of its cross terms and is one sensor. A rename that would give two records one identity — two accelerometers at one point — is refused, where it would have made two rows of the grid into one and hidden a record.

Parameters:

Name Type Description Default
old str

The coordinate as it is, '101Z+'.

required
new str

The coordinate to give it; normalised the way every DOF is ('101Z' is '101Z+'), and refused when it is not one.

required
quantity str

Which channel at old: 'acceleration', 'force' … as channel_quantities names a record's factors. Every quantity at the coordinate when omitted — the point rather than the channel, said explicitly.

None

Returns:

Type Description
int

How many records changed, counting a response and a reference on one record separately.

Source code in src/visualdynamics/core/data.py
def rename_dof(self, old: str, new: str,
               quantity: str | None = None) -> int:
    """Give a channel's coordinate a new name, in place.

    A channel is a coordinate *and* a quantity — a drive point
    carries a load cell and an accelerometer at one DOF — and the
    rename is the channel's: a force labelled at the wrong node
    moves without taking the accelerometer at that node with it,
    and the other is changed explicitly if it should be (Brandon,
    2026-09-06). The channel moves wherever a record wears it, as
    a response and as a reference alike: a CPSD's accelerometer is
    on both sides of its cross terms and is one sensor. A rename
    that would give two records one identity — two accelerometers
    at one point — is refused, where it would have made two rows of
    the grid into one and hidden a record.

    Parameters
    ----------
    old : str
        The coordinate as it is, '101Z+'.
    new : str
        The coordinate to give it; normalised the way every DOF is
        ('101Z' is '101Z+'), and refused when it is not one.
    quantity : str, optional
        Which channel at `old`: 'acceleration', 'force' … as
        `channel_quantities` names a record's factors. Every
        quantity at the coordinate when omitted — the point rather
        than the channel, said explicitly.

    Returns
    -------
    int
        How many records changed, counting a response and a
        reference on one record separately.
    """
    old = str(old).strip()
    (new,) = _dofs([str(new)], 'DOF')
    if new == old:
        return 0
    factors = [channel_quantities(self.known_dim(i))
               for i in range(self.num_records)]
    rows = [i for i, dof in enumerate(self.response_dof)
            if dof == old and quantity in (None, factors[i][0])]
    columns = [i for i, dof in enumerate(self.reference_dof or [])
               if dof == old and quantity in (None, factors[i][1])]
    if not rows and not columns:
        raise ValueError(f'no {quantity + " " if quantity else ""}record '
                         f'at {old!r}')
    moving = {factors[i][0] for i in rows}
    for i, dof in enumerate(self.response_dof):
        if dof == new and factors[i][0] in moving:
            raise ValueError(
                f'{new} already has a {factors[i][0]} record — two '
                'channels cannot share a coordinate and a quantity')
    for i in rows:
        self.response_dof[i] = new
    for i in columns:
        self.reference_dof[i] = new
    return len(rows) + len(columns)
delete_records
delete_records(
    indices: Sequence[int] | None = None,
    *,
    dof: str | Sequence[str] | None = None,
    dim: str | Sequence[str] | None = None,
    reference: str | Sequence[str] | None = None,
    capture: int | Sequence[int] | None = None,
) -> None

Remove records in place — by index, by DOF, or by capture.

Everything a record owns goes with it: its row of the ordinate, its DOFs, units, comment, hint, block — and, on a specification, its limit curves, which would otherwise silently belong to the wrong channels. Removing the last record is refused: an empty data array is not a state anything else here can show.

dof and capture are the selectors a person means — "drop channel 101Z+", "drop the third run" — where indices are the machine's (Brandon, 2026-08-30, reading thirteen indices in the journal where one capture number would have said it). They combine as an intersection, and either combines with explicit indices as a union.

Parameters:

Name Type Description Default
indices sequence of int

Which records to remove, by position.

None
dof str or sequence of str

Remove every record at these response DOFs. A drive point carries two records at one DOF — a force and an acceleration — and the DOF alone takes both; dim is how the finer thing is said.

None
dim str or sequence of str

Restrict to these quantities ('force', 'acceleration', …) — the other half of a channel's identity.

None
reference str or sequence of str

Remove every record at these reference DOFs — a column of an FRF matrix, where dof is a row.

None
capture int or sequence of int

Remove these captures — each channel's n-th playing, the numbering capture_indices gives. Time histories only.

None

Returns:

Type Description
None
Source code in src/visualdynamics/core/data.py
def delete_records(self, indices: Sequence[int] | None = None, *,
                   dof: str | Sequence[str] | None = None,
                   dim: str | Sequence[str] | None = None,
                   reference: str | Sequence[str] | None = None,
                   capture: int | Sequence[int] | None = None) -> None:
    """Remove records in place — by index, by DOF, or by capture.

    Everything a record owns goes with it: its row of the ordinate, its
    DOFs, units, comment, hint, block — and, on a specification, its
    limit curves, which would otherwise silently belong to the wrong
    channels. Removing the last record is refused: an empty data array
    is not a state anything else here can show.

    `dof` and `capture` are the selectors a person means — "drop
    channel 101Z+", "drop the third run" — where indices are the
    machine's (Brandon, 2026-08-30, reading thirteen indices in the
    journal where one capture number would have said it). They
    combine as an intersection, and either combines with explicit
    indices as a union.

    Parameters
    ----------
    indices : sequence of int, optional
        Which records to remove, by position.
    dof : str or sequence of str, optional
        Remove every record at these response DOFs. A drive point
        carries two records at one DOF — a force and an
        acceleration — and the DOF alone takes both; `dim` is how
        the finer thing is said.
    dim : str or sequence of str, optional
        Restrict to these quantities ('force', 'acceleration', …)
        — the other half of a channel's identity.
    reference : str or sequence of str, optional
        Remove every record at these reference DOFs — a column of
        an FRF matrix, where `dof` is a row.
    capture : int or sequence of int, optional
        Remove these captures — each channel's n-th playing, the
        numbering `capture_indices` gives. Time histories only.

    Returns
    -------
    None
    """
    doomed = {int(i) for i in indices} if indices is not None else set()
    if dof is not None or dim is not None or reference is not None \
            or capture is not None:
        wanted_dofs = ({dof} if isinstance(dof, str)
                       else None if dof is None else {str(d) for d in dof})
        wanted_dims = ({dim} if isinstance(dim, str)
                       else None if dim is None else {str(d) for d in dim})
        wanted_references = None
        if reference is not None:
            if self.reference_dof is None:
                raise ValueError(
                    'references are a matrix\'s; this '
                    f'{type(self).__name__} has none')
            wanted_references = ({reference}
                                 if isinstance(reference, str)
                                 else {str(r) for r in reference})
        wanted_captures = None
        if capture is not None:
            ordinals = getattr(self, 'capture_indices', None)
            if ordinals is None:
                raise ValueError(
                    'captures are a time history\'s; this is a '
                    f'{type(self).__name__}')
            ordinals = ordinals()
            wanted_captures = ({int(capture)}
                               if isinstance(capture, (int, np.integer))
                               else {int(c) for c in capture})
        chosen = []
        for i in range(self.num_records):
            if wanted_dofs is not None \
                    and self.response_dof[i] not in wanted_dofs:
                continue
            if wanted_dims is not None \
                    and self.ordinate_dim[i] not in wanted_dims:
                continue
            if wanted_references is not None \
                    and self.reference_dof[i] not in wanted_references:
                continue
            if wanted_captures is not None \
                    and ordinals[i] not in wanted_captures:
                continue
            chosen.append(i)
        if not chosen:
            raise ValueError('nothing matches that selection')
        doomed |= set(chosen)
    bad = [i for i in doomed if not 0 <= i < self.num_records]
    if bad:
        raise IndexError(f'no such records: {sorted(bad)}')
    keep = [i for i in range(self.num_records) if i not in doomed]
    if not keep:
        raise ValueError('cannot delete every record; delete the '
                         'object instead')
    self.ordinate = self.ordinate[keep]
    for name in ('response_dof', 'reference_dof', 'block',
                 'ordinate_dim', 'ordinate_unit', 'reference_unit',
                 'comment', 'dimension_hint'):
        values = getattr(self, name)
        if values is not None:
            setattr(self, name, [values[i] for i in keep])
    for name, values in getattr(self, 'limits', {}).items():
        self.limits[name] = values[keep]
    if self.block is not None:
        # repeated-measurement labels renumber to contiguous —
        # 'avg 1, avg 3' after deleting the second capture is a
        # gap the tree's columns would faithfully show (Brandon,
        # 2026-08-30) — by the merge rule, which is the one
        # implementation of what a block label is: bookkeeping,
        # not identity. Named blocks (an exodus 'KE') survive.
        from .merge import _merged_blocks

        self.block = _merged_blocks([self])
define_units
define_units(
    units: str | Sequence[str | None],
    reference_units: str
    | Sequence[str | None]
    | None = None,
) -> DataArray

Declare what the ordinate values are in, converting them to SI.

units is a single unit applied to every record, a sequence with one entry per record, or a {record index: unit} mapping to set only some. Entries of None leave a record's units undefined. Records that already have units are reinterpreted, not re-scaled twice.

Parameters:

Name Type Description Default
units str or sequence of str

The unit each record's values are in; one string applies to every record.

required
reference_units str or sequence of str

The denominator unit, for records that have one.

None

Returns:

Type Description
DataArray

Self, converted to SI in place.

Source code in src/visualdynamics/core/data.py
def define_units(self, units: str | Sequence[str | None],
                 reference_units: str | Sequence[str | None] | None
                 = None) -> DataArray:
    """Declare what the ordinate values are in, converting them to SI.

    `units` is a single unit applied to every record, a sequence with one
    entry per record, or a {record index: unit} mapping to set only some.
    Entries of None leave a record's units undefined. Records that already
    have units are reinterpreted, not re-scaled twice.

    Parameters
    ----------
    units : str or sequence of str
        The unit each record's values are in; one string applies to
        every record.
    reference_units : str or sequence of str, optional
        The denominator unit, for records that have one.

    Returns
    -------
    DataArray
        Self, converted to SI in place.
    """
    specs = self._expand(units, 'units')
    refs = self._expand(reference_units, 'reference_units')
    for i, unit in enumerate(specs):
        if unit is None:
            continue
        reference = refs[i]
        scale, offset, dim = self._conversion(unit, reference)
        old_scale, old_offset = self._current_transform(i)
        for values in self._value_arrays():
            raw = (values[i] - old_offset) / old_scale
            values[i] = raw * scale + offset
        self.ordinate_dim[i] = dim
        self.ordinate_unit[i] = unit
        self.reference_unit[i] = reference
    self._drop_stale_hints()
    return self
undefine_units
undefine_units(
    records: Sequence[int] | None = None,
) -> DataArray

Take a declaration back, restoring the file's raw values.

The inverse of define_units. A wrong guess should be correctable without reimporting, and that means being able to withdraw one, not only to replace it — there is no unit string meaning 'I no longer know'.

Parameters:

Name Type Description Default
records sequence of int

Which records to revert. All of them when omitted.

None

Returns:

Type Description
DataArray

Self, with the file's raw values restored.

Source code in src/visualdynamics/core/data.py
def undefine_units(self, records: Sequence[int] | None = None) -> DataArray:
    """Take a declaration back, restoring the file's raw values.

    The inverse of `define_units`. A wrong guess should be correctable
    without reimporting, and that means being able to withdraw one, not
    only to replace it — there is no unit string meaning 'I no longer
    know'.

    Parameters
    ----------
    records : sequence of int, optional
        Which records to revert. All of them when omitted.

    Returns
    -------
    DataArray
        Self, with the file's raw values restored.
    """
    for i in (range(self.num_records) if records is None
              else [int(r) for r in records]):
        if self.ordinate_unit[i] is None:
            continue
        old_scale, old_offset = self._current_transform(i)
        for values in self._value_arrays():
            values[i] = (values[i] - old_offset) / old_scale
        self.ordinate_dim[i] = UNKNOWN
        self.ordinate_unit[i] = None
        self.reference_unit[i] = None
    return self
column_keys
column_keys() -> list[str] | None

What tells one record from another besides its response.

The reference DOF when records are a matrix of measurements, the block when they are the same measurement repeated, None when the response alone is the whole identity. This is what decides whether an object expands into a grid.

Source code in src/visualdynamics/core/data.py
def column_keys(self) -> list[str] | None:
    """What tells one record from another besides its response.

    The reference DOF when records are a matrix of measurements, the
    block when they are the same measurement repeated, None when the
    response alone is the whole identity. This is what decides whether
    an object expands into a grid.
    """
    return self.reference_dof if self.reference_dof is not None else self.block
record_label
record_label(i: int) -> str

A short label for one record, for a legend or an axis.

Parameters:

Name Type Description Default
i int

Which record.

required

Returns:

Type Description
str

The record's DOF, plus whatever tells it from its neighbours.

Source code in src/visualdynamics/core/data.py
def record_label(self, i: int) -> str:
    """A short label for one record, for a legend or an axis.

    Parameters
    ----------
    i : int
        Which record.

    Returns
    -------
    str
        The record's DOF, plus whatever tells it from its neighbours.
    """
    if self.reference_dof is not None:
        return f'{self.response_dof[i]}/{self.reference_dof[i]}'
    if self.block is not None:
        # a space, not a slash: this is not a ratio of two DOFs —
        # stripped, because either half may be empty: an exodus
        # global variable is all block and no DOF, and ' KE' with a
        # ghost space is not a label anyone typed
        return f'{self.response_dof[i]} {self.block[i]}'.strip()
    return self.response_dof[i]
record_pair
record_pair(i: int) -> tuple[str, str]

The (response, reference) record i is between.

A record with no reference is an autospectrum — a channel against itself — so it pairs with the diagonal, which is what a specification bounds. The one reading of that rule: the plot, the table beside it and the report all ask here, so their labels cannot disagree.

Parameters:

Name Type Description Default
i int

The record.

required

Returns:

Type Description
tuple of str

Response DOF, reference DOF.

Source code in src/visualdynamics/core/data.py
def record_pair(self, i: int) -> tuple[str, str]:
    """The (response, reference) record `i` is between.

    A record with no reference is an autospectrum — a channel
    against itself — so it pairs with the diagonal, which is what
    a specification bounds. The one reading of that rule: the
    plot, the table beside it and the report all ask here, so
    their labels cannot disagree.

    Parameters
    ----------
    i : int
        The record.

    Returns
    -------
    tuple of str
        Response DOF, reference DOF.
    """
    response = self.response_dof[i]
    reference = (self.reference_dof[i]
                 if self.reference_dof is not None else response)
    return response, reference
log_scaled
log_scaled() -> bool

Whether this object's magnitude reads on a log axis.

Logarithmic for frequency-domain data unless the class pins it (log_ordinate — a coherence is a 0..1 ratio and says nothing on a log axis). The object answers so the 2-D plot and the 3-D waterfall read one rule and cannot disagree about its axis.

Source code in src/visualdynamics/core/data.py
def log_scaled(self) -> bool:
    """Whether this object's magnitude reads on a log axis.

    Logarithmic for frequency-domain data unless the class pins it
    (`log_ordinate` — a coherence is a 0..1 ratio and says nothing on
    a log axis). The object answers so the 2-D plot and the 3-D
    waterfall read one rule and cannot disagree about its axis.
    """
    return (self.abscissa_dim == 'frequency' if self.log_ordinate is None
            else bool(self.log_ordinate))
display_abscissa
display_abscissa(unit_system: UnitSystem) -> ndarray

The abscissa converted into a unit system's own units.

Parameters:

Name Type Description Default
unit_system UnitSystem

The units to present in.

required

Returns:

Type Description
ndarray

The abscissa in display units.

Source code in src/visualdynamics/core/data.py
def display_abscissa(self, unit_system: UnitSystem) -> np.ndarray:
    """The abscissa converted into a unit system's own units.

    Parameters
    ----------
    unit_system : UnitSystem
        The units to present in.

    Returns
    -------
    numpy.ndarray
        The abscissa in display units.
    """
    return unit_system.from_si(self.abscissa, self.abscissa_dim)
display_ordinate
display_ordinate(
    unit_system: UnitSystem,
    records: Iterable[int] | None = None,
) -> ndarray

Ordinate in display units; undefined records pass through as-is.

records limits the work to the ones asked for. A 1356-record FRF has one or two distinct dimensions in it, so the conversion is gathered per dimension and applied to a whole block at once — converting row by row meant a unit lookup per record, which is how drawing a single curve came to cost 2713 trips through pint.

Parameters:

Name Type Description Default
unit_system UnitSystem

The units to present the values in.

required
records iterable of int

Which records to convert. All of them when omitted.

None

Returns:

Type Description
ndarray

The values in display units. Records with undefined units pass through untouched.

Source code in src/visualdynamics/core/data.py
def display_ordinate(self, unit_system: UnitSystem,
                     records: Iterable[int] | None = None
                     ) -> np.ndarray:
    """Ordinate in display units; undefined records pass through as-is.

    `records` limits the work to the ones asked for. A 1356-record FRF
    has one or two distinct dimensions in it, so the conversion is
    gathered per dimension and applied to a whole block at once —
    converting row by row meant a unit lookup per record, which is how
    drawing a single curve came to cost 2713 trips through pint.

    Parameters
    ----------
    unit_system : UnitSystem
        The units to present the values in.
    records : iterable of int, optional
        Which records to convert. All of them when omitted.

    Returns
    -------
    numpy.ndarray
        The values in display units. Records with undefined
        units pass through untouched.
    """
    rows = range(self.num_records) if records is None else list(records)
    out = np.empty((len(rows), self.ordinate.shape[1]),
                   dtype=self.ordinate.dtype)
    by_dimension = {}
    for position, record in enumerate(rows):
        by_dimension.setdefault(self.ordinate_dim[record], []).append(
            (position, record))
    for dim, pairs in by_dimension.items():
        positions = [position for position, _ in pairs]
        source = self.ordinate[[record for _, record in pairs]]
        out[positions] = (source if dim == UNKNOWN
                          else unit_system.from_si(source, dim))
    return out
save
save(path: str | PathLike) -> None

Write this object to a .vdyn file of its own.

Parameters:

Name Type Description Default
path str or PathLike

Where to write it.

required

Returns:

Type Description
None
Source code in src/visualdynamics/core/data.py
def save(self, path: str | os.PathLike) -> None:
    """Write this object to a `.vdyn` file of its own.

    Parameters
    ----------
    path : str or os.PathLike
        Where to write it.

    Returns
    -------
    None
    """
    from ..io import native
    native.save(self, path)
plot
plot(
    unit_system: UnitSystem | None = None, **kwargs: Any
) -> Any

Draw every record on one set of axes.

Parameters:

Name Type Description Default
unit_system UnitSystem

Units to draw in.

None
**kwargs Any

Passed through to the plotting layer.

{}

Returns:

Type Description
object

The plot widget or plotter.

Source code in src/visualdynamics/core/data.py
def plot(self, unit_system: UnitSystem | None = None,
         **kwargs: Any) -> Any:
    """Draw every record on one set of axes.

    Parameters
    ----------
    unit_system : UnitSystem, optional
        Units to draw in.
    **kwargs
        Passed through to the plotting layer.

    Returns
    -------
    object
        The plot widget or plotter.
    """
    from ..plot import plot_data
    return plot_data(self, unit_system=unit_system, **kwargs)
save_plot
save_plot(
    path: str | PathLike,
    unit_system: UnitSystem | None = None,
    **kwargs: Any,
) -> Any

Draw the records and write the figure to path.

Parameters:

Name Type Description Default
path str or PathLike

Where to write the image.

required
unit_system UnitSystem

Units to draw in.

None
**kwargs Any

Passed through to the plotting layer.

{}

Returns:

Type Description
object

The plot widget or plotter.

Source code in src/visualdynamics/core/data.py
def save_plot(self, path: str | os.PathLike,
              unit_system: UnitSystem | None = None,
              **kwargs: Any) -> Any:
    """Draw the records and write the figure to `path`.

    Parameters
    ----------
    path : str or os.PathLike
        Where to write the image.
    unit_system : UnitSystem, optional
        Units to draw in.
    **kwargs
        Passed through to the plotting layer.

    Returns
    -------
    object
        The plot widget or plotter.
    """
    from ..plot import save_plot
    return save_plot(self, path, unit_system=unit_system, **kwargs)
plot_waterfall
plot_waterfall(
    records: Sequence[int] | None = None, **kwargs: Any
) -> Any

The records spread along a depth axis, coloured by level — the plot bar's 3-D reading, scripted. screenshot= renders headless to a file; without it a window of the app's own 3-D pane opens.

Parameters:

Name Type Description Default
records sequence of int

Which records to stage. All of them when omitted.

None
**kwargs Any

Passed through to the scene.

{}

Returns:

Type Description
object

The plot widget or plotter.

Source code in src/visualdynamics/core/data.py
def plot_waterfall(self, records: Sequence[int] | None = None,
                   **kwargs: Any) -> Any:
    """The records spread along a depth axis, coloured by level —
    the plot bar's 3-D reading, scripted. `screenshot=` renders
    headless to a file; without it a window of the app's own 3-D
    pane opens.

    Parameters
    ----------
    records : sequence of int, optional
        Which records to stage. All of them when omitted.
    **kwargs
        Passed through to the scene.

    Returns
    -------
    object
        The plot widget or plotter.
    """
    from ..viz.waterfall import plot_waterfall
    return plot_waterfall(self, records, **kwargs)

Frf

Frf(
    abscissa: ArrayLike,
    ordinate: ArrayLike,
    response_dof: str | Sequence[str],
    reference_dof: str | Sequence[str] | None = None,
    ordinate_dim: str | Sequence[str] | None = None,
    comment: str | Sequence[str] | None = None,
    ordinate_unit: str | Sequence[str | None] | None = None,
    reference_unit: str
    | Sequence[str | None]
    | None = None,
    dimension_hint: str
    | Sequence[str | None]
    | None = None,
    block: str | Sequence[str] | None = None,
)

Bases: DataArray

Frequency response function: response per unit reference.

Methods:

Name Description
plot_cmif

The CMIF the fitting screen draws; with shapes the modal

animate

The operating deflection shape at one frequency line, moving

Source code in src/visualdynamics/core/data.py
def __init__(self, abscissa: ArrayLike, ordinate: ArrayLike,
             response_dof: str | Sequence[str],
             reference_dof: str | Sequence[str] | None = None,
             ordinate_dim: str | Sequence[str] | None = None,
             comment: str | Sequence[str] | None = None,
             ordinate_unit: str | Sequence[str | None] | None = None,
             reference_unit: str | Sequence[str | None] | None = None,
             dimension_hint: str | Sequence[str | None] | None = None,
             block: str | Sequence[str] | None = None) -> None:
    self.abscissa: np.ndarray = np.asarray(abscissa, dtype=np.float64)
    ordinate = np.atleast_2d(np.asarray(
        ordinate, dtype=np.complex128 if self.complex_ordinate else np.float64))
    self.ordinate: np.ndarray = ordinate
    n = ordinate.shape[0]
    if self.abscissa.ndim != 1 or ordinate.shape[1] != len(self.abscissa):
        raise ValueError(
            f'ordinate shape {ordinate.shape} does not match '
            f'abscissa length {len(self.abscissa)}')
    # a channel whose node or direction was never recorded imports
    # as a DOF of '' — see `validate.dofs`
    self.response_dof: list[str] = _dofs(
        self._str_list(response_dof, n, 'response_dof'), 'response DOF',
        allow_unknown=True)
    self.reference_dof: list[str] | None
    if reference_dof is None:
        if self.needs_reference:
            raise ValueError(f'{type(self).__name__} requires reference_dof')
        self.reference_dof = None
    else:
        self.reference_dof = _dofs(
            self._str_list(reference_dof, n, 'reference_dof'),
            'reference DOF', allow_unknown=True)
    # A record is its response plus, sometimes, one more thing. For a
    # measurement against something else that is the reference DOF; for
    # the same measurement repeated it is which repeat — an average, a
    # run, a temperature. `block` holds the short label of that second
    # kind, and nothing about it is time-specific.
    self.block: list[str] | None = (None if block is None
                                    else self._str_list(block, n, 'block'))
    self.ordinate_dim: list[str] = self._str_list(
        UNKNOWN if ordinate_dim is None else ordinate_dim, n, 'ordinate_dim')
    for dim in set(self.ordinate_dim):
        parse_dimension(dim)  # validates
    self.ordinate_unit: list[str | None] = self._opt_list(
        ordinate_unit, n, 'ordinate_unit')
    self.reference_unit: list[str | None] = self._opt_list(
        reference_unit, n, 'reference_unit')
    self.comment: list[str] = self._str_list(
        comment if comment is not None else '', n, 'comment')
    self.dimension_hint: list[str | None] = self._opt_list(
        dimension_hint, n, 'dimension_hint')
    for hint in set(self.dimension_hint):
        if hint is not None:
            parse_dimension(hint)  # validates
    self._fill_si_units()
    self._drop_stale_hints()
Methods:
plot_cmif
plot_cmif(shapes: Any = None, **kwargs: Any) -> Any

The CMIF the fitting screen draws; with shapes the modal model's synthesis is drawn dashed over the measurement.

Parameters:

Name Type Description Default
shapes ShapeSet

A modal fit, drawn as the synthesised CMIF over the measured one.

None
**kwargs Any

Passed through to the plotting layer.

{}

Returns:

Type Description
object

The plot widget or plotter.

Source code in src/visualdynamics/core/data.py
def plot_cmif(self, shapes: Any = None, **kwargs: Any) -> Any:
    """The CMIF the fitting screen draws; with `shapes` the modal
    model's synthesis is drawn dashed over the measurement.

    Parameters
    ----------
    shapes : ShapeSet, optional
        A modal fit, drawn as the synthesised CMIF over the measured one.
    **kwargs
        Passed through to the plotting layer.

    Returns
    -------
    object
        The plot widget or plotter.
    """
    from ..plot import plot_cmif
    return plot_cmif(self, shapes, **kwargs)
animate
animate(
    geometry: Any,
    frequency: float | None = None,
    **kwargs: Any,
) -> Any

The operating deflection shape at one frequency line, moving on a geometry as the GUI animates it. Defaults to the strongest line; frequency picks another.

Parameters:

Name Type Description Default
geometry Geometry

The geometry to move.

required
frequency float

Which frequency line. The strongest when omitted.

None
**kwargs Any

Passed through to the scene.

{}

Returns:

Type Description
object

The plot widget or plotter.

Source code in src/visualdynamics/core/data.py
def animate(self, geometry: Any, frequency: float | None = None,
            **kwargs: Any) -> Any:
    """The operating deflection shape at one frequency line, moving
    on a geometry as the GUI animates it. Defaults to the strongest
    line; `frequency` picks another.

    Parameters
    ----------
    geometry : Geometry
        The geometry to move.
    frequency : float, optional
        Which frequency line. The strongest when omitted.
    **kwargs
        Passed through to the scene.

    Returns
    -------
    object
        The plot widget or plotter.
    """
    from ..viz.animate import animate_ods
    return animate_ods(geometry, self, frequency=frequency, **kwargs)

Psd

Psd(
    *args: Any,
    bandwidth: ArrayLike | None = None,
    **kwargs: Any,
)

Bases: DataArray

Power spectral density: the declared unit is the engineering unit whose square-per-Hz the values are in (declare 'g' for g^2/Hz).

Held complex because a cross spectrum is complex — the phase between two channels is most of what a CPSD is for. An autospectrum is not: a channel against itself is a magnitude squared, real by construction. So the type allows complex and the object stores what it actually has, which for a specification or a set of ASDs is a real array of half the size.

Methods:

Name Description
principal_shapes

The dominant shape of the cross-spectral matrix at each line,

animate

This set on a geometry, as the GUI shows it.

area

The area under one record, over a band or over all of it.

to_octave

This spectrum integrated onto proportional bands.

bin_widths

The width of every line's own bin.

bin_bounds

(left, right) of every line's own bin — see octave.bin_bounds.

Source code in src/visualdynamics/core/data.py
def __init__(self, *args: Any, bandwidth: ArrayLike | None = None,
             **kwargs: Any) -> None:
    super().__init__(*args, **kwargs)
    if not has_phase(self.ordinate):
        self.ordinate: np.ndarray = np.ascontiguousarray(self.ordinate.real)
    if bandwidth is not None:
        bandwidth = np.asarray(bandwidth, dtype=np.float64)
        if bandwidth.shape != self.abscissa.shape:
            raise ValueError(
                f'bandwidth has shape {bandwidth.shape}, expected '
                f'{self.abscissa.shape} to match the abscissa')
        self.bandwidth = bandwidth
Methods:
principal_shapes
principal_shapes(
    quantity: str | None = None,
) -> tuple[list[str], ndarray, str]

The dominant shape of the cross-spectral matrix at each line, as (dofs, shapes (channels x lines), quantity).

The channels of one quantity form a square Hermitian matrix per line; its largest eigenvalue's eigenvector, scaled by the square root of that eigenvalue, is the principal operating deflection shape — the direction of the output spectra's own CMIF, with each channel's phase relative to the others and no reference to choose. quantity picks which channels (the commonest when not told). Refuses a set with no cross records — an autospectrum set has no phase and its reading is the envelope — and an incomplete block, whose eigenvectors would be shapes of holes.

Parameters:

Name Type Description Default
quantity str

Which quantity, for a mixed object.

None

Returns:

Type Description
tuple of (list of str, numpy.ndarray, str)

The DOF labels, the dominant shape at each line, and the quantity they are in.

Source code in src/visualdynamics/core/data.py
def principal_shapes(self, quantity: str | None = None
                     ) -> tuple[list[str], np.ndarray, str]:
    """The dominant shape of the cross-spectral matrix at each line,
    as (dofs, shapes (channels x lines), quantity).

    The channels of one quantity form a square Hermitian matrix per
    line; its largest eigenvalue's eigenvector, scaled by the square
    root of that eigenvalue, is the principal operating deflection
    shape — the direction of the output spectra's own CMIF, with
    each channel's phase relative to the others and no reference to
    choose. `quantity` picks which channels (the commonest when not
    told). Refuses a set with no cross records — an autospectrum set
    has no phase and its reading is the envelope — and an incomplete
    block, whose eigenvectors would be shapes of holes.

    Parameters
    ----------
    quantity : str, optional
        Which quantity, for a mixed object.

    Returns
    -------
    tuple of (list of str, numpy.ndarray, str)
        The DOF labels, the dominant shape at each line, and the
        quantity they are in.
    """
    if self.reference_dof is None:
        raise ValueError('no cross records — an autospectrum set '
                         'has no phase')
    factors = [channel_quantities(self.known_dim(i))
               for i in range(self.num_records)]
    kinds = [response for response, _reference in factors]
    if quantity is None:
        kinds_present = [k for k in kinds if k != UNKNOWN]
        if not kinds_present:
            raise ValueError('no record names a quantity')
        quantity = max(set(kinds_present), key=kinds_present.count)
    channels: list[str] = []
    for i, dof in enumerate(self.response_dof):
        if kinds[i] == quantity and dof not in channels:
            channels.append(dof)
    n = len(channels)
    if n < 2:
        raise ValueError(f'fewer than two {quantity} channels')
    index = {dof: k for k, dof in enumerate(channels)}
    matrix = np.zeros((len(self.abscissa), n, n), dtype=np.complex128)
    filled = np.zeros((n, n), dtype=bool)
    for i in range(self.num_records):
        row = index.get(self.response_dof[i])
        column = index.get(self.reference_dof[i])
        if (row is None or column is None
                or (kinds[i], factors[i][1]) != (quantity, quantity)):
            continue
        matrix[:, row, column] = self.ordinate[i]
        filled[row, column] = True
    if not filled.all():
        raise ValueError(f'the {quantity} cross-spectral block is '
                         'incomplete')
    values, vectors = np.linalg.eigh(matrix)
    principal = (vectors[:, :, -1]
                 * np.sqrt(np.maximum(values[:, -1:], 0.0)))
    return channels, principal.T.copy(), quantity
animate
animate(
    geometry: Any,
    frequency: float | None = None,
    quantity: str | None = None,
    **kwargs: Any,
) -> Any

This set on a geometry, as the GUI shows it.

A CPSD — cross records present — animates its principal operating deflection shape: the dominant eigenvector of the cross-spectral matrix per line, each channel's phase relative to the others. An autospectrum set has no phase, so it shows the envelope instead: two copies deflected ±sqrt(PSD), colour reading dB below the loudest node at any line. Defaults to the strongest line; frequency picks another, quantity which measurement deflects.

Parameters:

Name Type Description Default
geometry Geometry

The geometry to move.

required
frequency float

Which frequency line.

None
quantity str

Which quantity, for a mixed object.

None
**kwargs Any

Passed through to the scene.

{}

Returns:

Type Description
object

The plot widget or plotter.

Source code in src/visualdynamics/core/data.py
def animate(self, geometry: Any, frequency: float | None = None,
            quantity: str | None = None, **kwargs: Any) -> Any:
    """This set on a geometry, as the GUI shows it.

    A CPSD — cross records present — animates its principal
    operating deflection shape: the dominant eigenvector of the
    cross-spectral matrix per line, each channel's phase relative
    to the others. An autospectrum set has no phase, so it shows
    the envelope instead: two copies deflected ±sqrt(PSD), colour
    reading dB below the loudest node at any line. Defaults to the
    strongest line; `frequency` picks another, `quantity` which
    measurement deflects.

    Parameters
    ----------
    geometry : Geometry
        The geometry to move.
    frequency : float, optional
        Which frequency line.
    quantity : str, optional
        Which quantity, for a mixed object.
    **kwargs
        Passed through to the scene.

    Returns
    -------
    object
        The plot widget or plotter.
    """
    try:
        dofs, shapes, _used = self.principal_shapes(quantity)
    except ValueError:
        from ..viz.animate import animate_envelope
        return animate_envelope(geometry, self, frequency=frequency,
                                quantity=quantity, **kwargs)
    return Spectrum(self.abscissa, shapes,
                    response_dof=dofs).animate(
        geometry, frequency, **kwargs)
area
area(
    record: int = 0,
    low: float | None = None,
    high: float | None = None,
) -> float

The area under one record, over a band or over all of it.

The one integral. Whichever way this spectrum is read, it is read the same way here as it is drawn — that is what the field above is for, and why nothing outside this method chooses.

Units are the ordinate's times frequency, so the square root of it is an RMS for a PSD.

Parameters:

Name Type Description Default
record int

Which record.

0
low float

The band to integrate over.

None
high float

The band to integrate over.

None

Returns:

Type Description
float

The area beneath the curve — the mean square, whose root is the RMS.

Source code in src/visualdynamics/core/data.py
def area(self, record: int = 0, low: float | None = None,
         high: float | None = None) -> float:
    """The area under one record, over a band or over all of it.

    The one integral. Whichever way this spectrum is read, it is
    read the same way here as it is drawn — that is what the field
    above is for, and why nothing outside this method chooses.

    Units are the ordinate's times frequency, so the square root of
    it is an RMS for a PSD.

    Parameters
    ----------
    record : int, default 0
        Which record.
    low, high : float, optional
        The band to integrate over.

    Returns
    -------
    float
        The area beneath the curve — the mean square, whose root
        is the RMS.
    """
    from .compliance import log_log_area

    values = np.real(np.asarray(self.ordinate[record], dtype=float))
    if self.interpolation == 'log_log':
        return log_log_area(self.abscissa, values, low, high)
    left, right = self.bin_bounds()
    if low is not None:
        left, right = np.maximum(left, low), np.maximum(right, low)
    if high is not None:
        left, right = np.minimum(left, high), np.minimum(right, high)
    width = np.maximum(right - left, 0.0)
    good = np.isfinite(values) & (values >= 0.0) & np.isfinite(width)
    if not good.any():
        return float('nan')
    return float(np.sum(values[good] * width[good]))
to_octave
to_octave(
    per_octave: int | None = None,
    low: float | None = None,
    high: float | None = None,
) -> Psd

This spectrum integrated onto proportional bands.

An integration, not a resampling: each band takes the mean-square content that falls in it, divided by its own width, so the area under the spectrum — and therefore the RMS it carries — is unchanged. Reading the narrowband curve at each band centre would throw away everything between the centres.

The band grid is absolute (see visualdynamics.core.octave), so this needs no specification to be told about: the frequency range only chooses which bands of the one fixed grid come back, and two runs banded the same way land on the same bands whatever their ranges were.

Cross terms come through complex, which is what makes this work for a CPSD as well as a PSD.

Parameters:

Name Type Description Default
per_octave int

Bands per octave.

None
low float

The band to cover.

None
high float

The band to cover.

None

Returns:

Type Description
Psd

The same power, arranged on proportional bands.

Source code in src/visualdynamics/core/data.py
def to_octave(self, per_octave: int | None = None,
              low: float | None = None,
              high: float | None = None) -> Psd:
    """This spectrum integrated onto proportional bands.

    An integration, not a resampling: each band takes the
    mean-square content that falls in it, divided by its own width,
    so the area under the spectrum — and therefore the RMS it
    carries — is unchanged. Reading the narrowband curve at each
    band centre would throw away everything between the centres.

    The band grid is absolute (see `visualdynamics.core.octave`), so this
    needs no specification to be told about: the frequency range
    only chooses which bands of the one fixed grid come back, and
    two runs banded the same way land on the same bands whatever
    their ranges were.

    Cross terms come through complex, which is what makes this work
    for a CPSD as well as a PSD.

    Parameters
    ----------
    per_octave : int, optional
        Bands per octave.
    low, high : float, optional
        The band to cover.

    Returns
    -------
    Psd
        The same power, arranged on proportional bands.
    """
    from .octave import PER_OCTAVE, bands, resample

    frequencies = np.asarray(self.abscissa, dtype=float)
    positive = frequencies[frequencies > 0.0]
    if positive.size < 2:
        raise ValueError(
            'octave bands need a spectrum with frequency lines above '
            'zero; this one has ' + str(positive.size))
    # the range the bands have to span is the range the *bins*
    # cover, not the range the centres do. A line at 0.5 Hz stands
    # for a bin reaching down to 0.25, and a grid that started at
    # the line would leave that half outside every band — which is
    # a small loss of area and an unnecessary one.
    reach, beyond = self.bin_bounds()
    inside = reach[reach > 0.0]
    low = (float(inside.min()) if inside.size else float(positive.min())) \
        if low is None else float(low)
    high = float(beyond.max()) if high is None else float(high)
    per_octave = PER_OCTAVE if per_octave is None else int(per_octave)
    centres, widths, bounds = bands(low, high, per_octave)
    banded = resample(frequencies, self.ordinate, bounds, widths,
                      source=self.bin_bounds())
    out = Psd(centres, banded,
              response_dof=list(self.response_dof),
              reference_dof=(None if self.reference_dof is None
                             else list(self.reference_dof)),
              ordinate_dim=list(self.ordinate_dim),
              ordinate_unit=list(self.ordinate_unit),
              reference_unit=list(self.reference_unit),
              dimension_hint=list(self.dimension_hint),
              block=None if self.block is None else list(self.block),
              bandwidth=widths,
              comment=f'1/{per_octave} octave bands')
    # banding conserves the area and changes nothing about what the
    # object is — a held comparison scale included, so the report's
    # own banding compares what the narrowband comparison compared
    out.scale_db = self.scale_db
    return out
bin_widths
bin_widths() -> ndarray

The width of every line's own bin.

Its own when it has one, the midpoints between neighbours when it does not — so a caller integrating a spectrum never has to ask which kind it is holding.

Source code in src/visualdynamics/core/data.py
def bin_widths(self) -> np.ndarray:
    """The width of every line's own bin.

    Its own when it has one, the midpoints between neighbours when
    it does not — so a caller integrating a spectrum never has to
    ask which kind it is holding.
    """
    if self.bandwidth is not None:
        return self.bandwidth
    return np.gradient(np.asarray(self.abscissa, dtype=float))
bin_bounds
bin_bounds() -> tuple[ndarray, ndarray]

(left, right) of every line's own bin — see octave.bin_bounds.

Source code in src/visualdynamics/core/data.py
def bin_bounds(self) -> tuple[np.ndarray, np.ndarray]:
    """(left, right) of every line's own bin — see `octave.bin_bounds`."""
    from .octave import bin_bounds

    return bin_bounds(np.asarray(self.abscissa, dtype=float),
                      self.bandwidth)

ShockSpecification

ShockSpecification(
    *args: Any,
    warning_lower: ArrayLike | None = None,
    warning_upper: ArrayLike | None = None,
    abort_lower: ArrayLike | None = None,
    abort_upper: ArrayLike | None = None,
    **kwargs: Any,
)

Bases: Bounded, Srs

What a shock test was controlled to: an SRS, and its band.

The same relationship a Specification has to a Psd. A shock target is written as a required SRS with tolerance either side of it — conventionally +6 dB and -3 dB, which is a factor of 2 up and 0.707 down — and the limits are those curves.

Written at one Q, and read at that Q: a target quoted at Q = 10 says nothing about what the same shock does to a Q = 50 oscillator, so the amplification travels with the target the way it travels with a measurement.

Source code in src/visualdynamics/core/data.py
def __init__(self, *args: Any, warning_lower: ArrayLike | None = None,
             warning_upper: ArrayLike | None = None,
             abort_lower: ArrayLike | None = None,
             abort_upper: ArrayLike | None = None,
             **kwargs: Any) -> None:
    super().__init__(*args, **kwargs)
    self.limits: dict[str, np.ndarray] = {}
    given = {'warning_lower': warning_lower,
             'warning_upper': warning_upper,
             'abort_lower': abort_lower,
             'abort_upper': abort_upper}
    for name, values in given.items():
        if values is None:
            continue
        values = np.atleast_2d(np.asarray(values, dtype=np.float64))
        if values.shape != self.ordinate.shape:
            raise ValueError(
                f'{name} has shape {values.shape}, expected '
                f'{self.ordinate.shape} to match the ordinate')
        self.limits[name] = values

Specification

Specification(
    *args: Any,
    warning_lower: ArrayLike | None = None,
    warning_upper: ArrayLike | None = None,
    abort_lower: ArrayLike | None = None,
    abort_upper: ArrayLike | None = None,
    **kwargs: Any,
)

Bases: Bounded, Psd

What a random vibration test was controlled to: a PSD, and its band.

A specification is a PSD in every respect — same abscissa, same quantity, same conversions — with the four limit curves Bounded carries. So it inherits both, rather than reimplementing either.

Source code in src/visualdynamics/core/data.py
def __init__(self, *args: Any, warning_lower: ArrayLike | None = None,
             warning_upper: ArrayLike | None = None,
             abort_lower: ArrayLike | None = None,
             abort_upper: ArrayLike | None = None,
             **kwargs: Any) -> None:
    super().__init__(*args, **kwargs)
    self.limits: dict[str, np.ndarray] = {}
    given = {'warning_lower': warning_lower,
             'warning_upper': warning_upper,
             'abort_lower': abort_lower,
             'abort_upper': abort_upper}
    for name, values in given.items():
        if values is None:
            continue
        values = np.atleast_2d(np.asarray(values, dtype=np.float64))
        if values.shape != self.ordinate.shape:
            raise ValueError(
                f'{name} has shape {values.shape}, expected '
                f'{self.ordinate.shape} to match the ordinate')
        self.limits[name] = values

Spectrum

Spectrum(
    abscissa: ArrayLike,
    ordinate: ArrayLike,
    response_dof: str | Sequence[str],
    reference_dof: str | Sequence[str] | None = None,
    ordinate_dim: str | Sequence[str] | None = None,
    comment: str | Sequence[str] | None = None,
    ordinate_unit: str | Sequence[str | None] | None = None,
    reference_unit: str
    | Sequence[str | None]
    | None = None,
    dimension_hint: str
    | Sequence[str | None]
    | None = None,
    block: str | Sequence[str] | None = None,
)

Bases: DataArray

A linear spectrum: amplitude and phase at each frequency line.

The complex average of a record's frames, not a power average — so content whose phase is random frame to frame averages toward zero, which is what makes this the wrong reading for burst random and the right one for a deterministic signal. A Psd is the power average and does not have that property.

Methods:

Name Description
animate

The operating deflection shape at one frequency line, moving

Source code in src/visualdynamics/core/data.py
def __init__(self, abscissa: ArrayLike, ordinate: ArrayLike,
             response_dof: str | Sequence[str],
             reference_dof: str | Sequence[str] | None = None,
             ordinate_dim: str | Sequence[str] | None = None,
             comment: str | Sequence[str] | None = None,
             ordinate_unit: str | Sequence[str | None] | None = None,
             reference_unit: str | Sequence[str | None] | None = None,
             dimension_hint: str | Sequence[str | None] | None = None,
             block: str | Sequence[str] | None = None) -> None:
    self.abscissa: np.ndarray = np.asarray(abscissa, dtype=np.float64)
    ordinate = np.atleast_2d(np.asarray(
        ordinate, dtype=np.complex128 if self.complex_ordinate else np.float64))
    self.ordinate: np.ndarray = ordinate
    n = ordinate.shape[0]
    if self.abscissa.ndim != 1 or ordinate.shape[1] != len(self.abscissa):
        raise ValueError(
            f'ordinate shape {ordinate.shape} does not match '
            f'abscissa length {len(self.abscissa)}')
    # a channel whose node or direction was never recorded imports
    # as a DOF of '' — see `validate.dofs`
    self.response_dof: list[str] = _dofs(
        self._str_list(response_dof, n, 'response_dof'), 'response DOF',
        allow_unknown=True)
    self.reference_dof: list[str] | None
    if reference_dof is None:
        if self.needs_reference:
            raise ValueError(f'{type(self).__name__} requires reference_dof')
        self.reference_dof = None
    else:
        self.reference_dof = _dofs(
            self._str_list(reference_dof, n, 'reference_dof'),
            'reference DOF', allow_unknown=True)
    # A record is its response plus, sometimes, one more thing. For a
    # measurement against something else that is the reference DOF; for
    # the same measurement repeated it is which repeat — an average, a
    # run, a temperature. `block` holds the short label of that second
    # kind, and nothing about it is time-specific.
    self.block: list[str] | None = (None if block is None
                                    else self._str_list(block, n, 'block'))
    self.ordinate_dim: list[str] = self._str_list(
        UNKNOWN if ordinate_dim is None else ordinate_dim, n, 'ordinate_dim')
    for dim in set(self.ordinate_dim):
        parse_dimension(dim)  # validates
    self.ordinate_unit: list[str | None] = self._opt_list(
        ordinate_unit, n, 'ordinate_unit')
    self.reference_unit: list[str | None] = self._opt_list(
        reference_unit, n, 'reference_unit')
    self.comment: list[str] = self._str_list(
        comment if comment is not None else '', n, 'comment')
    self.dimension_hint: list[str | None] = self._opt_list(
        dimension_hint, n, 'dimension_hint')
    for hint in set(self.dimension_hint):
        if hint is not None:
            parse_dimension(hint)  # validates
    self._fill_si_units()
    self._drop_stale_hints()
Methods:
animate
animate(
    geometry: Any,
    frequency: float | None = None,
    **kwargs: Any,
) -> Any

The operating deflection shape at one frequency line, moving on a geometry as the GUI animates it. Defaults to the strongest line; frequency picks another.

Parameters:

Name Type Description Default
geometry Geometry

The geometry to move.

required
frequency float

Which frequency line. The strongest when omitted.

None
**kwargs Any

Passed through to the scene.

{}

Returns:

Type Description
object

The plot widget or plotter.

Source code in src/visualdynamics/core/data.py
def animate(self, geometry: Any, frequency: float | None = None,
            **kwargs: Any) -> Any:
    """The operating deflection shape at one frequency line, moving
    on a geometry as the GUI animates it. Defaults to the strongest
    line; `frequency` picks another.

    Parameters
    ----------
    geometry : Geometry
        The geometry to move.
    frequency : float, optional
        Which frequency line. The strongest when omitted.
    **kwargs
        Passed through to the scene.

    Returns
    -------
    object
        The plot widget or plotter.
    """
    from ..viz.animate import animate_ods
    return animate_ods(geometry, self, frequency=frequency, **kwargs)

Srs

Srs(
    *args: Any,
    q: float | None = None,
    kind: str = "maximax",
    **kwargs: Any,
)

Bases: DataArray

A shock response spectrum: the peak an oscillator reached.

Not a spectrum of the shock. Every point is the largest response a single-degree-of-freedom oscillator of that natural frequency ever reached while its base was shaken by the measured transient — one number out of one whole run of one filter. Two shocks with the same SRS can look nothing alike, and an SRS cannot be turned back into a time history, because the phase that produced each peak is gone.

The abscissa is the oscillator's natural frequency, not a frequency in the shock — laid out in decades (log_abscissa): an SRS is specified at octave-spaced natural frequencies, and drawn linear the bottom five octaves crush into the left margin. The ordinate is in the quantity the base was measured in — an acceleration transient gives an acceleration SRS.

q and kind change what the curve means, so they belong to the object rather than to whoever happened to compute it: an SRS at Q = 10 and the same shock at Q = 50 are different curves, and a maximax reading is not a positive one. See visualdynamics.core.srs.

Attributes:

Name Type Description
damping float

The damping ratio the amplification factor means.

Source code in src/visualdynamics/core/data.py
def __init__(self, *args: Any, q: float | None = None,
             kind: str = 'maximax', **kwargs: Any) -> None:
    from .srs import DEFAULT_Q

    super().__init__(*args, **kwargs)
    self.q: float = DEFAULT_Q if q is None else float(q)
    self.kind: str = str(kind)
Attributes
damping property
damping: float

The damping ratio the amplification factor means.

TimeHistory

TimeHistory(
    abscissa: ArrayLike,
    ordinate: ArrayLike,
    response_dof: str | Sequence[str],
    reference_dof: str | Sequence[str] | None = None,
    ordinate_dim: str | Sequence[str] | None = None,
    comment: str | Sequence[str] | None = None,
    ordinate_unit: str | Sequence[str | None] | None = None,
    reference_unit: str
    | Sequence[str | None]
    | None = None,
    dimension_hint: str
    | Sequence[str | None]
    | None = None,
    block: str | Sequence[str] | None = None,
)

Bases: DataArray

A measurement against time: the record as it was acquired.

Everything else in a random or shock test is derived from one of these, and the deriving is here — spectra, PSDs, the full CPSD matrix, multiple coherence, shock response spectra. Two things ride along that say how to read it: averaging, the frames a spectrum is averaged over, and shocks, the events an SRS is computed from. Both are the app's two views of a trace, and both are stored on the history rather than passed at the call, so a PSD and the coherence beside it cannot describe different measurements.

Methods:

Name Description
compute_spectra

The averaged spectrum of every channel — sdynpy's convention.

channel_key

What makes record i the same channel as another.

suggest_averaging

Averaging parameters worked out from the record itself.

capture_indices

Which playing each record is: 0 for a channel's first

suggest_truncation

The whole record — the only neutral span.

truncate

This record cut to a span (core.truncate.truncate), using

suggest_filtering

A starting low-pass: a tenth of the sample rate, order 4.

filter

This record through its filter (core.filters.filtered) —

integrate

One integration — acceleration to velocity, velocity to

differentiate

One differentiation — displacement to velocity, velocity to

srs_windows

The stretches an SRS of this record would read, settled.

srs_band

(low, high) in Hz: the band these windows can support.

compute_srs

A shock response spectrum for every channel of every shock.

compute_psds

One-sided auto-power spectral density per channel, averaged

psd_type

What a PSD of this history is.

srs_type

What an SRS of this history is — see psd_type.

compute_cpsds

The full cross-spectral density matrix, averaged across the

drive_dofs

The DOFs this history looks like it was driven at.

compute_frfs

The frequency response functions, one per response/drive pair.

compute_multiple_coherence

How much of each response the drives together account for.

to_sep005

This record as SEP 005 timeseries — the sdypy ecosystem's

Attributes:

Name Type Description
sample_rate float

Samples per second, from the abscissa — which must be even.

records_per_channel dict[tuple[str, str, str | None], int]

{channel key: how many records carry it}.

split_into_frames bool

Whether the records are already the averages.

average_counts tuple[int, int]

(fewest, most) frames any one channel will be averaged over.

Source code in src/visualdynamics/core/data.py
def __init__(self, abscissa: ArrayLike, ordinate: ArrayLike,
             response_dof: str | Sequence[str],
             reference_dof: str | Sequence[str] | None = None,
             ordinate_dim: str | Sequence[str] | None = None,
             comment: str | Sequence[str] | None = None,
             ordinate_unit: str | Sequence[str | None] | None = None,
             reference_unit: str | Sequence[str | None] | None = None,
             dimension_hint: str | Sequence[str | None] | None = None,
             block: str | Sequence[str] | None = None) -> None:
    self.abscissa: np.ndarray = np.asarray(abscissa, dtype=np.float64)
    ordinate = np.atleast_2d(np.asarray(
        ordinate, dtype=np.complex128 if self.complex_ordinate else np.float64))
    self.ordinate: np.ndarray = ordinate
    n = ordinate.shape[0]
    if self.abscissa.ndim != 1 or ordinate.shape[1] != len(self.abscissa):
        raise ValueError(
            f'ordinate shape {ordinate.shape} does not match '
            f'abscissa length {len(self.abscissa)}')
    # a channel whose node or direction was never recorded imports
    # as a DOF of '' — see `validate.dofs`
    self.response_dof: list[str] = _dofs(
        self._str_list(response_dof, n, 'response_dof'), 'response DOF',
        allow_unknown=True)
    self.reference_dof: list[str] | None
    if reference_dof is None:
        if self.needs_reference:
            raise ValueError(f'{type(self).__name__} requires reference_dof')
        self.reference_dof = None
    else:
        self.reference_dof = _dofs(
            self._str_list(reference_dof, n, 'reference_dof'),
            'reference DOF', allow_unknown=True)
    # A record is its response plus, sometimes, one more thing. For a
    # measurement against something else that is the reference DOF; for
    # the same measurement repeated it is which repeat — an average, a
    # run, a temperature. `block` holds the short label of that second
    # kind, and nothing about it is time-specific.
    self.block: list[str] | None = (None if block is None
                                    else self._str_list(block, n, 'block'))
    self.ordinate_dim: list[str] = self._str_list(
        UNKNOWN if ordinate_dim is None else ordinate_dim, n, 'ordinate_dim')
    for dim in set(self.ordinate_dim):
        parse_dimension(dim)  # validates
    self.ordinate_unit: list[str | None] = self._opt_list(
        ordinate_unit, n, 'ordinate_unit')
    self.reference_unit: list[str | None] = self._opt_list(
        reference_unit, n, 'reference_unit')
    self.comment: list[str] = self._str_list(
        comment if comment is not None else '', n, 'comment')
    self.dimension_hint: list[str | None] = self._opt_list(
        dimension_hint, n, 'dimension_hint')
    for hint in set(self.dimension_hint):
        if hint is not None:
            parse_dimension(hint)  # validates
    self._fill_si_units()
    self._drop_stale_hints()
Attributes
sample_rate property
sample_rate: float

Samples per second, from the abscissa — which must be even.

records_per_channel property
records_per_channel: dict[tuple[str, str, str | None], int]

{channel key: how many records carry it}.

Usually one. A capture a controller saved frame by frame holds one record per average.

split_into_frames property
split_into_frames: bool

Whether the records are already the averages.

A controller that saves its spectral captures writes each frame as its own record. There is then nothing to slice and nothing to overlap: the frame length and the count are settled by the file, and the only parameter left to choose is the window.

average_counts property
average_counts: tuple[int, int]

(fewest, most) frames any one channel will be averaged over.

The two agree for anything a controller wrote, which saves every channel the same number of times. They part only for a history assembled by hand out of unequal captures, and then the table has to say so rather than quote a number that is true of some channels and not others.

Methods:
compute_spectra
compute_spectra() -> Spectrum

The averaged spectrum of every channel — sdynpy's convention.

A channel's frames — its records across the averages — are each FFT'd single-sided with no amplitude scaling (numpy's rfft, norm='backward': a sine of amplitude A on a bin reads AN/2), rectangular window, and averaged as the complex mean*, exactly what sdynpy's TimeHistoryArray.fft does with frames. Phase is preserved; content whose phase is random frame to frame — a burst random excitation's response — averages toward zero, which is that convention's documented behavior. Returns a Spectrum with one record per channel, carrying the channel's own quantity and units.

Source code in src/visualdynamics/core/data.py
def compute_spectra(self) -> Spectrum:
    """The averaged spectrum of every channel — sdynpy's convention.

    A channel's frames — its records across the averages — are each
    FFT'd single-sided with no amplitude scaling (numpy's rfft,
    norm='backward': a sine of amplitude A on a bin reads A*N/2),
    rectangular window, and averaged as the *complex mean*, exactly
    what sdynpy's `TimeHistoryArray.fft` does with frames. Phase is
    preserved; content whose phase is random frame to frame — a
    burst random excitation's response — averages toward zero,
    which is that convention's documented behavior. Returns a
    Spectrum with one record per channel, carrying the channel's
    own quantity and units.
    """
    if len(self.abscissa) < 2:
        raise ValueError('Spectra need at least two time samples')
    step = _even_steps(self.abscissa, 'Spectra')
    groups = {}
    for i, dof in enumerate(self.response_dof):
        key = (dof, self.ordinate_dim[i], self.ordinate_unit[i],
               self.dimension_hint[i])
        groups.setdefault(key, []).append(i)
    frequencies = np.fft.rfftfreq(len(self.abscissa), step)
    rows, dofs, dims, units, hints = [], [], [], [], []
    for (dof, dim, unit, hint), indices in groups.items():
        frames = np.fft.rfft(self.ordinate[indices], axis=1)
        rows.append(np.mean(frames, axis=0))
        dofs.append(dof)
        dims.append(dim)
        units.append(unit)
        hints.append(hint)
    return Spectrum(frequencies,
                    np.asarray(rows, dtype=np.complex128),
                    response_dof=dofs, ordinate_dim=dims,
                    ordinate_unit=units, dimension_hint=hints,
                    comment='averaged spectrum')
channel_key
channel_key(i: int) -> tuple[str, str, str | None]

What makes record i the same channel as another.

The DOF and what is measured there, never the DOF alone: a drive point carries a force record and an acceleration record at the same DOF, and those two are not each other's averages. This is the key the framing groups on, so counting channels and averaging them cannot disagree about what a channel is.

Parameters:

Name Type Description Default
i int

Which record.

required

Returns:

Type Description
tuple of (str, str, str or None)

The DOF, quantity and unit that identify the channel — records sharing this key are the same channel.

Source code in src/visualdynamics/core/data.py
def channel_key(self, i: int) -> tuple[str, str, str | None]:
    """What makes record `i` the same channel as another.

    The DOF and what is measured there, never the DOF alone: a drive
    point carries a force record and an acceleration record at the
    same DOF, and those two are not each other's averages. This is
    the key the framing groups on, so counting channels and
    averaging them cannot disagree about what a channel is.

    Parameters
    ----------
    i : int
        Which record.

    Returns
    -------
    tuple of (str, str, str or None)
        The DOF, quantity and unit that identify the channel —
        records sharing this key are the same channel.
    """
    return (self.response_dof[i], self.ordinate_dim[i],
            self.dimension_hint[i])
suggest_averaging
suggest_averaging(**kwargs: Any) -> Averaging

Averaging parameters worked out from the record itself.

A run holds more than the test — the shaker coming up, a reduced-level check, whatever was still recording afterwards — and this finds the settled stretch worth averaging and as many frames as it will carry. See visualdynamics.core.detect.

Parameters:

Name Type Description Default
**kwargs Any

Overrides for individual parameters, such as window.

{}

Returns:

Type Description
Averaging

Parameters worked out from the record itself.

Source code in src/visualdynamics/core/data.py
def suggest_averaging(self, **kwargs: Any) -> Averaging:
    """Averaging parameters worked out from the record itself.

    A run holds more than the test — the shaker coming up, a
    reduced-level check, whatever was still recording afterwards —
    and this finds the settled stretch worth averaging and as many
    frames as it will carry. See `visualdynamics.core.detect`.

    Parameters
    ----------
    **kwargs
        Overrides for individual parameters, such as `window`.

    Returns
    -------
    Averaging
        Parameters worked out from the record itself.
    """
    from .detect import suggest

    return suggest(self, **kwargs)
capture_indices
capture_indices() -> list[int]

Which playing each record is: 0 for a channel's first record, 1 for its second, and so on — in exactly the order _spectral_frame pools them, so the playing the averaging view pages to is one of the playings the average adds up. A channel is a channel_key group, the same grouping the pooling uses.

Source code in src/visualdynamics/core/data.py
def capture_indices(self) -> list[int]:
    """Which playing each record is: 0 for a channel's first
    record, 1 for its second, and so on — in exactly the order
    `_spectral_frame` pools them, so the playing the averaging
    view pages to is one of the playings the average adds up. A
    channel is a `channel_key` group, the same grouping the
    pooling uses.
    """
    seen: dict = {}
    out = []
    for i in range(len(self.response_dof)):
        key = self.channel_key(i)
        out.append(seen.get(key, 0))
        seen[key] = seen.get(key, 0) + 1
    return out
suggest_truncation
suggest_truncation() -> Any

The whole record — the only neutral span.

A starting point for the truncate view's handles, never a default the act adopts: keeping everything is not an act, so Truncate Data refuses until a real span is set.

Source code in src/visualdynamics/core/data.py
def suggest_truncation(self) -> Any:
    """The whole record — the only neutral span.

    A starting point for the truncate view's handles, never a
    default the act adopts: keeping everything is not an
    act, so Truncate Data refuses until a real span is set.
    """
    from .truncate import Truncation

    abscissa = np.asarray(self.abscissa, dtype=float)
    return Truncation(float(abscissa[0]), float(abscissa[-1]))
truncate
truncate(truncation: Any = None) -> TimeHistory

This record cut to a span (core.truncate.truncate), using the history's own truncation unless one is passed.

Parameters:

Name Type Description Default
truncation Truncation

The start and stop, in seconds on the record's own clock. Defaults to the record's own truncation.

None

Returns:

Type Description
TimeHistory

The samples inside the span, every channel, the clock kept.

Source code in src/visualdynamics/core/data.py
def truncate(self, truncation: Any = None) -> TimeHistory:
    """This record cut to a span (`core.truncate.truncate`), using
    the history's own `truncation` unless one is passed.

    Parameters
    ----------
    truncation : Truncation, optional
        The start and stop, in seconds on the record's own clock.
        Defaults to the record's own `truncation`.

    Returns
    -------
    TimeHistory
        The samples inside the span, every channel, the clock
        kept.
    """
    from .truncate import truncate

    chosen = self.truncation if truncation is None else truncation
    if chosen is None:
        raise ValueError('no span to keep: set `truncation` first, '
                         'or pass one')
    return truncate(self, chosen)
suggest_filtering
suggest_filtering() -> Any

A starting low-pass: a tenth of the sample rate, order 4.

A low-pass rather than any other kind, because cutting noise above the content is the reach-for-first case; the filter view offers high- and band-pass beside it.

A judgement, not a detection — nothing in the record says where its content stops being signal. A tenth of the rate is where the integrate/differentiate round trip was measured at ~2% RMS (core.filters), and it sits below the mounted-resonance range a shock accelerometer pollutes. The filter view exists precisely so this number gets looked at rather than trusted.

Source code in src/visualdynamics/core/data.py
def suggest_filtering(self) -> Any:
    """A starting low-pass: a tenth of the sample rate, order 4.

    A low-pass rather than any other kind, because cutting noise
    above the content is the reach-for-first case; the filter view
    offers high- and band-pass beside it.

    A judgement, not a detection — nothing in the record says where
    its content stops being signal. A tenth of the rate is where
    the integrate/differentiate round trip was measured at ~2% RMS
    (core.filters), and it sits below the mounted-resonance range a
    shock accelerometer pollutes. The filter view exists precisely
    so this number gets looked at rather than trusted.
    """
    from .filters import Filtering

    return Filtering(high=self.sample_rate / 10.0)
filter
filter(filtering: Any = None) -> TimeHistory

This record through its filter (core.filters.filtered) — low-, high- or band-pass, whichever the filtering describes — using the history's own filtering unless one is passed.

Parameters:

Name Type Description Default
filtering Filtering

The pass-band edges and order. Defaults to the record's own filtering.

None

Returns:

Type Description
TimeHistory

Every channel through the filter, zero phase.

Source code in src/visualdynamics/core/data.py
def filter(self, filtering: Any = None) -> TimeHistory:
    """This record through its filter (`core.filters.filtered`) —
    low-, high- or band-pass, whichever the filtering describes —
    using the history's own `filtering` unless one is passed.

    Parameters
    ----------
    filtering : Filtering, optional
        The pass-band edges and order. Defaults to the record's
        own `filtering`.

    Returns
    -------
    TimeHistory
        Every channel through the filter, zero phase.
    """
    from .filters import filtered

    chosen = self.filtering if filtering is None else filtering
    if chosen is None:
        raise ValueError('no filter to apply: set `filtering` first, '
                         'or pass one')
    return filtered(self, chosen)
integrate
integrate(drift_corner: Any = ...) -> TimeHistory

One integration — acceleration to velocity, velocity to displacement (core.filters.integrate). ... takes the default drift corner; None integrates raw, drift and all.

Parameters:

Name Type Description Default
drift_corner float or None

High-pass corner in Hz applied after integrating, so a sensor bias cannot become a ramp. ... takes the default; None integrates raw.

...

Returns:

Type Description
TimeHistory

Acceleration becomes velocity, velocity becomes displacement. Other quantities are left out.

Source code in src/visualdynamics/core/data.py
def integrate(self, drift_corner: Any = ...) -> TimeHistory:
    """One integration — acceleration to velocity, velocity to
    displacement (`core.filters.integrate`). ``...`` takes the
    default drift corner; ``None`` integrates raw, drift and all.

    Parameters
    ----------
    drift_corner : float or None, optional
        High-pass corner in Hz applied after integrating, so a sensor
        bias cannot become a ramp. `...` takes the default; `None`
        integrates raw.

    Returns
    -------
    TimeHistory
        Acceleration becomes velocity, velocity becomes
        displacement. Other quantities are left out.
    """
    from .filters import DRIFT_CORNER, integrate

    return integrate(self, DRIFT_CORNER if drift_corner is ...
                     else drift_corner)
differentiate
differentiate() -> TimeHistory

One differentiation — displacement to velocity, velocity to acceleration (core.filters.differentiate).

Source code in src/visualdynamics/core/data.py
def differentiate(self) -> TimeHistory:
    """One differentiation — displacement to velocity, velocity to
    acceleration (`core.filters.differentiate`)."""
    from .filters import differentiate

    return differentiate(self)
srs_windows
srs_windows(
    shocks: Sequence[Any] | None = None,
) -> list[Any]

The stretches an SRS of this record would read, settled.

The fallback chain compute_srs has always used, extracted so the shock panel's derived rows and the spectrum itself cannot disagree about it (one implementation): the shocks the record carries; failing those, the averaging frames when the record is being read as frames; failing everything, the whole record as one window. Clipped to the record either way.

Parameters:

Name Type Description Default
shocks sequence of Shock

Override windows; the record's own when omitted.

None

Returns:

Type Description
list of Shock

The settled windows, clipped to the record.

Source code in src/visualdynamics/core/data.py
def srs_windows(self, shocks: Sequence[Any] | None = None
                ) -> list[Any]:
    """The stretches an SRS of this record would read, settled.

    The fallback chain `compute_srs` has always used, extracted so
    the shock panel's derived rows and the spectrum itself cannot
    disagree about it (one implementation): the shocks the record
    carries; failing those, the averaging frames when the record
    is being read as frames; failing everything, the whole record
    as one window. Clipped to the record either way.

    Parameters
    ----------
    shocks : sequence of Shock, optional
        Override windows; the record's own when omitted.

    Returns
    -------
    list of Shock
        The settled windows, clipped to the record.
    """
    from .shocks import Shock

    rate = self.sample_rate
    samples = self.ordinate.shape[1]
    windows = self.shocks if shocks is None else shocks
    if not windows and self.averaging is not None:
        # the frames, when the record is being read as frames. A
        # transient run's playings are its averaging and nothing
        # else — the detector, asked at the same record, hunts for
        # events it was never meant to find here and answers with
        # its own idea of how many there are. Two readings of one
        # record is exactly what the two buttons refuse to show at
        # once, and the numbers should not disagree either.
        windows = tuple(Shock(start, stop) for start, stop
                        in self.averaging.frame_bounds(rate))
    if not windows:
        windows = (Shock(0.0, samples / rate),)
    return [w.clipped(samples, rate) for w in windows]
srs_band
srs_band(
    shocks: Sequence[Any] | None = None,
) -> tuple[float, float]

(low, high) in Hz: the band these windows can support.

From a frequency low enough that the shortest window still holds a cycle of it — a curve is one grid across every event, so the shortest is what the grid has to fit — up to a fifth of the sample rate, above which the ramp-invariant filter is being asked about frequencies the record cannot resolve. What compute_srs uses when no band is given, and what the shock panel states beside its settings.

Parameters:

Name Type Description Default
shocks sequence of Shock

Override windows; the record's own when omitted.

None

Returns:

Type Description
tuple of float

(low, high) in Hz.

Source code in src/visualdynamics/core/data.py
def srs_band(self, shocks: Sequence[Any] | None = None
             ) -> tuple[float, float]:
    """(low, high) in Hz: the band these windows can support.

    From a frequency low enough that the *shortest* window still
    holds a cycle of it — a curve is one grid across every event,
    so the shortest is what the grid has to fit — up to a fifth
    of the sample rate, above which the ramp-invariant filter is
    being asked about frequencies the record cannot resolve.
    What `compute_srs` uses when no band is given, and what the
    shock panel states beside its settings.

    Parameters
    ----------
    shocks : sequence of Shock, optional
        Override windows; the record's own when omitted.

    Returns
    -------
    tuple of float
        (low, high) in Hz.
    """
    rate = self.sample_rate
    windows = self.srs_windows(shocks)
    shortest = min(w.samples(rate) for w in windows) / rate
    return max(1.0 / shortest, 1.0), rate / 5.0
compute_srs
compute_srs(
    shocks: Sequence[int] | None = None,
    low: float | None = None,
    high: float | None = None,
    per_octave: int | None = None,
    q: float | None = None,
    kind: str = "maximax",
) -> Srs

A shock response spectrum for every channel of every shock.

The parameters live on the history, the way averaging does: pass shocks to override, or leave it and the windows already on the object are used. With none anywhere, the whole record is one window, which is what a history holding a single trimmed transient is.

The windows are the shocks the record carries, or — when it is being read as frames rather than events — the frames. A specification carries neither and is one window: it is a single playing of a waveform, whole.

One curve per channel per event, never averaged across events. A shock test is judged on the worst shock, and the mean of four of them describes none of them. Which event a curve came from is in block, exactly as which average a frame came from is.

The band defaults to what the windows can support: from a frequency low enough that the shortest window still holds a cycle of it — a curve is one grid across every event, so the shortest is what the grid has to fit — up to a fifth of the sample rate, above which the ramp-invariant filter is being asked about frequencies the record cannot resolve.

Parameters:

Name Type Description Default
shocks sequence of int

Which shock windows to use. All of them when omitted.

None
low float

The natural-frequency band, in Hz.

None
high float

The natural-frequency band, in Hz.

None
per_octave int

Frequency lines per octave.

None
q float

The oscillator amplification.

None
kind str

Which peak to keep: 'maximax' (largest magnitude of either sign), 'positive' or 'negative'.

'maximax'

Returns:

Type Description
Srs

One curve per channel per shock.

Source code in src/visualdynamics/core/data.py
def compute_srs(self, shocks: Sequence[int] | None = None,
                low: float | None = None, high: float | None = None,
                per_octave: int | None = None, q: float | None = None,
                kind: str = 'maximax') -> Srs:
    """A shock response spectrum for every channel of every shock.

    The parameters live on the history, the way averaging does: pass
    `shocks` to override, or leave it and the windows already on the
    object are used. With none anywhere, the whole record is one
    window, which is what a history holding a single trimmed
    transient is.

    The windows are the shocks the record carries, or — when it is
    being read as frames rather than events — the frames. A
    specification carries neither and is one window: it is a single
    playing of a waveform, whole.

    One curve per channel *per event*, never averaged across events.
    A shock test is judged on the worst shock, and the mean of four
    of them describes none of them. Which event a curve came from is
    in `block`, exactly as which average a frame came from is.

    The band defaults to what the windows can support: from a
    frequency low enough that the *shortest* window still holds a
    cycle of it — a curve is one grid across every event, so the
    shortest is what the grid has to fit — up to a fifth of the
    sample rate, above which the ramp-invariant filter is being
    asked about frequencies the record cannot resolve.

    Parameters
    ----------
    shocks : sequence of int, optional
        Which shock windows to use. All of them when omitted.
    low, high : float, optional
        The natural-frequency band, in Hz.
    per_octave : int, optional
        Frequency lines per octave.
    q : float, optional
        The oscillator amplification.
    kind : str, default 'maximax'
        Which peak to keep: 'maximax' (largest magnitude of either
        sign), 'positive' or 'negative'.

    Returns
    -------
    Srs
        One curve per channel per shock.
    """
    from .srs import DEFAULT_Q, PER_OCTAVE, maximax, octave_frequencies
    from .srs import peaks as srs_peaks

    rate = self.sample_rate
    windows = self.srs_windows(shocks)
    named = len(windows) > 1
    band_low, band_high = self.srs_band(shocks)
    low = band_low if low is None else float(low)
    high = band_high if high is None else float(high)
    frequencies = octave_frequencies(
        low, high, PER_OCTAVE if per_octave is None else per_octave)
    q = DEFAULT_Q if q is None else float(q)

    rows, dofs, dims, hints, blocks = [], [], [], [], []
    for i, record in enumerate(self.ordinate):
        for k, window in enumerate(windows):
            first, last = window.bounds(rate)
            cut = record.real[first:last]
            if kind == 'maximax':
                rows.append(maximax(cut, frequencies, rate, q=q))
            else:
                highest, lowest = srs_peaks(cut, frequencies, rate, q=q)
                rows.append(highest if kind == 'positive' else -lowest)
            dofs.append(self.response_dof[i])
            dims.append(self.ordinate_dim[i])
            hints.append(self.dimension_hint[i])
            blocks.append(f'shock {k + 1}' if named
                          else (None if self.block is None
                                else self.block[i]))
    return self.srs_type()(
        frequencies, np.asarray(rows, dtype=np.float64),
        response_dof=dofs, ordinate_dim=dims,
        dimension_hint=hints,
        block=blocks if named or self.block is not None else None,
        q=q, kind=kind,
        comment=f'{kind} SRS at Q={q:g}')
compute_psds
compute_psds(averaging: Averaging | None = None) -> Psd

One-sided auto-power spectral density per channel, averaged across the frames.

Welch's method where each average is already its own frame: rectangular window, no overlap, Gxx = 2|X|²/(fs·N) with DC and Nyquist unhalved, the frames' powers averaged. Power is phase-insensitive, so burst random's random phase costs nothing here. Values land in (SI unit)²/Hz with the Psd dimension convention ('acceleration**2/frequency'); a channel with undefined units stays undefined, its hint squared along.

Parameters:

Name Type Description Default
averaging Averaging

How to cut the record into frames. Defaults to the record's own averaging, or one frame per record.

None

Returns:

Type Description
Psd

One auto-power spectral density per channel.

Source code in src/visualdynamics/core/data.py
def compute_psds(self, averaging: Averaging | None = None) -> Psd:
    """One-sided auto-power spectral density per channel, averaged
    across the frames.

    Welch's method where each average is already its own frame:
    rectangular window, no overlap, Gxx = 2|X|²/(fs·N) with DC and
    Nyquist unhalved, the frames' powers averaged. Power is
    phase-insensitive, so burst random's random phase costs
    nothing here. Values land in (SI unit)²/Hz with the Psd
    dimension convention ('acceleration**2/frequency'); a channel
    with undefined units stays undefined, its hint squared along.

    Parameters
    ----------
    averaging : Averaging, optional
        How to cut the record into frames. Defaults to the record's
        own `averaging`, or one frame per record.

    Returns
    -------
    Psd
        One auto-power spectral density per channel.
    """
    frequencies, scale, groups = self._spectral_frame(averaging)
    rows, dofs, dims, hints = [], [], [], []
    for (dof, dim, hint), windowed in groups.items():
        frames = np.fft.rfft(windowed, axis=1)
        rows.append(np.mean(np.abs(frames) ** 2, axis=0) * scale)
        dofs.append(dof)
        dims.append(f'{dim}**2/frequency' if dim != UNKNOWN
                    else UNKNOWN)
        hints.append(f'{hint}**2/frequency' if hint else None)
    out = self.psd_type()(
        frequencies, np.asarray(rows, dtype=np.float64),
        response_dof=dofs, ordinate_dim=dims,
        dimension_hint=hints, comment='averaged PSD')
    # computed from a record, so a density per bin whatever class
    # it came back as — the PSD of a target is a Specification, and
    # a Specification is otherwise a power law through breakpoints
    out.interpolation = 'bin'
    return out
psd_type
psd_type() -> type[Psd]

What a PSD of this history is.

A plain record's spectra are plain spectra. A target's are still a target: the PSD of a waveform the article was required to see is the spectrum it was required to see, and losing that on the way through an FFT would leave two objects of the same class with nothing but a name to say which was the requirement. Overridden in TransientSpecification rather than decided by the caller, so a script and the app cannot disagree.

Source code in src/visualdynamics/core/data.py
def psd_type(self) -> type[Psd]:
    """What a PSD of this history is.

    A plain record's spectra are plain spectra. A **target's** are
    still a target: the PSD of a waveform the article was required
    to see is the spectrum it was required to see, and losing that
    on the way through an FFT would leave two objects of the same
    class with nothing but a name to say which was the requirement.
    Overridden in `TransientSpecification` rather than decided by
    the caller, so a script and the app cannot disagree.
    """
    return Psd
srs_type
srs_type() -> type[Srs]

What an SRS of this history is — see psd_type.

Source code in src/visualdynamics/core/data.py
def srs_type(self) -> type[Srs]:
    """What an SRS of this history is — see `psd_type`."""
    return Srs
compute_cpsds
compute_cpsds(averaging: Averaging | None = None) -> Psd

The full cross-spectral density matrix, averaged across the frames — every channel against every channel, not just each against itself.

Gxy = 2·conj(X)·Y/(fs·N) with DC and Nyquist unhalved, which is compute_psds on the diagonal, where conj(X)·X is |X|². The cross terms are how two channels move together, which is most of what a CPSD is for, and they are what a PSD throws away.

Laid out as the importer lays an imported matrix out: one record per (response, reference) pair, row by row, so an n-channel history gives n² records that read as a grid. A cross term's dimension is the product of the two, a*b/frequency, against a**2/frequency down the diagonal.

Parameters:

Name Type Description Default
averaging Averaging

How to cut the record into frames. Defaults to the record's own averaging, or one frame per record.

None

Returns:

Type Description
Psd

The full cross-spectral matrix, every channel against every channel.

Source code in src/visualdynamics/core/data.py
def compute_cpsds(self, averaging: Averaging | None = None) -> Psd:
    """The full cross-spectral density matrix, averaged across the
    frames — every channel against every channel, not just each
    against itself.

    Gxy = 2·conj(X)·Y/(fs·N) with DC and Nyquist unhalved, which is
    `compute_psds` on the diagonal, where conj(X)·X is |X|². The
    cross terms are how two channels move together, which is most of
    what a CPSD is for, and they are what a PSD throws away.

    Laid out as the importer lays an imported matrix out: one record
    per (response, reference) pair, row by row, so an n-channel
    history gives n² records that read as a grid. A cross term's
    dimension is the product of the two, `a*b/frequency`, against
    `a**2/frequency` down the diagonal.

    Parameters
    ----------
    averaging : Averaging, optional
        How to cut the record into frames. Defaults to the record's
        own `averaging`, or one frame per record.

    Returns
    -------
    Psd
        The full cross-spectral matrix, every channel against
        every channel.
    """
    frequencies, scale, groups = self._spectral_frame(averaging)
    keys = list(groups)
    frames = {key: np.fft.rfft(windowed, axis=1)
              for key, windowed in groups.items()}
    records, responses, references = [], [], []
    dims, hints = [], []
    for row, (dof_i, dim_i, hint_i) in enumerate(keys):
        for column, (dof_j, dim_j, hint_j) in enumerate(keys):
            cross = np.mean(np.conj(frames[keys[row]])
                            * frames[keys[column]], axis=0) * scale
            records.append(cross)
            responses.append(dof_i)
            references.append(dof_j)
            known = UNKNOWN not in (dim_i, dim_j)
            dims.append(
                (f'{dim_i}**2/frequency' if dim_i == dim_j
                 else f'{dim_i}*{dim_j}/frequency') if known
                else UNKNOWN)
            hints.append(
                (f'{hint_i}**2/frequency' if hint_i == hint_j
                 else f'{hint_i}*{hint_j}/frequency')
                if hint_i and hint_j else None)
    return Psd(frequencies, np.asarray(records, dtype=np.complex128),
               response_dof=responses, reference_dof=references,
               ordinate_dim=dims, dimension_hint=hints,
               comment='averaged CPSD')
drive_dofs
drive_dofs() -> list[str]

The DOFs this history looks like it was driven at.

A guess from the quantities alone, and it is only ever a default. What actually makes a channel a drive is that the controller had a feedback device on it, which the channel table records and a time history does not.

Source code in src/visualdynamics/core/data.py
def drive_dofs(self) -> list[str]:
    """The DOFs this history looks like it was driven at.

    A guess from the quantities alone, and it is only ever a
    default. What actually makes a channel a drive is that the
    controller had a feedback device on it, which the channel table
    records and a time history does not.
    """
    return list(dict.fromkeys(
        dof for dof, dim in zip(self.response_dof, self.ordinate_dim)
        if dim in self.EXCITATION_DIMS))
compute_frfs
compute_frfs(
    references: Sequence[str] | None = None,
    averaging: Averaging | None = None,
    method: str = "Hv",
) -> Frf

The frequency response functions, one per response/drive pair.

The three estimators differ in one assumption — where the noise is — and agree wherever there is little of it. They part company exactly where a measurement is worst, which is why the choice matters and why it is a choice.

H1 assumes the noise is on the response. It biases low at resonance, where the response is large and the force small, and it is what a controller computes and a modal fit expects.

Gfx = Gff H,   so   H = Gff^-1 Gfx

With one reference that is the textbook Gfx / Gff. With several it is the MIMO estimate, and the matrix inverse is the whole point: two shakers driving one article are correlated, and dividing each response by each drive separately would credit both with the same motion.

H2 assumes the noise is on the reference, and biases high at anti-resonance for the mirror-image reason. With one reference it is Gxx / Gxf, one response at a time. With several references it needs as many equations as unknowns, and there are exactly enough only when the system is square — as many responses as references — where it becomes the classical coupled form Gxx * Gfx^-1 (Rocklin, Crowley and Vold, 1985), computed here exactly as sdynpy computes it (matched by decision, Brandon 2026-08-28, and pinned against its numbers). The coupling is worth knowing about: every response feeds one matrix inverse, so a channel's H2 depends on which other channels are in the set — measured at 0.2% on the oracle signals, growing with noise — where H1 and Hv rows never do. A non-square multi-reference set is refused; Hv answers the same noise-on-both question per response, uncoupled.

Hv (the default) assumes noise on both and asks for neither: it is the total-least-squares fit, the null direction of

[[Gff, Gfx], [Gxf, Gxx]]

taken as the eigenvector of its smallest eigenvalue, per response and per line. It falls between H1 and H2 — strictly between, wherever the coherence is under one — and needs no claim about which instrument is the better one. It is the default because that claim is the one a test least often gets to make honestly: an accelerometer out on a structure and a force cell in the load path are both imperfect, in different places.

Note what a total-least-squares fit means with units in play: it weighs a unit of error on the force against a unit of error on the acceleration, and those are not the same thing. That is baked into the estimator and is the received formulation; it is why Hv is a middle reading and not a better one.

A pseudo-inverse rather than a solve for H1, because two shakers can be very nearly the same drive and Gff is then close to singular — where a solve raises or returns nonsense, a pseudo-inverse gives the least-squares answer the estimate is asking for anyway.

Built on the same frames compute_psds averages and the same references compute_multiple_coherence uses, so the coherence beside an FRF is that FRF's coherence.

Parameters:

Name Type Description Default
references sequence of str

The drive DOFs. Detected from the record when omitted.

None
averaging Averaging

How to cut the record into frames. Defaults to the record's own averaging, or one frame per record.

None
method str

The estimator: 'Hv', 'H1' or 'H2'.

'Hv'

Returns:

Type Description
Frf

One record per response and drive pair.

Source code in src/visualdynamics/core/data.py
def compute_frfs(self, references: Sequence[str] | None = None,
                 averaging: Averaging | None = None,
                 method: str = 'Hv') -> Frf:
    """The frequency response functions, one per response/drive pair.

    The three estimators differ in one assumption — where the noise
    is — and agree wherever there is little of it. They part company
    exactly where a measurement is worst, which is why the choice
    matters and why it is a choice.

    **H1** assumes the noise is on the *response*. It biases low at
    resonance, where the response is large and the force small, and
    it is what a controller computes and a modal fit expects.

        Gfx = Gff H,   so   H = Gff^-1 Gfx

    With one reference that is the textbook `Gfx / Gff`. With
    several it is the MIMO estimate, and the matrix inverse is the
    whole point: two shakers driving one article are correlated, and
    dividing each response by each drive separately would credit
    both with the same motion.

    **H2** assumes the noise is on the *reference*, and biases high
    at anti-resonance for the mirror-image reason. With one
    reference it is `Gxx / Gxf`, one response at a time. With
    several references it needs as many equations as unknowns, and
    there are exactly enough only when the system is *square* — as
    many responses as references — where it becomes the classical
    coupled form `Gxx * Gfx^-1` (Rocklin, Crowley and Vold, 1985),
    computed here exactly as sdynpy computes it (matched by
    decision, Brandon 2026-08-28, and pinned against its numbers).
    The coupling is worth knowing about: every response feeds one
    matrix inverse, so a channel's H2 depends on which other
    channels are in the set — measured at 0.2% on the oracle
    signals, growing with noise — where H1 and Hv rows never do. A
    non-square multi-reference set is refused; Hv answers the same
    noise-on-both question per response, uncoupled.

    **Hv** (the default) assumes noise on both and asks for neither:
    it is the total-least-squares fit, the null direction of

        [[Gff, Gfx], [Gxf, Gxx]]

    taken as the eigenvector of its smallest eigenvalue, per response
    and per line. It falls between H1 and H2 — strictly between,
    wherever the coherence is under one — and needs no claim about
    which instrument is the better one. It is the default because
    that claim is the one a test least often gets to make honestly:
    an accelerometer out on a structure and a force cell in the load
    path are both imperfect, in different places.

    Note what a total-least-squares fit means with units in play: it
    weighs a unit of error on the force against a unit of error on
    the acceleration, and those are not the same thing. That is
    baked into the estimator and is the received formulation; it is
    why Hv is a middle reading and not a better one.

    A pseudo-inverse rather than a solve for H1, because two shakers
    can be very nearly the same drive and `Gff` is then close to
    singular — where a solve raises or returns nonsense, a
    pseudo-inverse gives the least-squares answer the estimate is
    asking for anyway.

    Built on the same frames `compute_psds` averages and the same
    references `compute_multiple_coherence` uses, so the coherence
    beside an FRF is that FRF's coherence.

    Parameters
    ----------
    references : sequence of str, optional
        The drive DOFs. Detected from the record when omitted.
    averaging : Averaging, optional
        How to cut the record into frames. Defaults to the record's
        own `averaging`, or one frame per record.
    method : str, default 'Hv'
        The estimator: 'Hv', 'H1' or 'H2'.

    Returns
    -------
    Frf
        One record per response and drive pair.
    """
    if method not in self.FRF_METHODS:
        raise ValueError(
            f'{method!r} is not an FRF estimator: '
            + ', '.join(self.FRF_METHODS))
    frequencies, keys, drives, responses, cross, averages = \
        self._cross_spectral_frame('FRFs', references, averaging)
    # Multi-reference H2 exists only where the system is square —
    # as many responses as references — and there it is the
    # classical coupled form, Gxx * Gfx^-1 (Rocklin, Crowley and
    # Vold, 1985), adopted to match sdynpy (Brandon, 2026-08-28;
    # this file refused it for a day first, and the reasoning both
    # ways is worth keeping). The coupling is real: every response
    # feeds the one inverse, so a channel's H2 depends on which
    # other channels are in the set — measured at 0.2% on the
    # oracle signals with 5% noise, growing with noise, where H1
    # is bit-identical under the same swap. That property argued
    # for refusing; matching the tool the audience already trusts
    # argued for computing; Brandon chose compatibility, and the
    # property is documented instead of avoided. Non-square
    # multi-reference stays refused — there the equations genuinely
    # cannot balance — and Hv answers per response, uncoupled.
    coupled = None
    if method == 'H2' and len(drives) > 1:
        if len(responses) != len(drives):
            raise ValueError(
                f'H2 with {len(drives)} references needs exactly '
                f'{len(drives)} responses (the coupled square form) '
                f'and this history has {len(responses)}. '
                f'Hv is the estimator that answers the same question '
                f'for any shape.')
        cxx = np.stack([np.stack([cross(i, j) for j in responses],
                                 axis=-1)
                        for i in responses], axis=-2)
        cfx = np.stack([np.stack([cross(i, j) for j in responses],
                                 axis=-1)
                        for i in drives], axis=-2)
        # H solves H * Gfx = Gxx, taken as solve(Gfx^T, Gxx^T)^T per
        # line. The outer conj translates conventions: this file's
        # cross() is Bendat & Piersol's conj(X)*Y, the classical
        # form is written in X*conj(Y), and the two matrices are
        # elementwise conjugates — verified against sdynpy's own
        # square H2 at machine precision rather than trusted.
        coupled = np.conj(np.swapaxes(
            np.linalg.solve(np.swapaxes(cfx, -2, -1),
                            np.swapaxes(cxx, -2, -1)), -2, -1))

    estimator = getattr(self, f'_frf_{method.lower()}')
    gff = np.stack([np.stack([cross(i, j) for j in drives], axis=-1)
                    for i in drives], axis=-2)

    rows, response_dof, reference_dof, dims, hints = [], [], [], [], []
    for position, x in enumerate(responses):
        if coupled is not None:
            estimate = coupled[:, position, :]
        else:
            gfx = np.stack([cross(i, x) for i in drives], axis=-1)
            estimate = estimator(gff, gfx, np.real(cross(x, x)))
        # response-major, so a pair reads the way it is named:
        # every drive for one response, then the next response
        for column, i in enumerate(drives):
            rows.append(estimate[:, column])
            response_dof.append(keys[x][0])
            reference_dof.append(keys[i][0])
            dims.append(self._ratio(keys[x][1], keys[i][1]))
            hints.append(self._ratio(keys[x][2], keys[i][2])
                         if keys[x][2] and keys[i][2] else None)
    return Frf(frequencies, np.asarray(rows), response_dof=response_dof,
               reference_dof=reference_dof, ordinate_dim=dims,
               dimension_hint=hints,
               comment=f'{method} from {len(drives)} '
                       f'reference{"s" * (len(drives) != 1)}, '
                       f'{averages} averages')
compute_multiple_coherence
compute_multiple_coherence(
    references: Sequence[str] | None = None,
    averaging: Averaging | None = None,
) -> MultipleCoherence

How much of each response the drives together account for.

Ordinary coherence asks what one reference explains. Multiple coherence asks what a whole set of them explains at once, which is the only useful question in a MIMO test: two shakers driving one article are correlated with each other, so a response can look poorly coherent with either one alone while being fully accounted for by the pair.

For a response x and references r,

gamma^2 = (Grx^H Grr^-1 Grx) / Gxx

— the power of the best linear prediction of x from all the references at once, over the power actually measured. With one reference it collapses to the ordinary coherence, which is the cheapest check that the algebra is right.

Built on the same frames compute_psds averages, so it covers the stretch of record the averaging view has set and no other: a coherence worked out over the whole file would describe a different measurement from the PSD beside it.

references and the framing are _cross_spectral_frame's, the same ones compute_frfs uses — so the coherence beside an FRF is that FRF's coherence and not a differently-framed one.

A pseudo-inverse rather than a solve, because two shakers driving one article can be very nearly the same drive and the reference matrix is then close to singular — where a solve raises or returns nonsense, a pseudo-inverse gives the least-squares answer the estimate is asking for anyway.

Parameters:

Name Type Description Default
references sequence of str

The drive DOFs. Detected from the record when omitted.

None
averaging Averaging

How to cut the record into frames. Defaults to the record's own averaging, or one frame per record.

None

Returns:

Type Description
MultipleCoherence

One curve per response channel.

Source code in src/visualdynamics/core/data.py
def compute_multiple_coherence(
        self, references: Sequence[str] | None = None,
        averaging: Averaging | None = None) -> MultipleCoherence:
    """How much of each response the drives together account for.

    Ordinary coherence asks what one reference explains. Multiple
    coherence asks what a whole set of them explains at once, which
    is the only useful question in a MIMO test: two shakers driving
    one article are correlated with each other, so a response can
    look poorly coherent with either one alone while being fully
    accounted for by the pair.

    For a response x and references r,

        gamma^2 = (Grx^H Grr^-1 Grx) / Gxx

    — the power of the best linear prediction of x from all the
    references at once, over the power actually measured. With one
    reference it collapses to the ordinary coherence, which is the
    cheapest check that the algebra is right.

    Built on the same frames `compute_psds` averages, so it covers
    the stretch of record the averaging view has set and no other:
    a coherence worked out over the whole file would describe a
    different measurement from the PSD beside it.

    `references` and the framing are `_cross_spectral_frame`'s, the
    same ones `compute_frfs` uses — so the coherence beside an FRF
    is that FRF's coherence and not a differently-framed one.

    A pseudo-inverse rather than a solve, because two shakers
    driving one article can be very nearly the same drive and the
    reference matrix is then close to singular — where a solve
    raises or returns nonsense, a pseudo-inverse gives the
    least-squares answer the estimate is asking for anyway.

    Parameters
    ----------
    references : sequence of str, optional
        The drive DOFs. Detected from the record when omitted.
    averaging : Averaging, optional
        How to cut the record into frames. Defaults to the record's
        own `averaging`, or one frame per record.

    Returns
    -------
    MultipleCoherence
        One curve per response channel.
    """
    frequencies, keys, drives, responses, cross, averages = \
        self._cross_spectral_frame('multiple coherence', references,
                                   averaging)

    # (lines, drives, drives), Hermitian and positive semi-definite
    grr = np.stack([np.stack([cross(i, j) for j in drives], axis=-1)
                    for i in drives], axis=-2)
    inverse = np.linalg.pinv(grr)

    rows = []
    for i in responses:
        grx = np.stack([cross(k, i) for k in drives], axis=-1)[..., None]
        explained = np.real(
            np.conj(np.swapaxes(grx, -1, -2)) @ inverse @ grx)[..., 0, 0]
        measured = np.real(cross(i, i))
        with np.errstate(divide='ignore', invalid='ignore'):
            value = np.where(measured > 0.0, explained / measured, 0.0)
        # a ratio of powers cannot exceed one; a few parts in 1e12
        # over is the arithmetic, and a real overshoot means too few
        # averages for the number of references, which is a property
        # of the analysis and not of the article
        rows.append(np.clip(value, 0.0, 1.0))
    return MultipleCoherence(
        frequencies, np.asarray(rows, dtype=np.float64),
        response_dof=[keys[i][0] for i in responses],
        comment=f'multiple coherence against {len(drives)} references, '
                f'{averages} averages')
to_sep005
to_sep005(name: str | None = None) -> list[dict[str, Any]]

This record as SEP 005 timeseries — the sdypy ecosystem's interchange form (io.sep005 reads them back).

timeseries = history.to_sep005('run 4')

Returns the standard's list form: usually one dict, and one per unit where channels mix. The sdypy validator holds a series' unit_str to a single string, so accelerometers beside a force gauge cannot be one compliant series — the list of series is exactly what the standard provides for that, and a split series wears the unit in its name so two of them stay distinguishable.

Values go exactly as they are held: SI where units are defined, with unit_str naming the SI unit, and the file's raw numbers where they are not, with unit_str empty — the standard allows an empty unit, and inventing one would claim a scale nobody declared. fs says the sampling when it is even; an uneven record sends its time vector, which the standard equally accepts. quantity rides where the standard has a letter for what a series measures.

name defaults to the comment when the record carries one, because a SEP 005 series must be named and the comment is the nearest thing to a name an object holds — the project knows what it called this record, the record does not.

Parameters:

Name Type Description Default
name str

A name for the series.

None

Returns:

Type Description
list of dict

One SEP 005 timeseries mapping per channel.

Source code in src/visualdynamics/core/data.py
def to_sep005(self, name: str | None = None) -> list[dict[str, Any]]:
    """This record as SEP 005 timeseries — the sdypy ecosystem's
    interchange form (`io.sep005` reads them back).

        timeseries = history.to_sep005('run 4')

    Returns the standard's **list** form: usually one dict, and one
    per unit where channels mix. The sdypy validator holds a
    series' ``unit_str`` to a single string, so accelerometers
    beside a force gauge cannot be one compliant series — the list
    of series is exactly what the standard provides for that, and a
    split series wears the unit in its name so two of them stay
    distinguishable.

    Values go exactly as they are held: SI where units are defined,
    with ``unit_str`` naming the SI unit, and the file's raw
    numbers where they are not, with ``unit_str`` empty — the
    standard allows an empty unit, and inventing one would claim a
    scale nobody declared. ``fs`` says the sampling when it is
    even; an uneven record sends its ``time`` vector, which the
    standard equally accepts. ``quantity`` rides where the
    standard has a letter for what a series measures.

    `name` defaults to the comment when the record carries one,
    because a SEP 005 series must be named and the comment is the
    nearest thing to a name an object holds — the project knows
    what it called this record, the record does not.

    Parameters
    ----------
    name : str, optional
        A name for the series.

    Returns
    -------
    list of dict
        One SEP 005 timeseries mapping per channel.
    """
    from ..io.sep005 import DIMENSION_TO_QUANTITY
    from ..units import SI

    name = str(name or next((c for c in self.comment if c),
                            'time history'))
    groups: dict[tuple[str, str | None], list[int]] = {}
    for i in range(self.num_records):
        unit = (SI.label(self.ordinate_dim[i])
                if self.ordinate_unit[i] is not None else '')
        letter = DIMENSION_TO_QUANTITY.get(self.known_dim(i))
        groups.setdefault((unit, letter), []).append(i)
    data = np.asarray(np.real(self.ordinate), dtype=float)
    out = []
    for (unit, letter), rows in groups.items():
        series: dict[str, Any] = {
            # (n,) for a lone channel — the standard's preferred
            # single-channel shape, and the one the sdypy validator
            # measures a `time` vector against correctly
            'data': data[rows] if len(rows) > 1 else data[rows[0]],
            'name': (name if len(groups) == 1
                     else f'{name} [{unit or letter or "raw"}]'),
            'channel_name': [str(self.response_dof[i]) for i in rows],
            'unit_str': unit,
        }
        try:
            series['fs'] = float(self.sample_rate)
        except ValueError:
            series['time'] = np.asarray(self.abscissa, dtype=float)
        if letter is not None:
            series['quantity'] = letter
        out.append(series)
    return out

TransientSpecification

TransientSpecification(
    abscissa: ArrayLike,
    ordinate: ArrayLike,
    response_dof: str | Sequence[str],
    reference_dof: str | Sequence[str] | None = None,
    ordinate_dim: str | Sequence[str] | None = None,
    comment: str | Sequence[str] | None = None,
    ordinate_unit: str | Sequence[str | None] | None = None,
    reference_unit: str
    | Sequence[str | None]
    | None = None,
    dimension_hint: str
    | Sequence[str | None]
    | None = None,
    block: str | Sequence[str] | None = None,
)

Bases: TimeHistory

What a transient test was controlled to: a target time history.

A different thing from a ShockSpecification, and the difference is worth keeping straight because the two get called by each other's names. A shock specification is an SRS: a required response spectrum, and a controller meets it by producing some transient whose spectrum lands inside the band. A transient specification is a waveform: this acceleration, sample by sample, and the controller inverts the structure's transfer function to reproduce it. Rattlesnake can run the second today; the first it cannot.

So this is a time history that happens to be a target, and the comparison it invites is against another time history — what the article actually did — rather than against a band. It carries no limits for that reason: a tolerance on a waveform is not a settled idea the way a tolerance on a spectrum is, and inventing one here would be inventing a convention rather than reading one.

Its derived spectra stay targets. A PSD of this is a Specification and an SRS of it is a ShockSpecification — both without limits, for the reason above — because the spectrum of a waveform the article was required to see is the spectrum it was required to see. Left as plain objects they would be indistinguishable from the response's own spectra but for a name, and the comparison between them would have to be made by hand instead of by type, which is the one thing this whole arrangement exists to avoid.

Methods:

Name Description
psd_type

What a PSD of this record is: still a specification.

srs_type

What an SRS of this record is: a shock specification, for

Source code in src/visualdynamics/core/data.py
def __init__(self, abscissa: ArrayLike, ordinate: ArrayLike,
             response_dof: str | Sequence[str],
             reference_dof: str | Sequence[str] | None = None,
             ordinate_dim: str | Sequence[str] | None = None,
             comment: str | Sequence[str] | None = None,
             ordinate_unit: str | Sequence[str | None] | None = None,
             reference_unit: str | Sequence[str | None] | None = None,
             dimension_hint: str | Sequence[str | None] | None = None,
             block: str | Sequence[str] | None = None) -> None:
    self.abscissa: np.ndarray = np.asarray(abscissa, dtype=np.float64)
    ordinate = np.atleast_2d(np.asarray(
        ordinate, dtype=np.complex128 if self.complex_ordinate else np.float64))
    self.ordinate: np.ndarray = ordinate
    n = ordinate.shape[0]
    if self.abscissa.ndim != 1 or ordinate.shape[1] != len(self.abscissa):
        raise ValueError(
            f'ordinate shape {ordinate.shape} does not match '
            f'abscissa length {len(self.abscissa)}')
    # a channel whose node or direction was never recorded imports
    # as a DOF of '' — see `validate.dofs`
    self.response_dof: list[str] = _dofs(
        self._str_list(response_dof, n, 'response_dof'), 'response DOF',
        allow_unknown=True)
    self.reference_dof: list[str] | None
    if reference_dof is None:
        if self.needs_reference:
            raise ValueError(f'{type(self).__name__} requires reference_dof')
        self.reference_dof = None
    else:
        self.reference_dof = _dofs(
            self._str_list(reference_dof, n, 'reference_dof'),
            'reference DOF', allow_unknown=True)
    # A record is its response plus, sometimes, one more thing. For a
    # measurement against something else that is the reference DOF; for
    # the same measurement repeated it is which repeat — an average, a
    # run, a temperature. `block` holds the short label of that second
    # kind, and nothing about it is time-specific.
    self.block: list[str] | None = (None if block is None
                                    else self._str_list(block, n, 'block'))
    self.ordinate_dim: list[str] = self._str_list(
        UNKNOWN if ordinate_dim is None else ordinate_dim, n, 'ordinate_dim')
    for dim in set(self.ordinate_dim):
        parse_dimension(dim)  # validates
    self.ordinate_unit: list[str | None] = self._opt_list(
        ordinate_unit, n, 'ordinate_unit')
    self.reference_unit: list[str | None] = self._opt_list(
        reference_unit, n, 'reference_unit')
    self.comment: list[str] = self._str_list(
        comment if comment is not None else '', n, 'comment')
    self.dimension_hint: list[str | None] = self._opt_list(
        dimension_hint, n, 'dimension_hint')
    for hint in set(self.dimension_hint):
        if hint is not None:
            parse_dimension(hint)  # validates
    self._fill_si_units()
    self._drop_stale_hints()
Methods:
psd_type
psd_type() -> type[Psd]

What a PSD of this record is: still a specification.

The spectrum of a required waveform is itself a requirement, so it comes back as Specification rather than a plain Psd.

Source code in src/visualdynamics/core/data.py
def psd_type(self) -> type[Psd]:
    """What a PSD of this record is: still a specification.

    The spectrum of a required waveform is itself a requirement,
    so it comes back as `Specification` rather than a plain `Psd`.
    """
    return Specification
srs_type
srs_type() -> type[Srs]

What an SRS of this record is: a shock specification, for the same reason psd_type gives.

Source code in src/visualdynamics/core/data.py
def srs_type(self) -> type[Srs]:
    """What an SRS of this record is: a shock specification, for
    the same reason `psd_type` gives."""
    return ShockSpecification

Geometry

Geometry(
    node_id: Ids,
    node_xyz: ArrayLike,
    node_def_cs: ArrayLike | None = None,
    node_disp_cs: ArrayLike | None = None,
    node_color: ArrayLike | None = None,
    cs_id: Ids | None = None,
    cs_name: Sequence[str] | None = None,
    cs_type: ArrayLike | None = None,
    cs_matrix: ArrayLike | None = None,
    traceline_id: Ids | None = None,
    traceline_color: ArrayLike | None = None,
    traceline_desc: Sequence[str] | None = None,
    traceline_conn: Sequence[ArrayLike] | None = None,
    elem_id: Ids | None = None,
    elem_type: ArrayLike | None = None,
    elem_color: ArrayLike | None = None,
    elem_conn: Sequence[ArrayLike] | None = None,
    elem_block: Ids | None = None,
    block_id: Ids | None = None,
    block_name: Sequence[str] | None = None,
    length_unit: str | None = None,
)

Nodes, coordinate systems, tracelines, elements and blocks.

Parameters are array-likes; connectivity lists contain one integer array of node ids per traceline/element. All coordinates in SI meters.

The arrays below are the storage; nodes, coordinate_systems, tracelines, elements and blocks are views onto them, which is how the tree lists a geometry and how a script should usually reach one. A view is not a copy — writing through a row writes here.

Attributes: node_id: Every node's id. Unique, because connectivity and placement refer to nodes by id. node_xyz: (nodes, 3) coordinates, in metres once length_unit is declared and the file's raw numbers before that. node_def_cs: The coordinate system each node is placed in. node_disp_cs: The system each node is measured in — the frame a shape's values at that node are expressed in. node_color: Palette index per node. cs_id: Coordinate system ids. Unique, for the same reason as nodes. cs_name: A name per system, often empty. cs_type: 0 cartesian, 1 cylindrical, 2 spherical (CS_TYPES). cs_matrix: (systems, 4, 3) — three direction rows then the origin, so cs_matrix[i, 3] is where system i sits. traceline_id: Ids of the display polylines. Not unique: a UNV trace line that lifts the pen arrives as several runs under one id, and deleting that id removes all of them. traceline_color: Palette index per line. traceline_desc: Free text per line. traceline_conn: One array of node ids per line, in drawing order. elem_id: Element ids. Labels — nothing refers to them. elem_type: UFF dataset 2412 descriptor code per element (ELEMENT_TYPES names them and says how each is drawn). elem_color: Palette index per element. elem_conn: One array of node ids per element. elem_block: Which block each element belongs to, by block id. block_id: The declared blocks. Unique, since elements name them. block_name: A name per block — 'wing', 'arm front left'. This is where a mesh records that a region is a different part from its neighbour, and fem.Model.from_geometry reads a member's section from it. length_unit: What the coordinates are in, or None while that has not been declared — in which case they are the file's own numbers and nothing has been scaled.

Methods:

Name Description
validate

Check the geometry hangs together — every element's nodes

node_index

Positions of the given node ids in the node arrays.

contains_nodes

Boolean array: which of node_ids this geometry defines.

missing_dofs

The DOF strings whose node this geometry does not define.

suggest_mass_properties

The centroid of the nodes as the reference point, and no

define_units

Declare what the coordinates are in, converting them to SI.

undefine_units

Take the declaration back, restoring the file's raw coordinates.

add_node

Append a node at xyz (SI). Returns its id.

add_coordinate_system

Append a coordinate system. Returns its id.

add_traceline

Append a traceline through the given nodes. Returns its index.

block_of

The name of the block an element belongs to, or ''.

elements_in

The ids of the elements in a block, named or numbered.

add_block

Declare an element block. Returns its id.

add_element

Append an element. Type defaults to whatever fits the node count:

renumber_node

Give a node a new id, carrying its tracelines and elements over.

renumber_block

Give a block a new id, carrying its elements over.

renumber_coordinate_system

Give a coordinate system a new id, repointing the nodes using it.

delete_nodes

Remove nodes, and anything that referenced them.

delete_coordinate_systems

Remove coordinate systems, reassigning any node that used them.

delete_tracelines

Remove tracelines by id. Ids that are not there are ignored.

delete_blocks

Remove blocks, moving anything in them into the first one left.

delete_elements

Remove elements by id. Ids that are not there are ignored.

save

Write the geometry to a file of its own.

plot

Draw the geometry: nodes, elements and tracelines.

plot_dofs

This geometry with labelled arrows at every DOF source

Attributes:

Name Type Description
num_nodes int

How many nodes the geometry defines.

nodes EntityView

Every node: ids, xyz, colors, and the two systems.

coordinate_systems EntityView

Every coordinate system: ids, names, types, matrices.

tracelines EntityView

Every traceline: ids, descriptions, colors, nodes.

elements EntityView

Every element: ids, types, colors, nodes.

blocks EntityView

Every element block: ids and names.

extent tuple[ndarray, ndarray]

(min_xyz, max_xyz), in meters once units are defined.

units_defined bool

Whether the geometry knows what its coordinates mean.

Source code in src/visualdynamics/core/geometry.py
def __init__(self, node_id: Ids, node_xyz: ArrayLike,
             node_def_cs: ArrayLike | None = None,
             node_disp_cs: ArrayLike | None = None,
             node_color: ArrayLike | None = None,
             cs_id: Ids | None = None,
             cs_name: Sequence[str] | None = None,
             cs_type: ArrayLike | None = None,
             cs_matrix: ArrayLike | None = None,
             traceline_id: Ids | None = None,
             traceline_color: ArrayLike | None = None,
             traceline_desc: Sequence[str] | None = None,
             traceline_conn: Sequence[ArrayLike] | None = None,
             elem_id: Ids | None = None,
             elem_type: ArrayLike | None = None,
             elem_color: ArrayLike | None = None,
             elem_conn: Sequence[ArrayLike] | None = None,
             elem_block: Ids | None = None,
             block_id: Ids | None = None,
             block_name: Sequence[str] | None = None,
             length_unit: str | None = None) -> None:
    # coordinates are taken as given; length_unit records what they are
    # in (None = undefined, values are the file's raw numbers)
    self.length_unit: str | None = length_unit
    n = len(node_id)
    self.node_id: IdArray = _ids(node_id, 'node ids', unique=True)
    self.node_xyz: NDArray[np.float64] = np.asarray(
        node_xyz, dtype=np.float64).reshape(n, 3)
    self.node_def_cs: IdArray = self._default(node_def_cs, n, 1)
    self.node_disp_cs: IdArray = self._default(node_disp_cs, n, 1)
    self.node_color: NDArray[np.int64] = self._default(node_color, n, 1)

    if cs_id is None:
        cs_id, cs_name, cs_type = [1], [''], [0]
        cs_matrix = np.vstack([np.eye(3), np.zeros(3)])[np.newaxis]
    c = len(cs_id)
    self.cs_id: IdArray = _ids(cs_id, 'coordinate system ids', unique=True)
    self.cs_name: list[str] = (list(cs_name) if cs_name is not None
                               else [''] * c)
    self.cs_type: NDArray[np.int64] = self._default(cs_type, c, 0)
    self.cs_matrix: NDArray[np.float64] = (
        np.asarray(cs_matrix, dtype=np.float64).reshape(c, 4, 3)
        if cs_matrix is not None
        else np.tile(np.vstack([np.eye(3), np.zeros(3)]), (c, 1, 1)))

    t = len(traceline_conn) if traceline_conn is not None else 0
    self.traceline_id: IdArray = (
        _ids(traceline_id, 'traceline ids') if traceline_id is not None
        else self._default(None, t, None, arange=True))
    self.traceline_color: NDArray[np.int64] = self._default(
        traceline_color, t, 1)
    self.traceline_desc: list[str] = (
        list(traceline_desc) if traceline_desc is not None else [''] * t)
    self.traceline_conn: list[IdArray] = [
        np.asarray(c, dtype=np.int64) for c in (traceline_conn or [])]

    e = len(elem_conn) if elem_conn is not None else 0
    self.elem_id: IdArray = (
        _ids(elem_id, 'element ids') if elem_id is not None
        else self._default(None, e, None, arange=True))
    self.elem_type: NDArray[np.int64] = self._default(elem_type, e, 0)
    self.elem_color: NDArray[np.int64] = self._default(elem_color, e, 1)
    # Which block each element belongs to, and what the blocks are
    # called. This is how a mesh says "these elements are the wing and
    # those are the tail": exodus calls them element blocks, and it is
    # the only place a file records that a region is made of something
    # different from its neighbour. Read without keeping it, a
    # two-block file comes back as one anonymous block on the way out.
    self.elem_block: IdArray = (
        _ids(elem_block, 'element block ids') if elem_block is not None
        else self._default(None, e, 1))
    found = (np.unique(self.elem_block) if e
             else np.array([], dtype=np.int64))
    self.block_id: IdArray = (
        _ids(block_id, 'block ids', unique=True)
        if block_id is not None else found)
    self.block_name: list[str] = (list(block_name) if block_name is not None
                                  else [''] * len(self.block_id))
    self.elem_conn: list[IdArray] = [
        np.asarray(c, dtype=np.int64) for c in (elem_conn or [])]
    #: the reference point, mass and inertia the rigid-body view
    #: sets, riding the geometry the way averaging rides a time
    #: history (`core.rigid`); None until set or adopted
    self.mass_properties: MassProperties | None = None

    self.validate()
Attributes
num_nodes property
num_nodes: int

How many nodes the geometry defines.

nodes property
nodes: EntityView

Every node: ids, xyz, colors, and the two systems.

coordinate_systems property
coordinate_systems: EntityView

Every coordinate system: ids, names, types, matrices.

tracelines property
tracelines: EntityView

Every traceline: ids, descriptions, colors, nodes.

elements property
elements: EntityView

Every element: ids, types, colors, nodes.

blocks property
blocks: EntityView

Every element block: ids and names.

A block groups elements rather than holding them — which elements are in one is read off elem_block (elements_in), so moving an element between blocks is an edit to the element.

extent property
extent: tuple[ndarray, ndarray]

(min_xyz, max_xyz), in meters once units are defined.

units_defined property
units_defined: bool

Whether the geometry knows what its coordinates mean. False until define_units names the length unit.

Methods:
validate
validate() -> None

Check the geometry hangs together — every element's nodes present, every identifier unique — and report what does not.

Source code in src/visualdynamics/core/geometry.py
def validate(self) -> None:
    """Check the geometry hangs together — every element's nodes
    present, every identifier unique — and report what does not."""
    # An id that other data points at has to mean one thing:
    # connectivity names nodes by id, and a node names the systems it
    # is placed and measured in. Traceline and element ids are
    # labels — nothing refers to them — and a UNV trace line that
    # lifts the pen legitimately arrives as several polylines under
    # one id, so they are not held to this.
    # the same helper the constructor uses, so a duplicate introduced
    # by an edit is refused in the same words as one handed in
    for label, values in (('node ids', self.node_id),
                          ('coordinate system ids', self.cs_id)):
        _ids(values, label, unique=True)
    known = set(self.node_id.tolist())
    for kind, conns in (('traceline', self.traceline_conn),
                        ('element', self.elem_conn)):
        for i, conn in enumerate(conns):
            missing = set(conn.tolist()) - known
            if missing:
                raise ValueError(
                    f"{kind} {i} references unknown node ids {sorted(missing)}")
    if len(self.block_name) != len(self.block_id):
        raise ValueError(
            f'{len(self.block_name)} block names for '
            f'{len(self.block_id)} blocks')
    _ids(self.block_id, 'block ids', unique=True)
    stray = set(np.unique(self.elem_block).tolist()) - set(
        self.block_id.tolist())
    if stray:
        raise ValueError(
            'elements name blocks the geometry does not have: '
            + ', '.join(str(b) for b in sorted(stray)))
    for code in np.unique(self.elem_type) if len(self.elem_type) else []:
        if int(code) not in ELEMENT_TYPES:
            raise ValueError(f"Unknown element type code {int(code)}")
node_index
node_index(node_ids: Ids) -> ndarray

Positions of the given node ids in the node arrays.

Parameters:

Name Type Description Default
node_ids int or sequence of int

The identifiers, one or many.

required

Returns:

Type Description
ndarray

Each identifier's row in the node arrays.

Source code in src/visualdynamics/core/geometry.py
def node_index(self, node_ids: Ids) -> np.ndarray:
    """Positions of the given node ids in the node arrays.

    Parameters
    ----------
    node_ids : int or sequence of int
        The identifiers, one or many.

    Returns
    -------
    numpy.ndarray
        Each identifier's row in the node arrays.
    """
    order = np.argsort(self.node_id)
    pos = np.searchsorted(self.node_id, node_ids, sorter=order)
    return order[pos]
contains_nodes
contains_nodes(node_ids: Ids) -> ndarray

Boolean array: which of node_ids this geometry defines.

Parameters:

Name Type Description Default
node_ids int or sequence of int

The identifiers, one or many.

required

Returns:

Type Description
ndarray

A boolean per identifier: whether the geometry has it.

Source code in src/visualdynamics/core/geometry.py
def contains_nodes(self, node_ids: Ids) -> np.ndarray:
    """Boolean array: which of `node_ids` this geometry defines.

    Parameters
    ----------
    node_ids : int or sequence of int
        The identifiers, one or many.

    Returns
    -------
    numpy.ndarray
        A boolean per identifier: whether the geometry has it.
    """
    return np.isin(np.asarray(node_ids), self.node_id)
missing_dofs
missing_dofs(dofs: Sequence[str]) -> list[str]

The DOF strings whose node this geometry does not define.

Parameters:

Name Type Description Default
dofs sequence of str

The degrees of freedom to check, such as '101Z+'.

required

Returns:

Type Description
list of str

Those the geometry has no node for — what makes a data object incompatible with it.

Source code in src/visualdynamics/core/geometry.py
def missing_dofs(self, dofs: Sequence[str]) -> list[str]:
    """The DOF strings whose node this geometry does not define.

    Parameters
    ----------
    dofs : sequence of str
        The degrees of freedom to check, such as '101Z+'.

    Returns
    -------
    list of str
        Those the geometry has no node for — what makes a
        data object incompatible with it.
    """
    from .data import parse_dof

    nodes = [parse_dof(dof)[0] for dof in dofs]
    known = self.contains_nodes([-1 if n is None else n for n in nodes])
    return [dof for dof, present in zip(dofs, known) if not present]
suggest_mass_properties
suggest_mass_properties() -> MassProperties

The centroid of the nodes as the reference point, and no mass — unit rigid-body shapes about the middle of the model.

The seed the rigid-body pane opens with and what generate_rigid_body_modes adopts when nothing was set: unlike a whole-record truncation, this is a real answer, and the one the virtual-point transformation wants most often.

Returns:

Type Description
MassProperties

The centroid, unscaled.

Source code in src/visualdynamics/core/geometry.py
def suggest_mass_properties(self) -> MassProperties:
    """The centroid of the nodes as the reference point, and no
    mass — unit rigid-body shapes about the middle of the model.

    The seed the rigid-body pane opens with and what
    `generate_rigid_body_modes` adopts when nothing was set: unlike
    a whole-record truncation, this is a real answer, and the one
    the virtual-point transformation wants most often.

    Returns
    -------
    MassProperties
        The centroid, unscaled.
    """
    from .rigid import MassProperties

    if self.num_nodes == 0:
        raise ValueError('the geometry has no nodes to take the '
                         'centroid of')
    return MassProperties(tuple(self.node_xyz.mean(axis=0)))
define_units
define_units(length_unit: str) -> Geometry

Declare what the coordinates are in, converting them to SI.

Re-declaring reinterprets the original file values rather than scaling twice, so a wrong guess can simply be corrected.

Parameters:

Name Type Description Default
length_unit str

The unit the coordinates are in, such as 'm' or 'in'.

required

Returns:

Type Description
Geometry

Self, converted to SI in place.

Source code in src/visualdynamics/core/geometry.py
def define_units(self, length_unit: str) -> Geometry:
    """Declare what the coordinates are in, converting them to SI.

    Re-declaring reinterprets the original file values rather than
    scaling twice, so a wrong guess can simply be corrected.

    Parameters
    ----------
    length_unit : str
        The unit the coordinates are in, such as 'm' or 'in'.

    Returns
    -------
    Geometry
        Self, converted to SI in place.
    """
    scale, _ = si_transform(length_unit, 'length')
    raw = (self.node_xyz if self.length_unit is None
           else from_si(self.node_xyz, self.length_unit, 'length'))
    self.node_xyz = raw * scale
    origins = self.cs_matrix[:, 3, :]
    raw_origins = (origins if self.length_unit is None
                   else from_si(origins, self.length_unit, 'length'))
    self.cs_matrix[:, 3, :] = raw_origins * scale
    self.length_unit = length_unit
    return self
undefine_units
undefine_units() -> Geometry

Take the declaration back, restoring the file's raw coordinates.

Source code in src/visualdynamics/core/geometry.py
def undefine_units(self) -> Geometry:
    """Take the declaration back, restoring the file's raw coordinates."""
    if self.length_unit is None:
        return self
    self.node_xyz = from_si(self.node_xyz, self.length_unit, 'length')
    self.cs_matrix[:, 3, :] = from_si(self.cs_matrix[:, 3, :],
                                      self.length_unit, 'length')
    self.length_unit = None
    return self
add_node
add_node(
    xyz: ArrayLike,
    node_id: int | None = None,
    color: int = 1,
    def_cs: int | None = None,
    disp_cs: int | None = None,
) -> int

Append a node at xyz (SI). Returns its id.

Parameters:

Name Type Description Default
xyz array_like

The node's coordinates.

required
node_id int

Its identifier. The next free one when omitted.

None
color int

Its display colour index.

1
def_cs int

The coordinate system the position is given in.

None
disp_cs int

The coordinate system displacements are measured in.

None

Returns:

Type Description
int

The node's identifier.

Source code in src/visualdynamics/core/geometry.py
def add_node(self, xyz: ArrayLike, node_id: int | None = None,
             color: int = 1, def_cs: int | None = None,
             disp_cs: int | None = None) -> int:
    """Append a node at `xyz` (SI). Returns its id.

    Parameters
    ----------
    xyz : array_like
        The node's coordinates.
    node_id : int, optional
        Its identifier. The next free one when omitted.
    color : int, default 1
        Its display colour index.
    def_cs : int, optional
        The coordinate system the position is given in.
    disp_cs : int, optional
        The coordinate system displacements are measured in.

    Returns
    -------
    int
        The node's identifier.
    """
    node_id = self._next_id(self.node_id) if node_id is None else int(node_id)
    if node_id in self.node_id:
        raise ValueError(f'node {node_id} already exists')
    default_cs = int(self.cs_id[0]) if len(self.cs_id) else 1
    self.node_id = np.append(self.node_id, node_id)
    self.node_xyz = np.vstack([self.node_xyz,
                               np.asarray(xyz, dtype=np.float64)])
    self.node_color = np.append(self.node_color, int(color))
    self.node_def_cs = np.append(
        self.node_def_cs, default_cs if def_cs is None else int(def_cs))
    self.node_disp_cs = np.append(
        self.node_disp_cs, default_cs if disp_cs is None else int(disp_cs))
    return node_id
add_coordinate_system
add_coordinate_system(
    origin: ArrayLike = (0.0, 0.0, 0.0),
    rotation: ArrayLike | None = None,
    cs_id: int | None = None,
    name: str = "",
    cs_type: int = 0,
) -> int

Append a coordinate system. Returns its id.

Parameters:

Name Type Description Default
origin array_like

The system's origin.

(0, 0, 0)
rotation array_like

A 3x3 rotation matrix. Identity when omitted.

None
cs_id int

Its identifier. The next free one when omitted.

None
name str

What to call it.

''
cs_type int

0 cartesian, 1 cylindrical, 2 spherical.

0

Returns:

Type Description
int

The coordinate system's identifier.

Source code in src/visualdynamics/core/geometry.py
def add_coordinate_system(self, origin: ArrayLike = (0.0, 0.0, 0.0),
                          rotation: ArrayLike | None = None,
                          cs_id: int | None = None, name: str = '',
                          cs_type: int = 0) -> int:
    """Append a coordinate system. Returns its id.

    Parameters
    ----------
    origin : array_like, default (0, 0, 0)
        The system's origin.
    rotation : array_like, optional
        A 3x3 rotation matrix. Identity when omitted.
    cs_id : int, optional
        Its identifier. The next free one when omitted.
    name : str, optional
        What to call it.
    cs_type : int, default 0
        0 cartesian, 1 cylindrical, 2 spherical.

    Returns
    -------
    int
        The coordinate system's identifier.
    """
    cs_id = self._next_id(self.cs_id) if cs_id is None else int(cs_id)
    if cs_id in self.cs_id:
        raise ValueError(f'coordinate system {cs_id} already exists')
    rotation = np.eye(3) if rotation is None else np.asarray(rotation)
    matrix = np.vstack([rotation, np.asarray(origin, dtype=np.float64)])
    self.cs_id = np.append(self.cs_id, cs_id)
    self.cs_type = np.append(self.cs_type, int(cs_type))
    self.cs_name = [*self.cs_name, str(name)]
    self.cs_matrix = np.concatenate([self.cs_matrix, matrix[np.newaxis]])
    return cs_id
add_traceline
add_traceline(
    node_ids: Ids, color: int = 1, description: str = ""
) -> int

Append a traceline through the given nodes. Returns its index.

Parameters:

Name Type Description Default
node_ids int or sequence of int

The nodes the line passes through, in order.

required
color int

Its display colour index.

1
description str

A label for it.

''

Returns:

Type Description
int

The traceline's identifier.

Source code in src/visualdynamics/core/geometry.py
def add_traceline(self, node_ids: Ids, color: int = 1,
                  description: str = '') -> int:
    """Append a traceline through the given nodes. Returns its index.

    Parameters
    ----------
    node_ids : int or sequence of int
        The nodes the line passes through, in order.
    color : int, default 1
        Its display colour index.
    description : str, optional
        A label for it.

    Returns
    -------
    int
        The traceline's identifier.
    """
    nodes = np.asarray([int(n) for n in node_ids], dtype=np.int64)
    unknown = set(nodes.tolist()) - set(self.node_id.tolist())
    if unknown:
        raise ValueError(f'unknown nodes {sorted(unknown)}')
    if len(nodes) < 2:
        raise ValueError('a traceline needs at least two nodes')
    self.traceline_id = np.append(self.traceline_id,
                                  self._next_id(self.traceline_id))
    self.traceline_color = np.append(self.traceline_color, int(color))
    self.traceline_desc.append(str(description))
    self.traceline_conn.append(nodes)
    return len(self.traceline_conn) - 1
block_of
block_of(elem_id: int) -> str

The name of the block an element belongs to, or ''.

Parameters:

Name Type Description Default
elem_id int

Which element.

required

Returns:

Type Description
str

The name of the block it belongs to.

Source code in src/visualdynamics/core/geometry.py
def block_of(self, elem_id: int) -> str:
    """The name of the block an element belongs to, or ''.

    Parameters
    ----------
    elem_id : int
        Which element.

    Returns
    -------
    str
        The name of the block it belongs to.
    """
    row = int(np.flatnonzero(self.elem_id == int(elem_id))[0])
    block = int(self.elem_block[row])
    where = np.flatnonzero(self.block_id == block)
    return self.block_name[int(where[0])] if len(where) else ''
elements_in
elements_in(block: int | str) -> list[int]

The ids of the elements in a block, named or numbered.

Parameters:

Name Type Description Default
block int or str

A block, by identifier or by name.

required

Returns:

Type Description
list of int

The identifiers of the elements it holds.

Source code in src/visualdynamics/core/geometry.py
def elements_in(self, block: int | str) -> list[int]:
    """The ids of the elements in a block, named or numbered.

    Parameters
    ----------
    block : int or str
        A block, by identifier or by name.

    Returns
    -------
    list of int
        The identifiers of the elements it holds.
    """
    if isinstance(block, str):
        where = [i for i, name in enumerate(self.block_name)
                 if name == block]
        if not where:
            return []
        block = int(self.block_id[where[0]])
    return [int(self.elem_id[i])
            for i in np.flatnonzero(self.elem_block == int(block))]
add_block
add_block(
    name: str = "", block_id: int | None = None
) -> int

Declare an element block. Returns its id.

A block with nothing in it is legitimate — exodus files carry empty ones, and a block has to exist before an element can be put in it.

Parameters:

Name Type Description Default
name str

What to call the block.

''
block_id int

Its identifier. The next free one when omitted.

None

Returns:

Type Description
int

The block's identifier.

Source code in src/visualdynamics/core/geometry.py
def add_block(self, name: str = '', block_id: int | None = None) -> int:
    """Declare an element block. Returns its id.

    A block with nothing in it is legitimate — exodus files carry
    empty ones, and a block has to exist before an element can be put
    in it.

    Parameters
    ----------
    name : str, optional
        What to call the block.
    block_id : int, optional
        Its identifier. The next free one when omitted.

    Returns
    -------
    int
        The block's identifier.
    """
    block_id = (self._next_id(self.block_id) if block_id is None
                else int(block_id))
    if block_id in self.block_id:
        raise ValueError(f'block {block_id} already exists')
    self.block_id = np.append(self.block_id, block_id)
    self.block_name.append(str(name))
    return block_id
add_element
add_element(
    node_ids: Ids,
    elem_type: int | None = None,
    color: int = 1,
    block: int | None = None,
) -> int

Append an element. Type defaults to whatever fits the node count: 2 nodes a beam, 3 a triangle, 4 a quadrilateral. Returns its index.

Parameters:

Name Type Description Default
node_ids int or sequence of int

The nodes the element connects, in order.

required
elem_type int

The element type code. Inferred from the node count when omitted.

None
color int

Its display colour index.

1
block int

Which block it belongs to.

None

Returns:

Type Description
int

The element's identifier.

Source code in src/visualdynamics/core/geometry.py
def add_element(self, node_ids: Ids, elem_type: int | None = None,
                color: int = 1, block: int | None = None) -> int:
    """Append an element. Type defaults to whatever fits the node count:
    2 nodes a beam, 3 a triangle, 4 a quadrilateral. Returns its index.

    Parameters
    ----------
    node_ids : int or sequence of int
        The nodes the element connects, in order.
    elem_type : int, optional
        The element type code. Inferred from the node
        count when omitted.
    color : int, default 1
        Its display colour index.
    block : int, optional
        Which block it belongs to.

    Returns
    -------
    int
        The element's identifier.
    """
    nodes = np.asarray([int(n) for n in node_ids], dtype=np.int64)
    unknown = set(nodes.tolist()) - set(self.node_id.tolist())
    if unknown:
        raise ValueError(f'unknown nodes {sorted(unknown)}')
    if elem_type is None:
        elem_type = {2: 21, 3: 41, 4: 44}.get(len(nodes))
        if elem_type is None:
            raise ValueError(
                f'no default element type for {len(nodes)} nodes; '
                'give elem_type')
    if int(elem_type) not in ELEMENT_TYPES:
        raise ValueError(f'unknown element type {elem_type}')
    self.elem_id = np.append(self.elem_id, self._next_id(self.elem_id))
    self.elem_type = np.append(self.elem_type, int(elem_type))
    self.elem_color = np.append(self.elem_color, int(color))
    if block is None:
        block = int(self.block_id[0]) if len(self.block_id) else 1
    block = int(block)
    if block not in self.block_id.tolist():
        self.block_id = np.append(self.block_id, block)
        self.block_name.append('')
    self.elem_block = np.append(self.elem_block, block)
    self.elem_conn.append(nodes)
    return len(self.elem_conn) - 1
renumber_node
renumber_node(row: int, node_id: int) -> None

Give a node a new id, carrying its tracelines and elements over.

Connectivity names nodes by id, so a rename that left it alone would orphan every line and face touching the node.

Parameters:

Name Type Description Default
row int

Which node, by row.

required
node_id int

Its new identifier.

required

Returns:

Type Description
None
Source code in src/visualdynamics/core/geometry.py
def renumber_node(self, row: int, node_id: int) -> None:
    """Give a node a new id, carrying its tracelines and elements over.

    Connectivity names nodes by id, so a rename that left it alone
    would orphan every line and face touching the node.

    Parameters
    ----------
    row : int
        Which node, by row.
    node_id : int
        Its new identifier.

    Returns
    -------
    None
    """
    node_id = int(node_id)
    clash = np.flatnonzero(self.node_id == node_id)
    if len(clash) and clash[0] != row:
        raise ValueError(f'node {node_id} already exists')
    old = int(self.node_id[row])
    self.node_id[row] = node_id
    for conn in (*self.traceline_conn, *self.elem_conn):
        conn[conn == old] = node_id
renumber_block
renumber_block(row: int, block_id: int) -> None

Give a block a new id, carrying its elements over.

An element names its block by id, so a renumber that left them alone would put every element of the block in a block that is no longer there — which validate refuses, after the damage.

Parameters:

Name Type Description Default
row int

Which block, by row.

required
block_id int

Its new identifier.

required

Returns:

Type Description
None
Source code in src/visualdynamics/core/geometry.py
def renumber_block(self, row: int, block_id: int) -> None:
    """Give a block a new id, carrying its elements over.

    An element names its block by id, so a renumber that left them
    alone would put every element of the block in a block that is no
    longer there — which `validate` refuses, after the damage.

    Parameters
    ----------
    row : int
        Which block, by row.
    block_id : int
        Its new identifier.

    Returns
    -------
    None
    """
    block_id = int(block_id)
    clash = np.flatnonzero(self.block_id == block_id)
    if len(clash) and clash[0] != row:
        raise ValueError(f'block {block_id} already exists')
    old = int(self.block_id[row])
    self.block_id[row] = block_id
    self.elem_block[self.elem_block == old] = block_id
renumber_coordinate_system
renumber_coordinate_system(row: int, cs_id: int) -> None

Give a coordinate system a new id, repointing the nodes using it.

Parameters:

Name Type Description Default
row int

Which system, by row.

required
cs_id int

Its new identifier.

required

Returns:

Type Description
None
Source code in src/visualdynamics/core/geometry.py
def renumber_coordinate_system(self, row: int, cs_id: int) -> None:
    """Give a coordinate system a new id, repointing the nodes using it.

    Parameters
    ----------
    row : int
        Which system, by row.
    cs_id : int
        Its new identifier.

    Returns
    -------
    None
    """
    cs_id = int(cs_id)
    clash = np.flatnonzero(self.cs_id == cs_id)
    if len(clash) and clash[0] != row:
        raise ValueError(f'coordinate system {cs_id} already exists')
    old = int(self.cs_id[row])
    self.cs_id[row] = cs_id
    for name in ('node_def_cs', 'node_disp_cs'):
        references = getattr(self, name)
        references[references == old] = cs_id
delete_nodes
delete_nodes(node_ids: Ids) -> dict[str, int]

Remove nodes, and anything that referenced them.

A traceline or element naming a deleted node cannot survive, so it goes too. Returns what was removed, for reporting.

Parameters:

Name Type Description Default
node_ids int or sequence of int

The identifiers, one or many.

required

Returns:

Type Description
dict of str to int

How many of each kind were removed, including the dependants that went with them.

Source code in src/visualdynamics/core/geometry.py
def delete_nodes(self, node_ids: Ids) -> dict[str, int]:
    """Remove nodes, and anything that referenced them.

    A traceline or element naming a deleted node cannot survive, so it
    goes too. Returns what was removed, for reporting.

    Parameters
    ----------
    node_ids : int or sequence of int
        The identifiers, one or many.

    Returns
    -------
    dict of str to int
        How many of each kind were removed, including the
        dependants that went with them.
    """
    wanted = {int(node) for node in node_ids}
    keep = ~np.isin(self.node_id, list(wanted))
    removed_nodes = int((~keep).sum())
    if not removed_nodes:
        return {'nodes': 0, 'tracelines': 0, 'elements': 0}

    orphan_lines = [int(self.traceline_id[i])
                    for i, conn in enumerate(self.traceline_conn)
                    if wanted & {int(n) for n in conn}]
    orphan_elements = [int(self.elem_id[i])
                       for i, conn in enumerate(self.elem_conn)
                       if wanted & {int(n) for n in conn}]
    self.delete_tracelines(orphan_lines)
    self.delete_elements(orphan_elements)

    for name in ('node_id', 'node_def_cs', 'node_disp_cs', 'node_color'):
        setattr(self, name, getattr(self, name)[keep])
    self.node_xyz = self.node_xyz[keep]
    self.validate()
    return {'nodes': removed_nodes, 'tracelines': len(orphan_lines),
            'elements': len(orphan_elements)}
delete_coordinate_systems
delete_coordinate_systems(cs_ids: Ids) -> dict[str, int]

Remove coordinate systems, reassigning any node that used them.

The last coordinate system is never removed — nodes must reference something.

Parameters:

Name Type Description Default
cs_ids int or sequence of int

The identifiers, one or many.

required

Returns:

Type Description
dict of str to int

How many of each kind were removed, including the dependants that went with them.

Source code in src/visualdynamics/core/geometry.py
def delete_coordinate_systems(self, cs_ids: Ids) -> dict[str, int]:
    """Remove coordinate systems, reassigning any node that used them.

    The last coordinate system is never removed — nodes must reference
    something.

    Parameters
    ----------
    cs_ids : int or sequence of int
        The identifiers, one or many.

    Returns
    -------
    dict of str to int
        How many of each kind were removed, including the
        dependants that went with them.
    """
    wanted = {int(cs) for cs in cs_ids}
    keep = ~np.isin(self.cs_id, list(wanted))
    if keep.sum() == 0:
        raise ValueError('a geometry needs at least one coordinate system')
    removed = int((~keep).sum())
    if not removed:
        return {'coordinate_systems': 0, 'nodes_reassigned': 0}

    fallback = int(self.cs_id[keep][0])
    reassigned = 0
    for name in ('node_def_cs', 'node_disp_cs'):
        array = getattr(self, name)
        stale = np.isin(array, list(wanted))
        reassigned += int(stale.sum())
        array[stale] = fallback
    self.cs_id = self.cs_id[keep]
    self.cs_type = self.cs_type[keep]
    self.cs_matrix = self.cs_matrix[keep]
    self.cs_name = [name for name, k in zip(self.cs_name, keep) if k]
    return {'coordinate_systems': removed, 'nodes_reassigned': reassigned}
delete_tracelines
delete_tracelines(traceline_ids: Ids) -> dict[str, int]

Remove tracelines by id. Ids that are not there are ignored.

One id can name several polylines — a UNV trace line that lifts the pen arrives split into its drawn runs, all still that one trace line — and deleting it removes all of them, which is what deleting that trace line means.

Parameters:

Name Type Description Default
traceline_ids int or sequence of int

The identifiers, one or many.

required

Returns:

Type Description
dict of str to int

How many of each kind were removed, including the dependants that went with them.

Source code in src/visualdynamics/core/geometry.py
def delete_tracelines(self, traceline_ids: Ids) -> dict[str, int]:
    """Remove tracelines by id. Ids that are not there are ignored.

    One id can name several polylines — a UNV trace line that lifts
    the pen arrives split into its drawn runs, all still that one
    trace line — and deleting it removes all of them, which is what
    deleting that trace line means.

    Parameters
    ----------
    traceline_ids : int or sequence of int
        The identifiers, one or many.

    Returns
    -------
    dict of str to int
        How many of each kind were removed, including the
        dependants that went with them.
    """
    rows = self._rows_for(traceline_ids, self.traceline_id)
    for row in rows:
        del self.traceline_conn[row]
        del self.traceline_desc[row]
    keep = np.ones(len(self.traceline_id), dtype=bool)
    keep[rows] = False
    self.traceline_id = self.traceline_id[keep]
    self.traceline_color = self.traceline_color[keep]
    return {'tracelines': len(rows)}
delete_blocks
delete_blocks(block_ids: Ids) -> dict[str, int]

Remove blocks, moving anything in them into the first one left.

Deleting the grouping must not delete what was grouped — an element is a piece of the mesh and a block is a label on it — so the elements move rather than go, the way a node whose coordinate system is deleted is reassigned. The last block cannot go while any element names one; with no elements at all there is nothing to hold and the geometry may have none.

Parameters:

Name Type Description Default
block_ids int or sequence of int

The identifiers, one or many.

required

Returns:

Type Description
dict of str to int

How many of each kind were removed, including the dependants that went with them.

Source code in src/visualdynamics/core/geometry.py
def delete_blocks(self, block_ids: Ids) -> dict[str, int]:
    """Remove blocks, moving anything in them into the first one left.

    Deleting the grouping must not delete what was grouped — an
    element is a piece of the mesh and a block is a label on it — so
    the elements move rather than go, the way a node whose coordinate
    system is deleted is reassigned. The last block cannot go while
    any element names one; with no elements at all there is nothing
    to hold and the geometry may have none.

    Parameters
    ----------
    block_ids : int or sequence of int
        The identifiers, one or many.

    Returns
    -------
    dict of str to int
        How many of each kind were removed, including the
        dependants that went with them.
    """
    wanted = {int(block) for block in block_ids}
    keep = ~np.isin(self.block_id, list(wanted))
    removed = int((~keep).sum())
    if not removed:
        return {'blocks': 0, 'elements_reassigned': 0}
    if keep.sum() == 0 and len(self.elem_block):
        raise ValueError(
            'a geometry with elements needs a block to put them in')
    reassigned = 0
    if keep.sum():
        fallback = int(self.block_id[keep][0])
        stale = np.isin(self.elem_block, list(wanted))
        reassigned = int(stale.sum())
        self.elem_block[stale] = fallback
    self.block_name = [name for name, k in zip(self.block_name, keep) if k]
    self.block_id = self.block_id[keep]
    return {'blocks': removed, 'elements_reassigned': reassigned}
delete_elements
delete_elements(elem_ids: Ids) -> dict[str, int]

Remove elements by id. Ids that are not there are ignored.

Parameters:

Name Type Description Default
elem_ids int or sequence of int

The identifiers, one or many.

required

Returns:

Type Description
dict of str to int

How many of each kind were removed, including the dependants that went with them.

Source code in src/visualdynamics/core/geometry.py
def delete_elements(self, elem_ids: Ids) -> dict[str, int]:
    """Remove elements by id. Ids that are not there are ignored.

    Parameters
    ----------
    elem_ids : int or sequence of int
        The identifiers, one or many.

    Returns
    -------
    dict of str to int
        How many of each kind were removed, including the
        dependants that went with them.
    """
    rows = self._rows_for(elem_ids, self.elem_id)
    for row in rows:
        del self.elem_conn[row]
    keep = np.ones(len(self.elem_id), dtype=bool)
    keep[rows] = False
    self.elem_id = self.elem_id[keep]
    self.elem_type = self.elem_type[keep]
    self.elem_color = self.elem_color[keep]
    self.elem_block = self.elem_block[keep]
    # A block whose last element has gone is still a block: exodus
    # files carry empty ones, and forgetting the name would lose on a
    # round trip exactly what blocks were added to keep.
    return {'elements': len(rows)}
save
save(path: str | PathLike) -> None

Write the geometry to a file of its own.

Parameters:

Name Type Description Default
path str or PathLike

Where to write it.

required

Returns:

Type Description
None
Source code in src/visualdynamics/core/geometry.py
def save(self, path: str | os.PathLike) -> None:
    """Write the geometry to a file of its own.

    Parameters
    ----------
    path : str or os.PathLike
        Where to write it.

    Returns
    -------
    None
    """
    from ..io import native
    native.save(self, path)
plot
plot(
    unit_system: UnitSystem | None = None, **kwargs: Any
) -> Any

Draw the geometry: nodes, elements and tracelines.

Parameters:

Name Type Description Default
unit_system UnitSystem

Units to draw in.

None
**kwargs Any

Passed through to the scene.

{}

Returns:

Type Description
object

The plot widget or plotter.

Source code in src/visualdynamics/core/geometry.py
def plot(self, unit_system: UnitSystem | None = None,
         **kwargs: Any) -> Any:
    """Draw the geometry: nodes, elements and tracelines.

    Parameters
    ----------
    unit_system : UnitSystem, optional
        Units to draw in.
    **kwargs
        Passed through to the scene.

    Returns
    -------
    object
        The plot widget or plotter.
    """
    from ..viz.geometry import plot_geometry
    return plot_geometry(self, unit_system=unit_system, **kwargs)
plot_dofs
plot_dofs(
    source: Any,
    quantity: str,
    unit_system: UnitSystem | None = None,
    **kwargs: Any,
) -> Any

This geometry with labelled arrows at every DOF source measures as quantity — the GUI's DOF arrows.

Parameters:

Name Type Description Default
source DataArray

The object whose degrees of freedom are drawn.

required
quantity str

Which quantity's DOFs to show, such as 'acceleration'.

required
unit_system UnitSystem

Units to draw in.

None
**kwargs Any

Passed through to the scene.

{}

Returns:

Type Description
object

The plotter the scene is in.

Source code in src/visualdynamics/core/geometry.py
def plot_dofs(self, source: Any, quantity: str,
              unit_system: UnitSystem | None = None,
              **kwargs: Any) -> Any:
    """This geometry with labelled arrows at every DOF `source`
    measures as `quantity` — the GUI's DOF arrows.

    Parameters
    ----------
    source : DataArray
        The object whose degrees of freedom are drawn.
    quantity : str
        Which quantity's DOFs to show, such as 'acceleration'.
    unit_system : UnitSystem, optional
        Units to draw in.
    **kwargs
        Passed through to the scene.

    Returns
    -------
    object
        The plotter the scene is in.
    """
    from ..viz.geometry import plot_dofs
    return plot_dofs(self, source, quantity, unit_system=unit_system,
                     **kwargs)

ShapeSet

ShapeSet(
    frequency: ArrayLike,
    damping: ArrayLike,
    coordinate: Sequence[str],
    shape_matrix: ArrayLike,
    modal_mass: ArrayLike | None = None,
    comment: str | Sequence[str] | None = None,
    mass_unit: str | None = None,
    description: Sequence[str] | None = None,
    unscaled: bool = False,
    modal_damping: ArrayLike | None = None,
)

Mode shapes over a shared set of DOFs.

shape_matrix is (modes, dofs). coordinate lists the DOF strings the columns correspond to ('101X+').

A fitted set is also the record of the fit: reopening one in the app (Edit Fit) reconstructs the session that produced it, which is why the description and the scaling flag ride along with the numbers.

Attributes: frequency: Hz per mode. A rigid-body mode is exactly 0.0 — the FRF synthesis cancels its 0/0 by testing for that, so 'very small' is not the same thing. damping: Fraction of critical per mode, so 2% is 0.02. coordinate: The DOF string of each column of shape_matrix. shape_matrix: (modes, dofs). Complex for a complex mode; the overlay and MAC machinery handles either. modal_mass: Per mode. 1.0 throughout for a mass-normalized set, which is what an eigensolution here produces. Complex when an imported source carried complex modal mass — kept as measured, never squeezed real. modal_damping: Complex modal damping per mode where a source carried one (I-DEAS ADFs do), or None. Distinct from damping, the viscous fraction of critical: this is the complex-mode estimate as the identifying tool reported it. mass_unit: What modal_mass is in, or None when undeclared. description: Free text per mode — what the shape is, filled in while reading the table ('first torsion'). comment: One line about the set as a whole. unscaled: True when the fit had no drive point to pin the mass-normalized scale. Shapes and MACs are unaffected; modal masses are then a convention rather than physics, and comparisons refuse to read a scale factor out of them.

Methods:

Name Description
auto_mac

MAC of every mode against every other; the diagonal is 1.

covers

Does the shape set have a coefficient at this DOF?

synthesize_frf

FRFs from the modal model, one row per DOF pair.

delete_modes

Remove the given modes in place; the last one is refused.

define_units

Declare the mass unit the shapes were normalized against.

undefine_units

Take the declaration back, restoring the file's raw coefficients.

display_shapes

Coefficients in the display system's 1/sqrt(mass); undefined pass

unit_label

'1/√kg' for the stored unit, or the display system's.

mode_label

'Mode 3 — 12.4 Hz, 2.0% damping'.

save

Write the shape set to a file of its own.

plot_mac

The MAC grid: this set against itself, or against other.

animate

This mode moving on a geometry, as the GUI animates it.

plot

The set's own reading: its auto-MAC, or one mode animated

Attributes:

Name Type Description
num_shapes int

How many mode shapes the set holds.

num_dofs int

How many degrees of freedom each shape covers.

is_complex bool

Whether these are complex modes. Real normal modes move

units_defined bool

Whether the shapes carry a mass unit, without which a

Source code in src/visualdynamics/core/shapes.py
def __init__(self, frequency: ArrayLike, damping: ArrayLike,
             coordinate: Sequence[str], shape_matrix: ArrayLike,
             modal_mass: ArrayLike | None = None,
             comment: str | Sequence[str] | None = None,
             mass_unit: str | None = None,
             description: Sequence[str] | None = None,
             unscaled: bool = False,
             modal_damping: ArrayLike | None = None) -> None:
    # True when the fit had no drive point to pin the
    # mass-normalized scale: shapes and MACs are fine, modal
    # masses are a convention, not physics
    self.unscaled: bool = bool(unscaled)
    self.frequency: np.ndarray = _frequency(frequency)
    self.damping: np.ndarray = _damping(damping)
    matrix = np.atleast_2d(np.asarray(shape_matrix))
    self.shape_matrix: np.ndarray = matrix
    n = matrix.shape[0]
    if len(self.frequency) != n or len(self.damping) != n:
        raise ValueError(
            f'shape_matrix has {n} modes but frequency/damping have '
            f'{len(self.frequency)}/{len(self.damping)}')
    self.coordinate: list[str] = _dofs(coordinate, 'coordinate')
    if matrix.shape[1] != len(self.coordinate):
        raise ValueError(
            f'shape_matrix has {matrix.shape[1]} columns but '
            f'{len(self.coordinate)} coordinates')
    # complex where a source says so — an imported complex-mode set
    # carries complex modal parameters as measured, and squeezing
    # them real would silently discard half the estimate. The fit
    # here never produces them; imports may.
    mass = (np.ones(n) if modal_mass is None
            else np.atleast_1d(np.asarray(modal_mass)))
    if np.iscomplexobj(mass) and not np.any(mass.imag):
        mass = mass.real
    self.modal_mass: np.ndarray = mass.astype(
        np.complex128 if np.iscomplexobj(mass) else np.float64)
    #: complex modal damping per mode where a source carried one
    #: (I-DEAS ADFs do); None for sets that never had it
    self.modal_damping: np.ndarray | None = (
        None if modal_damping is None
        else np.atleast_1d(np.asarray(modal_damping)))
    self.comment: list[str]
    self.description: list[str]
    if comment is None:
        self.comment = [''] * n
    elif isinstance(comment, str):
        self.comment = [comment] * n
    else:
        self.comment = [str(c) for c in comment]
        if len(self.comment) != n:
            raise ValueError(f'comment has {len(self.comment)} entries, '
                             f'expected {n}')
    # free text the user can write against each mode
    if description is None:
        self.description = [''] * n
    else:
        self.description = [str(d) for d in description]
        if len(self.description) != n:
            raise ValueError(
                f'description has {len(self.description)} entries, '
                f'expected {n}')
    self.mass_unit: str | None = mass_unit or None
    if self.mass_unit is not None:
        si_factor(self.mass_unit, 'mass')  # validates
    self._sort_by_frequency()
Attributes
num_shapes property
num_shapes: int

How many mode shapes the set holds.

num_dofs property
num_dofs: int

How many degrees of freedom each shape covers.

is_complex property
is_complex: bool

Whether these are complex modes. Real normal modes move every DOF in phase; complex ones do not, which is what a damped or non-proportionally damped structure produces.

units_defined property
units_defined: bool

Whether the shapes carry a mass unit, without which a modal mass is a number with no scale behind it.

Methods:
auto_mac
auto_mac() -> ndarray

MAC of every mode against every other; the diagonal is 1.

Source code in src/visualdynamics/core/shapes.py
def auto_mac(self) -> np.ndarray:
    """MAC of every mode against every other; the diagonal is 1."""
    return mac_matrix(self.shape_matrix)
covers
covers(dof: str) -> bool

Does the shape set have a coefficient at this DOF?

Parameters:

Name Type Description Default
dof str

A degree of freedom, such as '101Z+'.

required

Returns:

Type Description
bool

Whether the shapes include it.

Source code in src/visualdynamics/core/shapes.py
def covers(self, dof: str) -> bool:
    """Does the shape set have a coefficient at this DOF?

    Parameters
    ----------
    dof : str
        A degree of freedom, such as '101Z+'.

    Returns
    -------
    bool
        Whether the shapes include it.
    """
    return _split_sign(str(dof))[0] in self._dof_lookup()
synthesize_frf
synthesize_frf(
    frequencies: ArrayLike,
    response_dof: Sequence[str],
    reference_dof: Sequence[str],
    modes: Sequence[int] | None = None,
    power: int = 0,
) -> ndarray

FRFs from the modal model, one row per DOF pair.

H_jk(f) = sum_r (iw)^power phi_jr phi_kr / (m_r (w_r^2 - w^2 + 2i z_r w_r w)) with w = 2pif — the residue form for mass-normalized shapes, with modal_mass carrying any other scaling. power picks the response quantity: 0 displacement per force, 1 velocity, 2 acceleration. It applies inside the sum because a rigid-body mode's denominator is exactly -w^2: at w = 0 its accelerance cancels to the finite residue, where an after-the-fact multiply is 0/0 and a screenful of warnings. Its displacement and velocity there are genuinely unbounded and come back as nan.

modes restricts the sum; a truncated synthesis beside the measurement is what shows which modes the measurement actually contains. Raises ValueError for a DOF the shapes do not cover; covers says so in advance.

Parameters:

Name Type Description Default
frequencies array_like

The lines to synthesise at, in Hz.

required
response_dof sequence of str

The response degrees of freedom.

required
reference_dof sequence of str

The drive degrees of freedom.

required
modes sequence of int

Which modes to include. All of them when omitted.

None
power int

0 receptance, 1 mobility, 2 accelerance.

0

Returns:

Type Description
ndarray

The synthesised FRFs, one row per response and drive pair.

Source code in src/visualdynamics/core/shapes.py
def synthesize_frf(self, frequencies: ArrayLike,
                   response_dof: Sequence[str],
                   reference_dof: Sequence[str],
                   modes: Sequence[int] | None = None,
                   power: int = 0) -> np.ndarray:
    """FRFs from the modal model, one row per DOF pair.

    H_jk(f) = sum_r (iw)^power phi_jr phi_kr
                    / (m_r (w_r^2 - w^2 + 2i z_r w_r w))
    with w = 2*pi*f — the residue form for mass-normalized shapes, with
    `modal_mass` carrying any other scaling. `power` picks the response
    quantity: 0 displacement per force, 1 velocity, 2 acceleration. It
    applies inside the sum because a rigid-body mode's denominator is
    exactly -w^2: at w = 0 its accelerance cancels to the finite
    residue, where an after-the-fact multiply is 0/0 and a screenful
    of warnings. Its displacement and velocity there are genuinely
    unbounded and come back as nan.

    `modes` restricts the sum; a truncated synthesis beside the
    measurement is what shows which modes the measurement actually
    contains. Raises ValueError for a DOF the shapes do not cover;
    `covers` says so in advance.

    Parameters
    ----------
    frequencies : array_like
        The lines to synthesise at, in Hz.
    response_dof : sequence of str
        The response degrees of freedom.
    reference_dof : sequence of str
        The drive degrees of freedom.
    modes : sequence of int, optional
        Which modes to include. All of them when omitted.
    power : int, default 0
        0 receptance, 1 mobility, 2 accelerance.

    Returns
    -------
    numpy.ndarray
        The synthesised FRFs, one row per response and drive pair.
    """
    frequencies = np.asarray(frequencies, dtype=np.float64)
    picked = (np.arange(self.num_shapes) if modes is None
              else np.asarray(sorted({int(m) for m in modes})))
    lookup = self._dof_lookup()

    def shape_at(dof: str) -> np.ndarray:
        base, sign = _split_sign(str(dof))
        if base not in lookup:
            raise ValueError(f'shapes have no coefficient at DOF {dof!r}')
        column, stored_sign = lookup[base]
        return self.shape_matrix[picked, column] * (sign * stored_sign)

    omega = 2.0 * np.pi * frequencies
    omega_r = 2.0 * np.pi * self.frequency[picked]
    denominator = ((omega_r ** 2)[:, None] - (omega ** 2)[None, :]
                   + 2j * (self.damping[picked] * omega_r)[:, None]
                   * omega[None, :])
    numerator = (1j * omega) ** power
    # an undamped mode's own resonance line divides by zero too; the
    # infinity is the right answer there, not worth a warning
    with np.errstate(divide='ignore', invalid='ignore'):
        ratio = numerator[None, :] / denominator
    rigid, still = omega_r == 0.0, omega == 0.0
    if rigid.any() and still.any():
        ratio[np.ix_(rigid, still)] = 1.0 if power == 2 else np.nan
    out = np.empty((len(response_dof), len(frequencies)),
                   dtype=np.complex128)
    for row, (response, reference) in enumerate(zip(response_dof,
                                                    reference_dof)):
        residues = (shape_at(response) * shape_at(reference)
                    / self.modal_mass[picked])
        out[row] = residues @ ratio
    return out
delete_modes
delete_modes(indices: Sequence[int]) -> None

Remove the given modes in place; the last one is refused.

Parameters:

Name Type Description Default
indices sequence of int

Which modes to remove.

required

Returns:

Type Description
None
Source code in src/visualdynamics/core/shapes.py
def delete_modes(self, indices: Sequence[int]) -> None:
    """Remove the given modes in place; the last one is refused.

    Parameters
    ----------
    indices : sequence of int
        Which modes to remove.

    Returns
    -------
    None
    """
    doomed = {int(i) for i in indices}
    bad = [i for i in doomed if not 0 <= i < self.num_shapes]
    if bad:
        raise IndexError(f'no such modes: {sorted(bad)}')
    keep = [i for i in range(self.num_shapes) if i not in doomed]
    if not keep:
        raise ValueError('cannot delete every mode; delete the '
                         'object instead')
    self.frequency = self.frequency[keep]
    self.damping = self.damping[keep]
    self.shape_matrix = self.shape_matrix[keep]
    self.modal_mass = self.modal_mass[keep]
    self.comment = [self.comment[i] for i in keep]
    self.description = [self.description[i] for i in keep]
define_units
define_units(mass_unit: str) -> ShapeSet

Declare the mass unit the shapes were normalized against.

Re-declaring reinterprets the file's values rather than scaling twice, so a wrong guess can be corrected.

Parameters:

Name Type Description Default
mass_unit str

The unit modal mass is in.

required

Returns:

Type Description
ShapeSet

Self, converted to SI in place.

Source code in src/visualdynamics/core/shapes.py
def define_units(self, mass_unit: str) -> ShapeSet:
    """Declare the mass unit the shapes were normalized against.

    Re-declaring reinterprets the file's values rather than scaling
    twice, so a wrong guess can be corrected.

    Parameters
    ----------
    mass_unit : str
        The unit modal mass is in.

    Returns
    -------
    ShapeSet
        Self, converted to SI in place.
    """
    scale = self._scale_to_si(mass_unit)
    raw = (self.shape_matrix if self.mass_unit is None
           else self.shape_matrix / self._scale_to_si(self.mass_unit))
    self.shape_matrix = raw * scale
    self.mass_unit = mass_unit
    return self
undefine_units
undefine_units() -> ShapeSet

Take the declaration back, restoring the file's raw coefficients.

Source code in src/visualdynamics/core/shapes.py
def undefine_units(self) -> ShapeSet:
    """Take the declaration back, restoring the file's raw coefficients."""
    if self.mass_unit is None:
        return self
    self.shape_matrix = self.shape_matrix / self._scale_to_si(self.mass_unit)
    self.mass_unit = None
    return self
display_shapes
display_shapes(unit_system: UnitSystem) -> ndarray

Coefficients in the display system's 1/sqrt(mass); undefined pass through unchanged.

Parameters:

Name Type Description Default
unit_system UnitSystem

The units to present in.

required

Returns:

Type Description
ndarray

The shape matrix in display units.

Source code in src/visualdynamics/core/shapes.py
def display_shapes(self, unit_system: UnitSystem) -> np.ndarray:
    """Coefficients in the display system's 1/sqrt(mass); undefined pass
    through unchanged.

    Parameters
    ----------
    unit_system : UnitSystem
        The units to present in.

    Returns
    -------
    numpy.ndarray
        The shape matrix in display units.
    """
    if not self.units_defined:
        return self.shape_matrix
    return self.shape_matrix * math.sqrt(
        si_factor(unit_system.unit('mass'), 'mass'))
unit_label
unit_label(unit_system: UnitSystem | None = None) -> str

'1/√kg' for the stored unit, or the display system's.

Parameters:

Name Type Description Default
unit_system UnitSystem

Units to label in.

None

Returns:

Type Description
str

How the shapes' own unit reads.

Source code in src/visualdynamics/core/shapes.py
def unit_label(self, unit_system: UnitSystem | None = None) -> str:
    """'1/√kg' for the stored unit, or the display system's.

    Parameters
    ----------
    unit_system : UnitSystem, optional
        Units to label in.

    Returns
    -------
    str
        How the shapes' own unit reads.
    """
    if not self.units_defined:
        return ''
    unit = (self.mass_unit if unit_system is None
            else unit_system.unit('mass'))
    return f'1/√{unit}'
mode_label
mode_label(i: int) -> str

'Mode 3 — 12.4 Hz, 2.0% damping'.

Parameters:

Name Type Description Default
i int

Which mode.

required

Returns:

Type Description
str

A short label: its frequency, and its damping when known.

Source code in src/visualdynamics/core/shapes.py
def mode_label(self, i: int) -> str:
    """'Mode 3 — 12.4 Hz, 2.0% damping'.

    Parameters
    ----------
    i : int
        Which mode.

    Returns
    -------
    str
        A short label: its frequency, and its damping when known.
    """
    label = f'Mode {i + 1}{self.frequency[i]:.4g} Hz'
    if self.damping[i]:
        label += f', {self.damping[i] * 100:.3g}% damping'
    return label
save
save(path: str | PathLike) -> None

Write the shape set to a file of its own.

Parameters:

Name Type Description Default
path str or PathLike

Where to write it.

required

Returns:

Type Description
None
Source code in src/visualdynamics/core/shapes.py
def save(self, path: str | os.PathLike) -> None:
    """Write the shape set to a file of its own.

    Parameters
    ----------
    path : str or os.PathLike
        Where to write it.

    Returns
    -------
    None
    """
    from ..io import native
    native.save(self, path)
plot_mac
plot_mac(
    other: ShapeSet | None = None, **kwargs: Any
) -> Any

The MAC grid: this set against itself, or against other.

Parameters:

Name Type Description Default
other ShapeSet

The set to compare against. This set against itself when omitted, which is how repeated modes show up.

None
**kwargs Any

Passed through to the plotting layer.

{}

Returns:

Type Description
object

The plot widget.

Source code in src/visualdynamics/core/shapes.py
def plot_mac(self, other: ShapeSet | None = None, **kwargs: Any) -> Any:
    """The MAC grid: this set against itself, or against `other`.

    Parameters
    ----------
    other : ShapeSet, optional
        The set to compare against. This set against itself when
        omitted, which is how repeated modes show up.
    **kwargs
        Passed through to the plotting layer.

    Returns
    -------
    object
        The plot widget.
    """
    from ..plot import plot_mac
    return plot_mac(self, other, **kwargs)
animate
animate(
    geometry: Geometry, mode: int = 0, **kwargs: Any
) -> Any

This mode moving on a geometry, as the GUI animates it.

Parameters:

Name Type Description Default
geometry Geometry

The geometry to move.

required
mode int

Which mode, by index.

0
**kwargs Any

Passed through to the scene.

{}

Returns:

Type Description
object

The plot widget or plotter.

Source code in src/visualdynamics/core/shapes.py
def animate(self, geometry: Geometry, mode: int = 0,
            **kwargs: Any) -> Any:
    """This mode moving on a geometry, as the GUI animates it.

    Parameters
    ----------
    geometry : Geometry
        The geometry to move.
    mode : int, default 0
        Which mode, by index.
    **kwargs
        Passed through to the scene.

    Returns
    -------
    object
        The plot widget or plotter.
    """
    from ..viz.animate import animate_shape
    return animate_shape(geometry, self, mode, **kwargs)
plot
plot(
    geometry: Geometry | None = None,
    mode: int = 0,
    **kwargs: Any,
) -> Any

The set's own reading: its auto-MAC, or one mode animated when a geometry says where to put it.

Parameters:

Name Type Description Default
geometry Geometry

The geometry to draw on.

None
mode int

Which mode.

0
**kwargs Any

Passed through to the scene.

{}

Returns:

Type Description
object

The plot widget or plotter.

Source code in src/visualdynamics/core/shapes.py
def plot(self, geometry: Geometry | None = None, mode: int = 0,
         **kwargs: Any) -> Any:
    """The set's own reading: its auto-MAC, or one mode animated
    when a geometry says where to put it.

    Parameters
    ----------
    geometry : Geometry, optional
        The geometry to draw on.
    mode : int, default 0
        Which mode.
    **kwargs
        Passed through to the scene.

    Returns
    -------
    object
        The plot widget or plotter.
    """
    if geometry is None:
        return self.plot_mac(**kwargs)
    return self.animate(geometry, mode, **kwargs)