Skip to content

visualdynamics

visualdynamics

visualdynamics: units-aware structural dynamics analysis toolset.

Classes:

Name Description
Issue

Why one object does not fit the active geometry.

Report

Compatibility of every object in a test.

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.

Geometry

Nodes, coordinate systems, tracelines, elements and blocks.

Psd

Power spectral density: the declared unit is the engineering unit

ShapeSet

Mode shapes over a shared set of DOFs.

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.

Project

Every object in one test, by name, plus the structure around them.

UnitsRequired

Raised when an operation needs units that have not been defined.

UnitSystem

A named mapping of dimension -> display unit.

Functions:

Name Description
check_compatibility

Check every object in a test against the geometry it answers to.

frequency_axis

Read or set how frequency axes are drawn: 'log', 'linear', or

export_file

Write obj to a foreign format, chosen by name or by suffix.

from_sep005

SEP 005 timeseries into TimeHistory objects.

import_file

Import a foreign file, returning the visualdynamics object it contains.

importers

Every format visualdynamics can read, in the order they are tried.

load

Load a .vdyn file: the object it contains, or a whole test.

register_importer

Teach visualdynamics a format. Registered ones are tried in order, so a

save

Save a visualdynamics object to a .vdyn (HDF5) file.

random_vibration_report

A Rattlesnake random vibration run in, an HTML report out.

random_vibration_run

A Rattlesnake random vibration run, worked up into a project.

convert

values from one unit to another, through SI.

si_factor

Multiplier converting values in unit to SI.

launch_gui

Launch the app, optionally importing files on the way in.

Classes

Issue dataclass

Issue(
    name: str,
    kind: str,
    message: str,
    missing_dofs: list = list(),
    sub_items: list = list(),
)

Why one object does not fit the active geometry.

Report dataclass

Report(
    geometry_name: str | None = None, issues: dict = dict()
)

Compatibility of every object in a test.

Methods:

Name Description
is_compatible

Whether one object fits the geometry it is linked to.

issue_for

What is wrong with one object, or None if nothing is.

sub_item_flagged

Whether one record within an object is incompatible.

Attributes:

Name Type Description
incompatible_names list[str]

The objects that do not fit the geometry, by name — what

Attributes
incompatible_names property
incompatible_names: list[str]

The objects that do not fit the geometry, by name — what the tree marks in red and a link refuses over.

Methods:
is_compatible
is_compatible(name: str) -> bool

Whether one object fits the geometry it is linked to.

Parameters:

Name Type Description Default
name str

The object to ask about.

required

Returns:

Type Description
bool

Whether it fits the geometry it is linked to.

Source code in src/visualdynamics/compatibility.py
def is_compatible(self, name: str) -> bool:
    """Whether one object fits the geometry it is linked to.

    Parameters
    ----------
    name : str
        The object to ask about.

    Returns
    -------
    bool
        Whether it fits the geometry it is linked to.
    """
    return name not in self.issues
issue_for
issue_for(name: str) -> Issue | None

What is wrong with one object, or None if nothing is.

Parameters:

Name Type Description Default
name str

The object to ask about.

required

Returns:

Type Description
Issue or None

What is wrong with it, or None if nothing is.

Source code in src/visualdynamics/compatibility.py
def issue_for(self, name: str) -> Issue | None:
    """What is wrong with one object, or None if nothing is.

    Parameters
    ----------
    name : str
        The object to ask about.

    Returns
    -------
    Issue or None
        What is wrong with it, or None if nothing is.
    """
    return self.issues.get(name)
sub_item_flagged
sub_item_flagged(name: str, index: int) -> bool

Whether one record within an object is incompatible.

Parameters:

Name Type Description Default
name str

The object to ask about.

required
index int

Which record within it.

required

Returns:

Type Description
bool

Whether that record is one of the incompatible ones.

Source code in src/visualdynamics/compatibility.py
def sub_item_flagged(self, name: str, index: int) -> bool:
    """Whether one record within an object is incompatible.

    Parameters
    ----------
    name : str
        The object to ask about.
    index : int
        Which record within it.

    Returns
    -------
    bool
        Whether that record is one of the incompatible ones.
    """
    issue = self.issues.get(name)
    return bool(issue) and index in issue.sub_items

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)

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)

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)

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)

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

Project

Project(
    name: str = "Project",
    objects: dict[str, Any] | None = None,
    active_geometry: str | None = None,
    project_type: str | None = None,
    links: Iterable[LinkGroup] | None = None,
    provenance: dict[str, dict[str, Any]] | None = None,
)

Bases: dict

Every object in one test, by name, plus the structure around them.

A Project is the name-to-object mapping, so project['FRF'], list(project) and project.items() read the way the tree reads.

It is also what the desktop app holds: the window's objects, links, project_type and active_geometry are properties over one of these, and its buttons call the verbs below. A project built by clicking and one built by calling are the same object, and open in each other.

Objects are usually reached by type rather than by name — project.geometry, project.basis.frf, project.other.shapes. The singular gives the one there is and says so when several qualify; the plural is always a list.

Attributes: links: The groups objects have been declared to belong to, as {'members': [...], 'role': 'Basis' | None}. Association is explicit here, never inferred from names. project_type: What kind of test this is — 'Modal Test', 'Random Vibration', 'Shock', 'Transient' — which decides the report template and the skeleton of slots the tree shows. active_geometry: The name of the geometry data is drawn on when nothing says otherwise. name: What the project is called, which is what a saved .vdyn and a rendered report are titled.

Methods:

Name Description
grouped_names

[(group or None, [names])] in the order the tree shows them:

ordered_names

Every name, flat, in the order the tree shows them.

add

Add an object under a unique name; returns the name used.

duplicate

Copies of objects, added beside them (Copy, then Paste, in

import_file

Import a file into this project; returns the names added.

remove

Delete objects, pruning them out of every link group.

rename

Rename an object; every reference to it follows.

rename_dof

Correct a channel's coordinate on an object and on everything

link

Declare objects part of one group, merging any they are in.

unlink

Take objects out of their groups; a group of one dissolves.

relink

Move one object into the group holding target.

group_of

The members linked with name, or None.

role_of

'Basis', or None for an unroled or unlinked object.

placed

{role: [members]} — which objects are in each named group.

role_group

The link group carrying a role, or None.

place

Put one object into the named group, making it if need be.

set_role

Name what a group is. The Basis is unique: taking the role

set_basis

Declare the Basis of comparisons: the group whose DOFs

geometry_for

(name, geometry) the object answers to: its group's, else

absorb_links

Take on the link groups of a project being imported.

verbs

The processing verbs that apply to an object, each with its

selection_verbs

The processing verbs a selection can act on, each with its

compute_spectra

Spectra from a time history's averages (the averaging

compute_psds

PSDs from a time history's averages (Compute PSDs).

compute_octave

A spectrum integrated onto proportional bands (Compute

compute_frfs

Frequency response functions from a time history (Compute

compute_multiple_coherence

Multiple coherence from a time history (Compute Multiple

compute_srs

Shock response spectra from a time history's shocks (Compute

detect_shocks

Find the events in a time history and mark them on it (the

filter_data

A time history through its low-pass (the filter view's

truncate_data

A time history cut to its truncation's span (the

integrate

One integration of a time history (Integrate): acceleration

differentiate

One differentiation of a time history (Differentiate):

compute_cpsds

The full cross-spectral matrix from a time history's averages

transform

Physical responses through a shape set to modal responses

expand

Modal responses back through a shape set to physical

author_specification

A specification written from a sheet (the Specification

generate_rigid_body_modes

The six rigid-body mode shapes of a geometry (Generate Rigid

fit_modes

Fit a modal model to an FRF set (the fitting screen).

project_onto_basis

A shape set sampled at the Basis set's DOFs (Project onto

match_modes

Commit matched mode pairs (the comparison screen's +).

comparison_mac

The MAC between two shape sets as the comparison screen

plot_mac

The MAC picture the comparison screen draws: first

merge

Combine compatible objects into one (Merge).

export

Write an object to a foreign format, chosen by suffix —

generate_report

Build a report from a starter template, bound symbolically

export_report

Write a report as one self-contained HTML file (Export).

table

(headers, rows) for an object that reads as a table.

plot

Plot an object the way the GUI plots it: data as curves, a

animate

A mode shape — or a complex spectrum's operating deflection —

name_of

The name an object goes by here; a name passes through.

extract_sine

Each specification tone's level, read out of a recording

stale

{derived name: why} for everything whose source's settings

refresh

Recompute a derived object in place, under its own name.

refresh_stale

Refresh everything stale, sources before their dependents,

save

Write the whole project to one .vdyn file.

open

Read a .vdyn project back.

journal_as

Record a stretch of front-end work as one replaying line.

record_setting

A settings write, journalled the way a script would make it.

record_call

A method call on an object, journalled as a script makes it.

session_script

This sitting's acts as a runnable Python script.

Attributes:

Name Type Description
basis Selection

The Basis group, reached by type: project.basis.frf.

groups list[Selection]

Every link group, the Basis first, each reached by type.

other Selection

The one link group that is not the Basis — the model side of

names list[str]

Every object's name, in the order the tree shows them.

Source code in src/visualdynamics/project.py
def __init__(self, name: str = 'Project',
             objects: dict[str, Any] | None = None,
             active_geometry: str | None = None,
             project_type: str | None = None,
             links: Iterable[LinkGroup] | None = None,
             provenance: dict[str, dict[str, Any]] | None = None
             ) -> None:
    super().__init__(objects or {})
    self.name: str = str(name)
    self.active_geometry: str | None = active_geometry
    self.project_type: str | None = project_type
    # [{'members': [...], 'role': 'Basis' | None}] — a loaded file
    # may still carry a 'side' key from before the two vocabularies
    # merged, or a 'FEM' role from before that role was retired;
    # both read as the Basis where they meant it and as nothing
    # otherwise, and are never stored again
    self.links: list[dict[str, Any]] = [
        {'members': list(group['members']),
         'role': 'Basis' if (group.get('role') == 'Basis'
                             or group.get('side') == 'experimental'
                             and not group.get('role')) else None}
        for group in (links or [])]
    #: how each derived object was computed — {name: {'verb',
    #: 'source', 'params', 'state'}} with 'state' the fingerprint
    #: of the analysis settings read at compute time. Staleness is
    #: the recorded state disagreeing with the source's current
    #: one; a mismatch is exactly the recompute the refresh badge
    #: offers (Brandon, 2026-08-23: explicit, never automatic —
    #: a report must not rewrite itself).
    self.provenance: dict[str, dict[str, Any]] = dict(provenance or {})
    #: this sitting's acts, each a runnable line of Python — what
    #: the console shows and `session_script` exports (Brandon,
    #: 2026-08-30). In memory only, and deliberately so: the
    #: journal is a record of *this session*, where the `.vdyn`
    #: file's provenance records tell the durable story. Guarded
    #: by `_journal_depth` so a verb calling other verbs — merge
    #: adds and links, refresh recomputes — records once, as the
    #: line the user could have typed.
    self.journal: list[str] = [
        f'project = visualdynamics.Project({str(name)!r})']
    self._journal_depth: int = 0
Attributes
basis property
basis: Selection

The Basis group, reached by type: project.basis.frf.

Empty (and falsy) when no group has been declared the Basis, so if project.basis: still asks the question it reads as. Its member names are project.basis.names.

groups property
groups: list[Selection]

Every link group, the Basis first, each reached by type.

other property
other: Selection

The one link group that is not the Basis — the model side of a correlation, usually. Says so when there are several.

names property
names: list[str]

Every object's name, in the order the tree shows them.

Methods:
grouped_names
grouped_names() -> list[tuple[LinkGroup | None, list[str]]]

[(group or None, [names])] in the order the tree shows them: the Basis group first, then the other link groups, then what is unlinked — each in the canonical type order, and objects of one type in the order they arrived.

Source code in src/visualdynamics/project.py
def grouped_names(self) -> list[tuple[LinkGroup | None, list[str]]]:
    """[(group or None, [names])] in the order the tree shows them:
    the Basis group first, then the other link groups, then what is
    unlinked — each in the canonical type order, and objects of one
    type in the order they arrived."""
    def ordered(names: Iterable[str]) -> list[str]:
        return sorted(names, key=lambda name: type_rank(self[name]))

    groups = sorted(self.links, key=lambda g: g['role'] != 'Basis')
    out, linked = [], set()
    for group in groups:
        members = [name for name in group['members'] if name in self]
        if members:
            out.append((group, ordered(members)))
            linked |= set(members)
    loose = ordered(name for name in self if name not in linked)
    return out + ([(None, loose)] if loose else [])
ordered_names
ordered_names() -> list[str]

Every name, flat, in the order the tree shows them.

Source code in src/visualdynamics/project.py
def ordered_names(self) -> list[str]:
    """Every name, flat, in the order the tree shows them."""
    return [name for _group, members in self.grouped_names()
            for name in members]
add
add(name: str, obj: Any) -> str

Add an object under a unique name; returns the name used.

A clash is numbered rather than refused or overwritten, exactly as importing twice does in the GUI. The first geometry added becomes the active one.

Parameters:

Name Type Description Default
name str

What to call it. A clash gets a numbered suffix.

required
obj object

Any object the project can hold.

required

Returns:

Type Description
str

The name the result was added under, which is unique within the project — a clash gets a numbered suffix.

Source code in src/visualdynamics/project.py
def add(self, name: str, obj: Any) -> str:
    """Add an object under a unique name; returns the name used.

    A clash is numbered rather than refused or overwritten, exactly
    as importing twice does in the GUI. The first geometry added
    becomes the active one.

    Parameters
    ----------
    name : str
        What to call it. A clash gets a numbered suffix.
    obj : object
        Any object the project can hold.

    Returns
    -------
    str
        The name the result was added under, which is unique
        within the project — a clash gets a numbered suffix.
    """
    unique, n = str(name), 1
    while unique in self:
        n += 1
        unique = f'{name} ({n})'
    self[unique] = obj
    if self.active_geometry is None and isinstance(obj, Geometry):
        self.active_geometry = unique
    return unique
duplicate
duplicate(*names: Any) -> list[str]

Copies of objects, added beside them (Copy, then Paste, in the tree): each under its own name with ' copy', numbered when that is taken. Returns the names added.

Independent objects, not views: a copy's arrays are its own, so editing one leaves the other as it was. Links and provenance stay with the originals — a copy is a fresh object that happens to hold the same numbers, and what it is for is the user's to say.

Parameters:

Name Type Description Default
*names str or object

The objects to copy, by name or as the objects.

()

Returns:

Type Description
list of str

The names the copies were added under, in order.

Source code in src/visualdynamics/project.py
def duplicate(self, *names: Any) -> list[str]:
    """Copies of objects, added beside them (Copy, then Paste, in
    the tree): each under its own name with ' copy', numbered when
    that is taken. Returns the names added.

    Independent objects, not views: a copy's arrays are its own,
    so editing one leaves the other as it was. Links and
    provenance stay with the originals — a copy is a fresh object
    that happens to hold the same numbers, and what it is for is
    the user's to say.

    Parameters
    ----------
    *names : str or object
        The objects to copy, by name or as the objects.

    Returns
    -------
    list of str
        The names the copies were added under, in order.
    """
    import copy

    added = []
    for name in names:
        name = self.name_of(name)
        added.append(self.add(f'{name} copy', copy.deepcopy(self[name])))
    return added
import_file
import_file(
    path: str | PathLike, **options: Any
) -> list[str]

Import a file into this project; returns the names added.

Anything visualdynamics reads: a geometry, a Rattlesnake run, or a whole saved project. A project brings its structure with it — its link groups follow the objects even when a name clash renamed them — and, into an empty project, its name, type and active geometry too. Foreign readers' keys become readable names ('Modal_frf' is an FRF), the way the tree spells them.

A file that knows what kind of test it was says so: a controller's own save records which environment drove the run, and adopting it here settles the project type in a script the same way importing one settles it in the window.

options pass through to the format's reader — an exodus file's steps='time' and nodes=[...], a geometry's length_unit='m' — so a script can declare what the window asks about in a dialog.

Parameters:

Name Type Description Default
path str or PathLike

The file to read. The importer is chosen by content and extension.

required
**options Any

Passed through to the importer.

{}

Returns:

Type Description
list of str

The names of every object added, in the order added.

Source code in src/visualdynamics/project.py
def import_file(self, path: str | os.PathLike,
                **options: Any) -> list[str]:
    """Import a file into this project; returns the names added.

    Anything visualdynamics reads: a geometry, a Rattlesnake run, or a whole
    saved project. A project brings its structure with it — its
    link groups follow the objects even when a name clash renamed
    them — and, into an empty project, its name, type and active
    geometry too. Foreign readers' keys become readable names
    ('Modal_frf' is an FRF), the way the tree spells them.

    A file that knows what kind of test it was says so: a
    controller's own save records which environment drove the run,
    and adopting it here settles the project type in a script the
    same way importing one settles it in the window.

    `options` pass through to the format's reader — an exodus
    file's `steps='time'` and `nodes=[...]`, a geometry's
    `length_unit='m'` — so a script can declare what the
    window asks about in a dialog.

    Parameters
    ----------
    path : str or os.PathLike
        The file to read. The importer is chosen by content
        and extension.
    **options
        Passed through to the importer.

    Returns
    -------
    list of str
        The names of every object added, in the order added.
    """
    from .io import import_file as read
    from .io import project_type_of

    was_empty = not self
    result = read(str(path), **options)
    self.project_type = project_type_of(str(path)) or self.project_type
    if isinstance(result, Project):
        mapping = {name: self.add(name, obj)
                   for name, obj in result.items()}
        if any(old != new for old, new in mapping.items()):
            # a clash renamed something: the references the
            # imported objects carry follow it
            for obj in result.values():
                retarget(obj, mapping)
        self.links += remap_links(
            result.links, mapping,
            {group['role'] for group in self.links if group['role']})
        if was_empty:
            self.name = result.name or self.name
            self.project_type = result.project_type
            if result.active_geometry in mapping:
                self.active_geometry = mapping[result.active_geometry]
        return list(mapping.values())
    if isinstance(result, dict):
        # a reader's keys are for code; the name is what the object
        # *is*, and the key stays available on the result
        added = [self.add(display_name(type(obj).__name__), obj)
                 for obj in result.values()]
        # a controller's run arrives as a channel table beside the
        # data it describes: one file, so one group (Brandon,
        # 2026-09-06 — linked, and otherwise two independent
        # objects: a coordinate corrected on one is not corrected on
        # the other)
        if len(added) > 1 and any(isinstance(obj, ChannelTable)
                                  for obj in result.values()):
            self.link(*added)
        return added
    return [self.add(display_name(type(result).__name__), result)]
remove
remove(*names: str) -> None

Delete objects, pruning them out of every link group.

Parameters:

Name Type Description Default
*names str

The objects to act on, by name.

()

Returns:

Type Description
None
Source code in src/visualdynamics/project.py
def remove(self, *names: str) -> None:
    """Delete objects, pruning them out of every link group.

    Parameters
    ----------
    *names : str
        The objects to act on, by name.

    Returns
    -------
    None
    """
    for name in names:
        self.pop(name, None)
        if self.active_geometry == name:
            self.active_geometry = next(
                (n for n, obj in self.items()
                 if isinstance(obj, Geometry)), None)
    self._prune_links()
rename
rename(old: str, new: str) -> str

Rename an object; every reference to it follows.

Link groups, matched-modes sets and report block bindings all name their objects, and a rename that left any of them pointing at the old name would strand a figure or a bracket.

Parameters:

Name Type Description Default
old str

The current name.

required
new str

The name to give it.

required

Returns:

Type Description
str

The name actually used, which may carry a suffix.

Source code in src/visualdynamics/project.py
def rename(self, old: str, new: str) -> str:
    """Rename an object; every reference to it follows.

    Link groups, matched-modes sets and report block bindings all
    name their objects, and a rename that left any of them pointing
    at the old name would strand a figure or a bracket.

    Parameters
    ----------
    old : str
        The current name.
    new : str
        The name to give it.

    Returns
    -------
    str
        The name actually used, which may carry a suffix.
    """
    if old not in self:
        raise KeyError(f'no object named {old!r}')
    if new in self:
        raise ValueError(f'name {new!r} is already in use')
    if not str(new).strip():
        raise ValueError('name cannot be empty')
    # rebuilt rather than popped, so the order the tree shows holds
    items = [(new if name == old else name, obj)
             for name, obj in self.items()]
    self.clear()
    self.update(items)
    if self.active_geometry == old:
        self.active_geometry = new
    for group in self.links:
        group['members'] = [new if member == old else member
                            for member in group['members']]
    for obj in self.values():
        retarget(obj, {old: new})
    if old in self.provenance:
        self.provenance[new] = self.provenance.pop(old)
    for record in self.provenance.values():
        if record.get('source') == old:
            record['source'] = new
    return new
rename_dof
rename_dof(
    source: Any,
    old: str,
    new: str,
    quantity: str | None = None,
) -> list[str]

Correct a channel's coordinate on an object and on everything derived from it (double-click a row or reference column of the grid and type).

The channel, not the point: a force labelled at the wrong node moves without taking the accelerometer at that node with it (Brandon, 2026-09-06 — the other is changed explicitly if it should be). The spectra computed from a time history inherited its channels, so one found mislabelled is mislabelled in every one of them; the correction follows the derivation chain rather than leaving each derived object to be fixed by hand or recomputed. An object downstream that does not carry the channel is left alone.

Parameters:

Name Type Description Default
source str or object

The object whose grid row or column was edited.

required
old str

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

required
new str

The coordinate to give it, normalised the way every DOF is.

required
quantity str

Which channel at old — 'acceleration', 'force' …; every channel at the coordinate when omitted.

None

Returns:

Type Description
list of str

The names of the objects changed, the source first.

Source code in src/visualdynamics/project.py
def rename_dof(self, source: Any, old: str, new: str,
               quantity: str | None = None) -> list[str]:
    """Correct a channel's coordinate on an object and on everything
    derived from it (double-click a row or reference column of the
    grid and type).

    The channel, not the point: a force labelled at the wrong node
    moves without taking the accelerometer at that node with it
    (Brandon, 2026-09-06 — the other is changed explicitly if it
    should be). The spectra computed from a time history inherited
    its channels, so one found mislabelled is mislabelled in every
    one of them; the correction follows the derivation chain rather
    than leaving each derived object to be fixed by hand or
    recomputed. An object downstream that does not carry the
    channel is left alone.

    Parameters
    ----------
    source : str or object
        The object whose grid row or column was edited.
    old : str
        The coordinate as it is, '101Z+'.
    new : str
        The coordinate to give it, normalised the way every DOF is.
    quantity : str, optional
        Which channel at `old` — 'acceleration', 'force' …; every
        channel at the coordinate when omitted.

    Returns
    -------
    list of str
        The names of the objects changed, the source first.
    """
    name = self.name_of(source)
    obj = self[name]
    if not hasattr(obj, 'rename_dof'):
        raise TypeError(f'{name} has no coordinates to rename')
    obj.rename_dof(old, new, quantity)
    changed = [name]
    for other in list(self.provenance):
        if other not in self or not self._descends_from(other, name):
            continue
        derived = self[other]
        if not hasattr(derived, 'rename_dof'):
            continue
        try:
            if derived.rename_dof(old, new, quantity):
                changed.append(other)
        except ValueError:
            # the channel is not on this one — a modal
            # transformation carries none of the source's
            continue
    return changed
link(*names: str, role: str | None = None) -> list[str]

Declare objects part of one group, merging any they are in.

A group holds at most one geometry — its members are read against it — and a member naming nodes that geometry lacks is refused, because the link would be a claim that is not true. Returns the group's members.

Parameters:

Name Type Description Default
*names str

The objects to act on, by name.

()
role str

The role to give the group — 'Basis', or None.

None

Returns:

Type Description
list of str

The group's members after linking.

Source code in src/visualdynamics/project.py
def link(self, *names: str, role: str | None = None) -> list[str]:
    """Declare objects part of one group, merging any they are in.

    A group holds at most one geometry — its members are read
    against it — and a member naming nodes that geometry lacks is
    refused, because the link would be a claim that is not true.
    Returns the group's members.

    Parameters
    ----------
    *names : str
        The objects to act on, by name.
    role : str, optional
        The role to give the group — 'Basis', or None.

    Returns
    -------
    list of str
        The group's members after linking.
    """
    names = [str(name) for name in names]
    if len(names) < 2:
        raise ValueError('linking takes two or more objects')
    missing = [name for name in names if name not in self]
    if missing:
        raise KeyError(f'no object named {missing[0]!r}')
    # Existing members keep their places; only genuinely new names
    # append. The first version seeded `merged` with `names` and
    # prepended what each touched group already held — which moved
    # a member being re-linked to the back of its own group, and
    # the tree (which keeps arrival order within a type, read from
    # this list) showed a time history jumping down its group the
    # moment FRFs were computed from it (Brandon, 2026-08-29).
    merged, kept = [], []
    for group in self.links:
        if any(name in group['members'] for name in names):
            merged += [name for name in group['members']
                       if name not in merged]
            role = role or group['role']
        else:
            kept.append(group)
    merged += [name for name in names if name not in merged]
    geometries = [name for name in merged
                  if isinstance(self.get(name), Geometry)]
    if len(geometries) > 1:
        raise ValueError('a link holds one geometry — '
                         f'{" and ".join(geometries)} cannot share')
    from .compatibility import check_object, modal_object

    # a modal member answers to a shape set in the group, whether
    # or not the group holds a geometry; everything else answers
    # to the geometry when there is one
    companions = {member: self[member] for member in merged}
    against = (self[geometries[0]], geometries[0]) if geometries else None
    for member in merged:
        if against is None and modal_object(self[member]) is None:
            continue
        issue = check_object(
            member, self[member],
            None if against is None else against[0],
            'the active geometry' if against is None else against[1],
            companions=companions)
        if issue is not None:
            raise ValueError(f'cannot link: {issue.message}')
    self.links = kept + [{'members': merged, 'role': role}]
    if role is not None:
        self.set_role(merged[0], role)
    return merged
unlink(*names: str) -> None

Take objects out of their groups; a group of one dissolves.

Parameters:

Name Type Description Default
*names str

The objects to act on, by name.

()

Returns:

Type Description
None
Source code in src/visualdynamics/project.py
def unlink(self, *names: str) -> None:
    """Take objects out of their groups; a group of one dissolves.

    Parameters
    ----------
    *names : str
        The objects to act on, by name.

    Returns
    -------
    None
    """
    names = {str(name) for name in names}
    self.links = [
        {'members': kept, 'role': group['role']}
        for group, kept in
        ((group, [member for member in group['members']
                  if member not in names])
         for group in self.links)
        # A *named* group holds one object quite happily: the FEM
        # group of a modal test starts as a lone geometry, and
        # dissolving it is why the group could never be built up one
        # drag at a time.
        if len(kept) > 1 or (kept and group['role'])]
relink(name: Any, target: Any = None) -> list[str]

Move one object into the group holding target.

link merges the groups its arguments are in, which is right for declaring two things related and wrong for moving one thing between groups — linking a geometry to the other side would pull its whole group across with it. This takes the object out first, so only it moves; target of None just takes it out.

The group it lands in keeps its role, so dropping something into the Basis makes it part of the Basis rather than dissolving it. Returns the members of the group it ends up in, empty if none.

Parameters:

Name Type Description Default
name str or object

The object to move.

required
target str or object

An object whose group it should join. None removes it from its current group.

None

Returns:

Type Description
list of str

The group's members afterwards.

Source code in src/visualdynamics/project.py
def relink(self, name: Any, target: Any = None) -> list[str]:
    """Move one object into the group holding `target`.

    `link` *merges* the groups its arguments are in, which is right
    for declaring two things related and wrong for moving one thing
    between groups — linking a geometry to the other side would pull
    its whole group across with it. This takes the object out first,
    so only it moves; `target` of None just takes it out.

    The group it lands in keeps its role, so dropping something into
    the Basis makes it part of the Basis rather than dissolving it.
    Returns the members of the group it ends up in, empty if none.

    Parameters
    ----------
    name : str or object
        The object to move.
    target : str or object, optional
        An object whose group it should join. None removes it
        from its current group.

    Returns
    -------
    list of str
        The group's members afterwards.
    """
    name = self.name_of(name)
    if target is None:
        self.unlink(name)
        return []
    target = self.name_of(target)
    if target == name:
        raise ValueError('an object is already in its own group')
    members = self.group_of(target) or [target]
    if name in members:
        return list(members)
    role = self.role_of(target)
    # `link` refuses a second geometry or a member the group's
    # geometry cannot carry, and by then the object has already left
    # where it was: a refused move must leave the links untouched
    # rather than half applied.
    before = [dict(group) for group in self.links]
    self.unlink(name)
    try:
        # `name` last: it joins the group it landed in, and a group
        # reads in the order its members arrived
        return self.link(*members, name, role=role)
    except (ValueError, KeyError):
        self.links = before
        raise
group_of
group_of(name: Any) -> list[str] | None

The members linked with name, or None.

Parameters:

Name Type Description Default
name str or object

The object to look up.

required

Returns:

Type Description
list of str, or None

The names sharing its link group, or None when it is in no group.

Source code in src/visualdynamics/project.py
def group_of(self, name: Any) -> list[str] | None:
    """The members linked with `name`, or None.

    Parameters
    ----------
    name : str or object
        The object to look up.

    Returns
    -------
    list of str, or None
        The names sharing its link group, or None when it is in
        no group.
    """
    group = self._group(self.name_of(name))
    return None if group is None else list(group['members'])
role_of
role_of(name: Any) -> str | None

'Basis', or None for an unroled or unlinked object.

Parameters:

Name Type Description Default
name str or object

The object to look up.

required

Returns:

Type Description
str or None

Its link group's role, or None if it has none.

Source code in src/visualdynamics/project.py
def role_of(self, name: Any) -> str | None:
    """'Basis', or None for an unroled or unlinked object.

    Parameters
    ----------
    name : str or object
        The object to look up.

    Returns
    -------
    str or None
        Its link group's role, or None if it has none.
    """
    group = self._group(self.name_of(name))
    return None if group is None else group['role']
placed
placed() -> dict[str, list[str]]

{role: [members]} — which objects are in each named group.

Nothing is guessed here. An object nobody has placed is in no named group, which is what makes the tree's grey slots mean anything: a slot is filled by an object in its own group, so a modal test whose only geometry is the model's still shows a slot for the measured one.

Source code in src/visualdynamics/project.py
def placed(self) -> dict[str, list[str]]:
    """{role: [members]} — which objects are in each *named* group.

    Nothing is guessed here. An object nobody has placed is in no
    named group, which is what makes the tree's grey slots mean
    anything: a slot is filled by an object in its own group, so a
    modal test whose only geometry is the model's still shows a
    slot for the measured one.
    """
    out: dict[str, list[str]] = {}
    for group in self.links:
        if group['role']:
            out.setdefault(group['role'],
                           []).extend(group['members'])
    return out
role_group
role_group(role: str) -> LinkGroup | None

The link group carrying a role, or None.

Parameters:

Name Type Description Default
role str

Which named group to fetch — 'Basis' is the only name.

required

Returns:

Type Description
LinkGroup or None

That group, or None if unset.

Source code in src/visualdynamics/project.py
def role_group(self, role: str) -> LinkGroup | None:
    """The link group carrying a role, or None.

    Parameters
    ----------
    role : str
        Which named group to fetch — 'Basis' is the only name.

    Returns
    -------
    LinkGroup or None
        That group, or None if unset.
    """
    return next((group for group in self.links
                 if group['role'] == role), None)
place
place(name: Any, role: str) -> list[str]

Put one object into the named group, making it if need be.

Unlike an ordinary link this takes a single object, because a named group is a declaration rather than an observed relation — and needing two members before the group can exist at all is what made it impossible to move a wrongly-sorted pair across one at a time. Returns the group's members.

Parameters:

Name Type Description Default
name str or object

The object being placed.

required
role str or None

'Basis', or None for the other group — the one group that is not the Basis, made if there is none yet. With several groups besides the Basis there is no one other, and this refuses: relink onto a member of the one meant.

required

Returns:

Type Description
list of str

The group's members.

Source code in src/visualdynamics/project.py
def place(self, name: Any, role: str) -> list[str]:
    """Put one object into the named group, making it if need be.

    Unlike an ordinary link this takes a single object, because a
    named group is a *declaration* rather than an observed relation
    — and needing two members before the group can exist at all is
    what made it impossible to move a wrongly-sorted pair across
    one at a time. Returns the group's members.

    Parameters
    ----------
    name : str or object
        The object being placed.
    role : str or None
        'Basis', or None for the *other* group — the one group
        that is not the Basis, made if there is none yet. With
        several groups besides the Basis there is no one other,
        and this refuses: relink onto a member of the one meant.

    Returns
    -------
    list of str
        The group's members.
    """
    name = self.name_of(name)
    if role is None:
        others = [g for g in self.links if g['role'] != 'Basis'
                  and name not in g['members']]
        mine = self._group(name)
        if mine is not None and mine['role'] != 'Basis':
            return list(mine['members'])
        if len(others) > 1:
            raise ValueError(
                f'{len(others)} groups besides the Basis — drop '
                'onto a member of the one meant')
        before = [dict(existing) for existing in self.links]
        self.unlink(name)
        try:
            if not others:
                self.links = self.links + [
                    {'members': [name], 'role': None}]
                return [name]
            return self.link(*others[0]['members'], name)
        except (ValueError, KeyError):
            self.links = before
            raise
    group = self.role_group(role)
    if group is not None and name in group['members']:
        return list(group['members'])
    before = [dict(existing) for existing in self.links]
    self.unlink(name)
    group = self.role_group(role)
    try:
        if group is None:
            self.links = self.links + [
                {'members': [name], 'role': None}]
            self.set_role(name, role)
            return [name]
        return self.link(*group['members'], name, role=role)
    except (ValueError, KeyError):
        self.links = before
        raise
set_role
set_role(name: str, role: str | None) -> None

Name what a group is. The Basis is unique: taking the role takes it from whatever group held it.

Parameters:

Name Type Description Default
name str

The object whose group is being labelled.

required
role str or None

The role, or None to clear it.

required

Returns:

Type Description
None
Source code in src/visualdynamics/project.py
def set_role(self, name: str, role: str | None) -> None:
    """Name what a group is. The Basis is unique: taking the role
    takes it from whatever group held it.

    Parameters
    ----------
    name : str
        The object whose group is being labelled.
    role : str or None
        The role, or None to clear it.

    Returns
    -------
    None
    """
    group = self._group(name)
    if group is None:
        raise ValueError(f'{name!r} is not in a link group')
    if role is not None:
        for other in self.links:
            if other is not group and other['role'] == role:
                other['role'] = None
    group['role'] = role
set_basis
set_basis(*names: Any) -> list[str]

Declare the Basis of comparisons: the group whose DOFs comparisons happen in, whose modes are the MAC rows and the frequency-error baseline. One name marks that object's group; several link them first.

Parameters:

Name Type Description Default
*names str or object

The objects that form the basis set.

()

Returns:

Type Description
list of str

The basis group's members.

Source code in src/visualdynamics/project.py
def set_basis(self, *names: Any) -> list[str]:
    """Declare the Basis of comparisons: the group whose DOFs
    comparisons happen in, whose modes are the MAC rows and the
    frequency-error baseline. One name marks that object's group;
    several link them first.

    Parameters
    ----------
    *names : str or object
        The objects that form the basis set.

    Returns
    -------
    list of str
        The basis group's members.
    """
    names = tuple(self.name_of(name) for name in names)
    if len(names) > 1:
        self.link(*names, role='Basis')
    elif names:
        self.set_role(names[0], 'Basis')
    return self.basis.names
geometry_for
geometry_for(name: Any) -> tuple[str, Geometry] | None

(name, geometry) the object answers to: its group's, else the active one. What it is drawn on, and checked against.

Parameters:

Name Type Description Default
name str or object

The object whose geometry is wanted.

required

Returns:

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

The geometry's name and the geometry itself, or None when the object is not linked to one.

Source code in src/visualdynamics/project.py
def geometry_for(self, name: Any) -> tuple[str, Geometry] | None:
    """(name, geometry) the object answers to: its group's, else
    the active one. What it is drawn on, and checked against.

    Parameters
    ----------
    name : str or object
        The object whose geometry is wanted.

    Returns
    -------
    tuple of (str, Geometry), or None
        The geometry's name and the geometry itself, or None when
        the object is not linked to one.
    """
    name = self.name_of(name)
    for member in self.group_of(name) or ():
        if isinstance(self.get(member), Geometry):
            return member, self[member]
    active = self.active_geometry
    if active is not None and active in self:
        return active, self[active]
    return None
absorb_links(groups: Iterable[LinkGroup]) -> None

Take on the link groups of a project being imported.

The arriving objects have already been placed in named groups by the project type's rules — one at a time, as each arrived, which is a guess made without the file's own structure to go on. The file knows better, so it goes last and _prune_links lets it win.

Parameters:

Name Type Description Default
groups iterable of LinkGroup

Link groups from another project, merged into this one's.

required

Returns:

Type Description
None
Source code in src/visualdynamics/project.py
def absorb_links(self, groups: Iterable[LinkGroup]) -> None:
    """Take on the link groups of a project being imported.

    The arriving objects have *already* been placed in named
    groups by the project type's rules — one at a time, as each arrived,
    which is a guess made without the file's own structure to go
    on. The file knows better, so it goes last and `_prune_links`
    lets it win.

    Parameters
    ----------
    groups : iterable of LinkGroup
        Link groups from another project, merged into this one's.

    Returns
    -------
    None
    """
    self.links = list(self.links) + [dict(group) for group in groups]
    self._prune_links()
verbs
verbs(source: Any = None) -> list[tuple[str, str]]

The processing verbs that apply to an object, each with its one-line reading — how a script writer discovers what can be done with what (Brandon, 2026-08-31: a flat method list says nothing about what integrate is for).

The applicability table is the same one the window's bar reads, so the two surfaces cannot disagree; the summaries are the first paragraph of each verb's own docstring, so this and the API reference cannot disagree either.

Parameters:

Name Type Description Default
source str or object

The object to ask about — a name looks it up here, an object answers for itself whether or not it has been added (what applies to a result is knowable before it is kept). Omitted, every processing verb is listed.

None

Returns:

Type Description
list of (str, str)

(verb, summary) pairs, in the order the verbs are declared. Call the verb as getattr(project, verb), or just read the list and type the name.

Source code in src/visualdynamics/project.py
def verbs(self, source: Any = None) -> list[tuple[str, str]]:
    """The processing verbs that apply to an object, each with its
    one-line reading — how a script writer discovers what can be
    done with what (Brandon, 2026-08-31: a flat method list says
    nothing about what `integrate` is *for*).

    The applicability table is the same one the window's bar
    reads, so the two surfaces cannot disagree; the summaries
    are the first paragraph of each verb's own docstring, so this
    and the API reference cannot disagree either.

    Parameters
    ----------
    source : str or object, optional
        The object to ask about — a name looks it up here, an
        object answers for itself whether or not it has been
        added (what applies to a result is knowable before it is
        kept). Omitted, every processing verb is listed.

    Returns
    -------
    list of (str, str)
        ``(verb, summary)`` pairs, in the order the verbs are
        declared. Call the verb as ``getattr(project, verb)``, or
        just read the list and type the name.
    """
    import inspect

    obj = self[source] if isinstance(source, str) else source
    out = []
    for verb, applies in _VERB_APPLIES:
        if obj is not None and not applies(self, obj):
            continue
        doc = inspect.cleandoc(getattr(type(self), verb).__doc__)
        out.append((verb, ' '.join(doc.split('\n\n', 1)[0].split())))
    return out
selection_verbs
selection_verbs(*names: Any) -> list[tuple[str, str]]

The processing verbs a selection can act on, each with its one-line reading — what the window's bar offers (Brandon, 2026-09-04: every act on the bar, none behind a menu).

One object: its own verbs, less the ones that need a partner (transform needs a shape set beside the record). Several: the partner verbs that apply to exactly that combination — a record and a shape set transform or expand, two shape sets on two geometries project, siblings of one type merge — and none of the verbs that apply to one of them alone. The same table verbs reads, so the bar and the API cannot disagree.

Parameters:

Name Type Description Default
*names str or object

The selection, by name or as the objects themselves.

()

Returns:

Type Description
list of (str, str)

(verb, summary) pairs, in the order the verbs are declared.

Source code in src/visualdynamics/project.py
def selection_verbs(self, *names: Any) -> list[tuple[str, str]]:
    """The processing verbs a *selection* can act on, each with its
    one-line reading — what the window's bar offers (Brandon,
    2026-09-04: every act on the bar, none behind a menu).

    One object: its own verbs, less the ones that need a partner
    (`transform` needs a shape set beside the record). Several: the
    partner verbs that apply to exactly that combination — a
    record and a shape set transform or expand, two shape sets on
    two geometries project, siblings of one type merge — and none
    of the verbs that apply to one of them alone. The same table
    `verbs` reads, so the bar and the API cannot disagree.

    Parameters
    ----------
    *names : str or object
        The selection, by name or as the objects themselves.

    Returns
    -------
    list of (str, str)
        ``(verb, summary)`` pairs, in the order the verbs are
        declared.
    """
    import inspect

    objects = [self[self.name_of(name)] for name in names]
    if not objects:
        return []
    if len(objects) == 1:
        return [(verb, summary) for verb, summary in self.verbs(objects[0])
                if verb not in PARTNER_VERBS]
    out = []
    for verb, applies in _SELECTION_APPLIES:
        if applies(self, objects):
            doc = inspect.cleandoc(getattr(type(self), verb).__doc__)
            out.append((verb, ' '.join(doc.split('\n\n', 1)[0].split())))
    return out
compute_spectra
compute_spectra(source: Any) -> str

Spectra from a time history's averages (the averaging view's Compute Spectra).

Parameters:

Name Type Description Default
source str or object

The object to read, by name or as the object itself; name_of resolves either.

required

Returns:

Type Description
str

The name the result was added under, which is unique within the project — a clash gets a numbered suffix.

Source code in src/visualdynamics/project.py
def compute_spectra(self, source: Any) -> str:
    """Spectra from a time history's averages (the averaging
    view's Compute Spectra).

    Parameters
    ----------
    source : str or object
        The object to read, by name or as the object itself;
        `name_of` resolves either.

    Returns
    -------
    str
        The name the result was added under, which is unique
        within the project — a clash gets a numbered suffix.
    """
    source = self.name_of(source)
    return self._derive(source, self[source].compute_spectra(),
                        f'{source} Spectra', recipe=('compute_spectra', {}))
compute_psds
compute_psds(source: Any) -> str

PSDs from a time history's averages (Compute PSDs).

Parameters:

Name Type Description Default
source str or object

The object to read, by name or as the object itself; name_of resolves either.

required

Returns:

Type Description
str

The name the result was added under, which is unique within the project — a clash gets a numbered suffix.

Source code in src/visualdynamics/project.py
def compute_psds(self, source: Any) -> str:
    """PSDs from a time history's averages (Compute PSDs).

    Parameters
    ----------
    source : str or object
        The object to read, by name or as the object itself;
        `name_of` resolves either.

    Returns
    -------
    str
        The name the result was added under, which is unique
        within the project — a clash gets a numbered suffix.
    """
    source = self.name_of(source)
    return self._derive(source, self[source].compute_psds(),
                        f'{source} PSDs', recipe=('compute_psds', {}))
compute_octave
compute_octave(
    source: Any, per_octave: int | None = None
) -> str

A spectrum integrated onto proportional bands (Compute Octave Bands) — the same power, arranged the way it is read.

Parameters:

Name Type Description Default
source str or object

The object to read, by name or as the object itself; name_of resolves either.

required
per_octave int

Bands per octave. Defaults to core.octave.PER_OCTAVE.

None

Returns:

Type Description
str

The name the result was added under, which is unique within the project — a clash gets a numbered suffix.

Source code in src/visualdynamics/project.py
def compute_octave(self, source: Any, per_octave: int | None = None
                   ) -> str:
    """A spectrum integrated onto proportional bands (Compute
    Octave Bands) — the same power, arranged the way it is read.

    Parameters
    ----------
    source : str or object
        The object to read, by name or as the object itself;
        `name_of` resolves either.
    per_octave : int, optional
        Bands per octave. Defaults to `core.octave.PER_OCTAVE`.

    Returns
    -------
    str
        The name the result was added under, which is unique
        within the project — a clash gets a numbered suffix.
    """
    from .core.octave import PER_OCTAVE

    per_octave = PER_OCTAVE if per_octave is None else int(per_octave)
    source = self.name_of(source)
    return self._derive(
        source, self[source].to_octave(per_octave),
        f'{source} 1/{per_octave} Octave',
        recipe=('compute_octave', {'per_octave': per_octave}))
compute_frfs
compute_frfs(source: Any, method: str = 'Hv') -> str

Frequency response functions from a time history (Compute FRFs) — one per response and drive, over the frames a PSD uses.

method is 'Hv', 'H1' or 'H2': where the noise is assumed to be, which is the one thing the three estimators disagree about. The name goes on the object, since two FRF sets from one history differ in nothing else a reader can see.

The frames are detected if the history has none, the same way the coherence does it, so the two describe one measurement.

Parameters:

Name Type Description Default
source str or object

The object to read, by name or as the object itself; name_of resolves either.

required
method str

Which estimator: 'Hv', 'H1' or 'H2' — where the noise is assumed to be, which is the one thing they disagree about.

'Hv'

Returns:

Type Description
str

The name the result was added under, which is unique within the project — a clash gets a numbered suffix.

Source code in src/visualdynamics/project.py
def compute_frfs(self, source: Any, method: str = 'Hv') -> str:
    """Frequency response functions from a time history (Compute
    FRFs) — one per response and drive, over the frames a PSD uses.

    `method` is 'Hv', 'H1' or 'H2': where the noise is assumed to
    be, which is the one thing the three estimators disagree about.
    The name goes on the object, since two FRF sets from one history
    differ in nothing else a reader can see.

    The frames are detected if the history has none, the same way
    the coherence does it, so the two describe one measurement.

    Parameters
    ----------
    source : str or object
        The object to read, by name or as the object itself;
        `name_of` resolves either.
    method : str, default 'Hv'
        Which estimator: 'Hv', 'H1' or 'H2' — where the noise is
        assumed to be, which is the one thing they disagree about.

    Returns
    -------
    str
        The name the result was added under, which is unique
        within the project — a clash gets a numbered suffix.
    """
    source = self.name_of(source)
    history = self[source]
    if history.averaging is None:
        history.averaging = history.suggest_averaging()
    return self._derive(source, history.compute_frfs(method=method),
                        f'{source} {method} FRFs',
                        recipe=('compute_frfs', {'method': method}))
compute_multiple_coherence
compute_multiple_coherence(source: Any) -> str

Multiple coherence from a time history (Compute Multiple Coherence) — how much of each response the drives account for.

Averaged over frames, and the frames are detected if the history has none. Computed over the whole selection instead, the reference set fits every response exactly and the answer is 1.0 at every line — a number that says nothing, arrived at honestly.

Parameters:

Name Type Description Default
source str or object

The object to read, by name or as the object itself; name_of resolves either.

required

Returns:

Type Description
str

The name the result was added under, which is unique within the project — a clash gets a numbered suffix.

Source code in src/visualdynamics/project.py
def compute_multiple_coherence(self, source: Any) -> str:
    """Multiple coherence from a time history (Compute Multiple
    Coherence) — how much of each response the drives account for.

    Averaged over frames, and the frames are detected if the history
    has none. Computed over the whole selection instead, the
    reference set fits every response exactly and the answer is 1.0
    at every line — a number that says nothing, arrived at honestly.

    Parameters
    ----------
    source : str or object
        The object to read, by name or as the object itself;
        `name_of` resolves either.

    Returns
    -------
    str
        The name the result was added under, which is unique
        within the project — a clash gets a numbered suffix.
    """
    source = self.name_of(source)
    history = self[source]
    if history.averaging is None:
        history.averaging = history.suggest_averaging()
    return self._derive(source, history.compute_multiple_coherence(),
                        f'{source} Multiple Coherence',
                        recipe=('compute_multiple_coherence', {}))
compute_srs
compute_srs(
    source: Any,
    *,
    per_octave: int | None = None,
    q: float | None = None,
    kind: str = "maximax",
) -> str

Shock response spectra from a time history's shocks (Compute SRS) — one curve per channel per event.

The events are the history's own — detected only when there is nothing else to say where they are, so this answers rather than asking the caller to go and find them.

Detection is the last resort and not the first. A record being read as frames already says where its events are: a transient run's playings are its averaging, and set loose on one the detector answered with thirty-one events where there were six. A target is one playing by definition and gets no detector at all — it was being cut into three.

Parameters:

Name Type Description Default
source str or object

The object to read, by name or as the object itself; name_of resolves either.

required
per_octave int

Natural-frequency lines per octave. Defaults to the module's convention (12).

None
q float

The oscillator amplification, Q = 1/(2ζ); 10 — 5% damping, the shock-test convention — when omitted.

None
kind str

Which peak each oscillator reports: 'maximax' (largest magnitude of either sign), 'positive' or 'negative'.

'maximax'

Returns:

Type Description
str

The name the result was added under, which is unique within the project — a clash gets a numbered suffix.

Source code in src/visualdynamics/project.py
def compute_srs(self, source: Any, *,
                per_octave: int | None = None,
                q: float | None = None,
                kind: str = 'maximax') -> str:
    """Shock response spectra from a time history's shocks (Compute
    SRS) — one curve per channel per event.

    The events are the history's own — detected only when there is
    nothing else to say where they are, so this answers rather than
    asking the caller to go and find them.

    Detection is the last resort and not the first. A record being
    read as frames already says where its events are: a transient
    run's playings *are* its averaging, and set loose on one the
    detector answered with thirty-one events where there were six.
    A target is one playing by definition and gets no detector at
    all — it was being cut into three.

    Parameters
    ----------
    source : str or object
        The object to read, by name or as the object itself;
        `name_of` resolves either.
    per_octave : int, optional
        Natural-frequency lines per octave. Defaults to the
        module's convention (12).
    q : float, optional
        The oscillator amplification, Q = 1/(2ζ); 10 — 5%
        damping, the shock-test convention — when omitted.
    kind : str, default 'maximax'
        Which peak each oscillator reports: 'maximax' (largest
        magnitude of either sign), 'positive' or 'negative'.

    Returns
    -------
    str
        The name the result was added under, which is unique
        within the project — a clash gets a numbered suffix.
    """
    from .core.data import TransientSpecification

    source = self.name_of(source)
    history = self[source]
    if (not history.shocks and history.averaging is None
            and not isinstance(history, TransientSpecification)):
        from .core.shocks import suggest

        history.shocks = suggest(history)
    # the analysis choices are parameters of the act, recorded in
    # the recipe like integrate's drift corner — a refresh after
    # the windows move recomputes at the same Q, spacing and kind
    return self._derive(source,
                        history.compute_srs(per_octave=per_octave,
                                            q=q, kind=kind),
                        f'{source} SRS',
                        recipe=('compute_srs',
                                {'per_octave': per_octave,
                                 'q': q, 'kind': kind}))
detect_shocks
detect_shocks(source: Any) -> int

Find the events in a time history and mark them on it (the shock view's Detect), returning how many.

The verb the API was missing (Brandon, 2026-08-25). compute_srs detects as a side effect when a record carries no windows, which served while the SRS came straight off the recording — but the recommended shock workflow filters first, and then the detection happened on the filtered record and the recording itself was left unmarked. The events belong to the recording: mark them there and every derivation carries them forward, because core.filters copies the marks onto whatever it makes.

Parameters:

Name Type Description Default
source str or object

The object to read, by name or as the object itself; name_of resolves either.

required

Returns:

Type Description
int

How many events were found and marked on the record.

Source code in src/visualdynamics/project.py
def detect_shocks(self, source: Any) -> int:
    """Find the events in a time history and mark them on it (the
    shock view's Detect), returning how many.

    The verb the API was missing (Brandon, 2026-08-25). `compute_srs`
    detects as a side effect when a record carries no windows, which
    served while the SRS came straight off the recording — but the
    recommended shock workflow filters first, and then the detection
    happened on the *filtered* record and the recording itself was
    left unmarked. The events belong to the recording: mark them
    there and every derivation carries them forward, because
    `core.filters` copies the marks onto whatever it makes.

    Parameters
    ----------
    source : str or object
        The object to read, by name or as the object itself;
        `name_of` resolves either.

    Returns
    -------
    int
        How many events were found and marked on the record.
    """
    source = self.name_of(source)
    from .core.shocks import find

    history = self[source]
    history.shocks = find(history)
    return len(history.shocks or ())
filter_data
filter_data(source: Any) -> str

A time history through its low-pass (the filter view's Apply Filter) — every channel, zero phase, so the peaks stay put.

The settings are the history's own filtering, set in the filter view; with none set, the suggestion is adopted the way compute_frfs adopts a suggested averaging, so the button works before the view has been visited.

Parameters:

Name Type Description Default
source str or object

The object to read, by name or as the object itself; name_of resolves either.

required

Returns:

Type Description
str

The name the result was added under, which is unique within the project — a clash gets a numbered suffix.

Source code in src/visualdynamics/project.py
def filter_data(self, source: Any) -> str:
    """A time history through its low-pass (the filter view's
    Apply Filter) — every channel, zero phase, so the peaks stay put.

    The settings are the history's own `filtering`, set in the
    filter view; with none set, the suggestion is adopted the way
    `compute_frfs` adopts a suggested averaging, so the button
    works before the view has been visited.

    Parameters
    ----------
    source : str or object
        The object to read, by name or as the object itself;
        `name_of` resolves either.

    Returns
    -------
    str
        The name the result was added under, which is unique
        within the project — a clash gets a numbered suffix.
    """
    source = self.name_of(source)
    history = self[source]
    if history.filtering is None:
        history.filtering = history.suggest_filtering()
    return self._derive(source, history.filter(),
                        f'{source} Filtered',
                        recipe=('filter_data', {}))
truncate_data
truncate_data(source: Any) -> str

A time history cut to its truncation's span (the truncate view's Apply Truncation) — every channel between start and stop, the clock kept.

The span is the history's own truncation, set in the truncate view. Unlike Filter Data there is no suggestion to adopt: the whole record is the only neutral span and keeping all of it is not an act, so with none set this refuses and says where to set one.

Parameters:

Name Type Description Default
source str or object

The object to read, by name or as the object itself; name_of resolves either.

required

Returns:

Type Description
str

The name the result was added under, which is unique within the project — a clash gets a numbered suffix.

Source code in src/visualdynamics/project.py
def truncate_data(self, source: Any) -> str:
    """A time history cut to its truncation's span (the
    truncate view's Apply Truncation) — every channel between start and
    stop, the clock kept.

    The span is the history's own `truncation`, set in the
    truncate view. Unlike Filter Data there is no suggestion to
    adopt: the whole record is the only neutral span and keeping
    all of it is not an act, so with none set this refuses and
    says where to set one.

    Parameters
    ----------
    source : str or object
        The object to read, by name or as the object itself;
        `name_of` resolves either.

    Returns
    -------
    str
        The name the result was added under, which is unique
        within the project — a clash gets a numbered suffix.
    """
    source = self.name_of(source)
    history = self[source]
    if history.truncation is None:
        raise ValueError('no span set: drag one in the truncate '
                         'view first')
    return self._derive(source, history.truncate(),
                        f'{source} Truncated',
                        recipe=('truncate_data', {}))
integrate
integrate(source: Any, drift_corner: Any = ...) -> str

One integration of a time history (Integrate): acceleration channels become velocity, velocity becomes displacement.

Whole record, never the shock windows — the reasons live in core.filters. The drift corner is a parameter of the act, recorded in the recipe: ... takes the default, None integrates raw.

Parameters:

Name Type Description Default
source str or object

The object to read, by name or as the object itself; name_of resolves either.

required
drift_corner float or None

High-pass corner in Hz applied after integration, to stop a sensor bias becoming a ramp. ... takes the default; None integrates raw, drift and all.

...

Returns:

Type Description
str

The name the result was added under, which is unique within the project — a clash gets a numbered suffix.

Source code in src/visualdynamics/project.py
def integrate(self, source: Any, drift_corner: Any = ...) -> str:
    """One integration of a time history (Integrate): acceleration
    channels become velocity, velocity becomes displacement.

    Whole record, never the shock windows — the reasons live in
    `core.filters`. The drift corner is a parameter of the act,
    recorded in the recipe: ``...`` takes the default, ``None``
    integrates raw.

    Parameters
    ----------
    source : str or object
        The object to read, by name or as the object itself;
        `name_of` resolves either.
    drift_corner : float or None, optional
        High-pass corner in Hz applied after integration, to stop a
        sensor bias becoming a ramp. `...` takes the default;
        `None` integrates raw, drift and all.

    Returns
    -------
    str
        The name the result was added under, which is unique
        within the project — a clash gets a numbered suffix.
    """
    from .core.filters import DRIFT_CORNER

    corner = DRIFT_CORNER if drift_corner is ... else drift_corner
    source = self.name_of(source)
    result = self[source].integrate(corner)
    return self._derive(source, result,
                        _motion_name(source, result, 'Integrated'),
                        recipe=('integrate', {'drift_corner': corner}))
differentiate
differentiate(source: Any) -> str

One differentiation of a time history (Differentiate): displacement channels become velocity, velocity becomes acceleration.

Parameters:

Name Type Description Default
source str or object

The object to read, by name or as the object itself; name_of resolves either.

required

Returns:

Type Description
str

The name the result was added under, which is unique within the project — a clash gets a numbered suffix.

Source code in src/visualdynamics/project.py
def differentiate(self, source: Any) -> str:
    """One differentiation of a time history (Differentiate):
    displacement channels become velocity, velocity becomes
    acceleration.

    Parameters
    ----------
    source : str or object
        The object to read, by name or as the object itself;
        `name_of` resolves either.

    Returns
    -------
    str
        The name the result was added under, which is unique
        within the project — a clash gets a numbered suffix.
    """
    source = self.name_of(source)
    result = self[source].differentiate()
    return self._derive(source, result,
                        _motion_name(source, result, 'Differentiated'),
                        recipe=('differentiate', {}))
compute_cpsds
compute_cpsds(source: Any) -> str

The full cross-spectral matrix from a time history's averages (Compute CPSDs) — every channel against every channel.

Parameters:

Name Type Description Default
source str or object

The object to read, by name or as the object itself; name_of resolves either.

required

Returns:

Type Description
str

The name the result was added under, which is unique within the project — a clash gets a numbered suffix.

Source code in src/visualdynamics/project.py
def compute_cpsds(self, source: Any) -> str:
    """The full cross-spectral matrix from a time history's averages
    (Compute CPSDs) — every channel against every channel.

    Parameters
    ----------
    source : str or object
        The object to read, by name or as the object itself;
        `name_of` resolves either.

    Returns
    -------
    str
        The name the result was added under, which is unique
        within the project — a clash gets a numbered suffix.
    """
    source = self.name_of(source)
    return self._derive(source, self[source].compute_cpsds(),
                        f'{source} CPSDs', recipe=('compute_cpsds', {}))
transform
transform(
    source: Any,
    shapes: Any,
    *,
    records: Sequence[int] | None = None,
    name: str | None = None,
) -> str

Physical responses through a shape set to modal responses (Transform to Modal Responses) — q = Φ⁺u for the motions, Φᵀf for the forces, one record per mode and quantity at the modal coordinates M1Mn.

Any set serves: the six rigid-body shapes of a geometry make this the virtual point transformation. The result stands alone in the tree — its DOFs are on no geometry — with its provenance naming both the record and the set, and a transform_report saying what was shared, dropped and left unexplained.

Parameters:

Name Type Description Default
source str or object

The time history, by name or as the object itself; name_of resolves either.

required
shapes str or object

The shape set to transform through, by name or as itself.

required
records sequence of int

Which of the record's channels to carry through — the ones picked in the tree. All of them when omitted.

None
name str

What to call the result. Defaults to the record's name followed by 'Modal Responses'.

None

Returns:

Type Description
str

The name the result was added under, which is unique within the project — a clash gets a numbered suffix.

Source code in src/visualdynamics/project.py
def transform(self, source: Any, shapes: Any, *,
              records: Sequence[int] | None = None,
              name: str | None = None) -> str:
    """Physical responses through a shape set to modal responses
    (Transform to Modal Responses) — `q = Φ⁺u` for the motions,
    `Φᵀf` for the forces, one record per mode and quantity at the
    modal coordinates `M1` … `Mn`.

    Any set serves: the six rigid-body shapes of a geometry make
    this the virtual point transformation. The result stands alone
    in the tree — its DOFs are on no geometry — with its
    provenance naming both the record and the set, and a
    `transform_report` saying what was shared, dropped and left
    unexplained.

    Parameters
    ----------
    source : str or object
        The time history, by name or as the object itself;
        `name_of` resolves either.
    shapes : str or object
        The shape set to transform through, by name or as itself.
    records : sequence of int, optional
        Which of the record's channels to carry through — the ones
        picked in the tree. All of them when omitted.
    name : str, optional
        What to call the result. Defaults to the record's name
        followed by 'Modal Responses'.

    Returns
    -------
    str
        The name the result was added under, which is unique
        within the project — a clash gets a numbered suffix.
    """
    from .core.transform import to_modal

    source, shapes = self.name_of(source), self.name_of(shapes)
    data, shape_set = self._transform_pair(source, shapes)
    picked = None if records is None else [int(i) for i in records]
    result, report = to_modal(data, shape_set, picked)
    result.transform_report = report
    params = {'shapes': shapes}
    if picked is not None:
        params['records'] = picked
    return self._derive(source, result,
                        name or f'{source} Modal Responses',
                        recipe=('transform', params))
expand
expand(
    source: Any,
    shapes: Any,
    *,
    records: Sequence[int] | None = None,
    name: str | None = None,
) -> str

Modal responses back through a shape set to physical responses (Expand to Physical Responses) — u = Φq at every DOF the set covers, linked into the set's group so the result animates on the geometry. A pick of modes expands those modes' contribution alone, and the name says which.

Parameters:

Name Type Description Default
source str or object

The modal time history, by name or as the object itself; name_of resolves either.

required
shapes str or object

The shape set it was transformed through, by name or as itself.

required
records sequence of int

Which modal records to expand — the modes picked in the tree. All of them when omitted.

None
name str

What to call the result. Defaults to the record's name with 'Modal Responses' read as 'Physical Responses', and the modes carried in brackets when they are not all.

None

Returns:

Type Description
str

The name the result was added under, which is unique within the project — a clash gets a numbered suffix.

Source code in src/visualdynamics/project.py
def expand(self, source: Any, shapes: Any, *,
           records: Sequence[int] | None = None,
           name: str | None = None) -> str:
    """Modal responses back through a shape set to physical
    responses (Expand to Physical Responses) — `u = Φq` at every
    DOF the set covers, linked into the set's group so the result
    animates on the geometry. A pick of modes expands those modes'
    contribution alone, and the name says which.

    Parameters
    ----------
    source : str or object
        The modal time history, by name or as the object itself;
        `name_of` resolves either.
    shapes : str or object
        The shape set it was transformed through, by name or as
        itself.
    records : sequence of int, optional
        Which modal records to expand — the modes picked in the
        tree. All of them when omitted.
    name : str, optional
        What to call the result. Defaults to the record's name
        with 'Modal Responses' read as 'Physical Responses', and
        the modes carried in brackets when they are not all.

    Returns
    -------
    str
        The name the result was added under, which is unique
        within the project — a clash gets a numbered suffix.
    """
    from .core.transform import carried_modes, to_physical

    source, shapes = self.name_of(source), self.name_of(shapes)
    data, shape_set = self._transform_pair(source, shapes)
    picked = None if records is None else [int(i) for i in records]
    result, report = to_physical(data, shape_set, picked)
    result.transform_report = report
    params = {'shapes': shapes}
    if picked is not None:
        params['records'] = picked
    if name is None:
        name = _physical_name(source, result)
        carried = carried_modes(report, shape_set)
        if carried:
            name += f' ({", ".join(carried)})'
    # its DOFs are the set's, so it belongs where the set is —
    # with the geometry it animates on — and not with its modal
    # source, which no geometry group can hold
    added = self._derive(source, result, name,
                         recipe=('expand', params), link=False)
    with contextlib.suppress(ValueError):
        self.link(shapes, added)
    return added
author_specification
author_specification(
    source: Any,
    draft: Any,
    *,
    name: str | None = None,
    replace: bool = False,
) -> str

A specification written from a sheet (the Specification reading's Make Specification) — autospectra at breakpoints, every cross term from a stated coherence and phase, bands in decibels — beside the object the sheet was opened on: a shape set (at its modal coordinates, ready to expand through it), a channel table (at its control channels), or a specification. With replace, the sheet is written into the specification it was opened from under its own name, so links and report slots hold — or into several at once, from a sheet that spans them. A sheet holding every channel of the specification rewrites it in the sheet's own form, its breakpoints or its lines becoming the object's; a sheet holding a picked subset merges back at the specification's own lines with the other channels untouched. A pair the sheet leaves unstated is absent from the result — nothing is assumed for it.

Parameters:

Name Type Description Default
source str, object, or list of str

The object the sheet was opened on, by name or as itself; the specifications' names when the sheet spans several.

required
draft SpecificationDraft

What the author stated (core.author). A draft with a pair unstated is refused by name.

required
name str

What to call the result. Defaults to the source's name followed by 'Specification'.

None
replace bool

Write the sheet into source — a specification, or several — in place.

False

Returns:

Type Description
str

The name the result was added under, which is unique within the project — a clash gets a numbered suffix — or the source's own name when replaced (the first of them when several).

Source code in src/visualdynamics/project.py
def author_specification(self, source: Any, draft: Any, *,
                         name: str | None = None,
                         replace: bool = False) -> str:
    """A specification written from a sheet (the Specification
    reading's Make Specification) — autospectra at breakpoints,
    every cross term from a stated coherence and phase, bands in
    decibels — beside the object the sheet was opened on: a shape
    set (at its modal coordinates, ready to expand through it), a
    channel table (at its control channels), or a specification.
    With `replace`, the sheet is written *into* the specification
    it was opened from under its own name, so links and report
    slots hold — or into several at once, from a sheet that spans
    them. A sheet holding every channel of the specification
    rewrites it in the sheet's own form, its breakpoints or its
    lines becoming the object's; a sheet holding a picked subset
    merges back at the specification's own lines with the other
    channels untouched. A pair the sheet leaves unstated is absent
    from the result — nothing is assumed for it.

    Parameters
    ----------
    source : str, object, or list of str
        The object the sheet was opened on, by name or as itself;
        the specifications' names when the sheet spans several.
    draft : SpecificationDraft
        What the author stated (`core.author`). A draft with a
        pair unstated is refused by name.
    name : str, optional
        What to call the result. Defaults to the source's name
        followed by 'Specification'.
    replace : bool, default False
        Write the sheet into `source` — a specification, or
        several — in place.

    Returns
    -------
    str
        The name the result was added under, which is unique
        within the project — a clash gets a numbered suffix — or
        the source's own name when replaced (the first of them
        when several).
    """
    from .core.author import SpecificationDraft

    if isinstance(draft, dict):
        draft = SpecificationDraft.from_dict(draft)
    if replace:
        names = ([self.name_of(s) for s in source]
                 if isinstance(source, (list, tuple))
                 else [self.name_of(source)])
        for each in names:
            if not isinstance(self[each], Specification):
                raise TypeError(f'{each!r} is not a specification to '
                                'replace')
        spanning = len(set(draft.sources)) > 1 or (
            bool(draft.sources) and len(names) > 1)
        for each in names:
            own = draft.for_source(each) if spanning else draft
            if own.sources:
                own = own._copy(sources=[])
            self[each] = _rewritten(self[each], own,
                                    f'edited as a sheet on {each}')
            self.provenance[each] = {
                'verb': 'author_specification', 'source': each,
                'params': {'draft': own.as_dict(), 'into': True},
                'state': None}
        return names[0]
    source = self.name_of(source)
    result = draft.make(f'written on {source}')
    return self._derive(source, result,
                        name or f'{source} Specification',
                        recipe=('author_specification',
                                {'draft': draft.as_dict()}))
generate_rigid_body_modes
generate_rigid_body_modes(
    source: Any, *, name: str | None = None
) -> str

The six rigid-body mode shapes of a geometry (Generate Rigid Body Mode Shapes) — three translations and three rotations about its reference point, as a shape set in the geometry's group.

The point, and the mass and inertia that mass-normalise the set, are the geometry's own mass_properties, set in the rigid-body view. With none set the centroid is adopted, unit shapes about the middle of the model — a real answer, unlike a whole-record truncation, and the one the virtual-point transformation wants most often — and stored, so the staleness fingerprint records what was actually used.

Parameters:

Name Type Description Default
source str or object

The geometry, by name or as the object itself; name_of resolves either.

required
name str

What to call the result. Defaults to the geometry's name followed by 'Rigid Body Modes'.

None

Returns:

Type Description
str

The name the result was added under, which is unique within the project — a clash gets a numbered suffix.

Source code in src/visualdynamics/project.py
def generate_rigid_body_modes(self, source: Any, *,
                              name: str | None = None) -> str:
    """The six rigid-body mode shapes of a geometry (Generate Rigid
    Body Mode Shapes) — three translations and three rotations
    about its reference point, as a shape set in the geometry's
    group.

    The point, and the mass and inertia that mass-normalise the
    set, are the geometry's own `mass_properties`, set in the
    rigid-body view. With none set the centroid is adopted, unit
    shapes about the middle of the model — a real answer, unlike
    a whole-record truncation, and the one the virtual-point
    transformation wants most often — and stored, so the
    staleness fingerprint records what was actually used.

    Parameters
    ----------
    source : str or object
        The geometry, by name or as the object itself; `name_of`
        resolves either.
    name : str, optional
        What to call the result. Defaults to the geometry's name
        followed by 'Rigid Body Modes'.

    Returns
    -------
    str
        The name the result was added under, which is unique
        within the project — a clash gets a numbered suffix.
    """
    from .core.rigid import rigid_body_shapes

    source = self.name_of(source)
    geometry = self[source]
    if not isinstance(geometry, Geometry):
        raise TypeError(f'{source!r} is not a geometry')
    if geometry.mass_properties is None:
        geometry.mass_properties = geometry.suggest_mass_properties()
    return self._derive(
        source, rigid_body_shapes(geometry, geometry.mass_properties),
        name or f'{source} Rigid Body Modes',
        recipe=('generate_rigid_body_modes', {}))
fit_modes
fit_modes(
    source: str,
    *,
    bounds: tuple[float, float] | None = None,
    limit: int = 30,
    name: str | None = None,
    at: Sequence[tuple[float, float]] | None = None,
    refine: int = 0,
) -> str

Fit a modal model to an FRF set (the fitting screen).

The screen's loop, scripted: confirm the suggestion, take the next, limit times. The session's own suggestion logic is the whole judgement — a confirmed peak is spoken for unless the shape standing there is somebody else's — so the loop adds no second opinion. It used to: a proximity guard here vetoed any suggestion within 1 Hz of a confirmed mode, which was the same ridge-trap bandaid the session has since outgrown, and it silently skipped the repeated pair's second tooth that suggest had deliberately offered. On the screen the person stops the loop; scripted, limit is that judgement, and the plate demo's own cap is the worked example of choosing it.

On the screen the equivalent of bounds is the zoom — what is on the plot is what gets searched. A script has no plot, so it says so here.

Parameters:

Name Type Description Default
source str

The FRF set to fit.

required
bounds tuple of float

(low, high) frequency limits to fit within. Defaults to the whole band.

None
limit int

The most modes to accept.

30
name str

What to call the shape set.

None
at sequence of tuple

Explicit (frequency, damping) picks — each optionally (frequency, damping, description) — confirmed in the order given — the residual is peeled sequentially, so the order is part of the fit. This is how an interactive session replays: the fitting screen journals its confirms as exactly this call. bounds and limit are ignored when picks are given.

None
refine int

Times to run the joint residue refinement after the confirms — the screen's Refine All, counted.

0

Returns:

Type Description
str

The name the result was added under, unique within the project — a clash gets a numbered suffix.

Source code in src/visualdynamics/project.py
def fit_modes(self, source: str, *, bounds: tuple[float, float] | None
              = None, limit: int = 30, name: str | None = None,
              at: Sequence[tuple[float, float]] | None = None,
              refine: int = 0) -> str:
    """Fit a modal model to an FRF set (the fitting screen).

    The screen's loop, scripted: confirm the suggestion, take the
    next, `limit` times. The session's own suggestion logic is the
    whole judgement — a confirmed peak is spoken for unless the
    shape standing there is somebody else's — so the loop adds no
    second opinion. It used to: a proximity guard here vetoed any
    suggestion within 1 Hz of a confirmed mode, which was the same
    ridge-trap bandaid the session has since outgrown, and it
    silently skipped the repeated pair's second tooth that
    `suggest` had deliberately offered. On the screen the person
    stops the loop; scripted, `limit` is that judgement, and the
    plate demo's own cap is the worked example of choosing it.

    On the screen the equivalent of `bounds` is the zoom — what is
    on the plot is what gets searched. A script has no plot, so it
    says so here.

    Parameters
    ----------
    source : str
        The FRF set to fit.
    bounds : tuple of float, optional
        (low, high) frequency limits to fit within. Defaults to
        the whole band.
    limit : int, default 30
        The most modes to accept.
    name : str, optional
        What to call the shape set.
    at : sequence of tuple, optional
        Explicit (frequency, damping) picks — each optionally
        (frequency, damping, description) — confirmed in the
        order given — the residual is peeled sequentially, so the
        order is part of the fit. This is how an interactive
        session replays: the fitting screen journals its confirms
        as exactly this call. `bounds` and `limit` are ignored
        when picks are given.
    refine : int, default 0
        Times to run the joint residue refinement after the
        confirms — the screen's Refine All, counted.

    Returns
    -------
    str
        The name the result was added under, unique within
        the project — a clash gets a numbered suffix.
    """
    from .core.modal_fit import ModalFitSession

    source = self.name_of(source)
    session = ModalFitSession(self[source])
    if at is not None:
        for pick in at:
            frequency, damping, *described = pick
            session.confirm(frequency=float(frequency),
                            damping=float(damping),
                            description=(described[0] if described
                                         else None))
    else:
        low, high = bounds or (float(self[source].abscissa[0]),
                               float(self[source].abscissa[-1]))
        session.suggest((low, high))
        for _ in range(limit):
            session.confirm()
            session.suggest((low, high))
    for _ in range(int(refine)):
        session.refine_residues()
    return self._derive(source, session.shape_set(),
                        name or f'{source} Modes')
project_onto_basis
project_onto_basis(
    source: str,
    *,
    onto: str | None = None,
    tolerance: float = 0.02,
    name: str | None = None,
) -> str

A shape set sampled at the Basis set's DOFs (Project onto Basis DOFs): nearest node within tolerance of the basis model's extent, the displacement there dotted with each basis DOF's direction. Returns the new set's name.

Parameters:

Name Type Description Default
source str

The shape set to project.

required
onto str

The basis to project onto. Defaults to the project's basis.

None
tolerance float

The residual a fit may leave.

0.02
name str

What to call the result.

None

Returns:

Type Description
str

The name the result was added under, unique within the project — a clash gets a numbered suffix.

Source code in src/visualdynamics/project.py
def project_onto_basis(self, source: str, *, onto: str | None = None,
                       tolerance: float = 0.02,
                       name: str | None = None) -> str:
    """A shape set sampled at the Basis set's DOFs (Project onto
    Basis DOFs): nearest node within `tolerance` of the basis
    model's extent, the displacement there dotted with each basis
    DOF's direction. Returns the new set's name.

    Parameters
    ----------
    source : str
        The shape set to project.
    onto : str, optional
        The basis to project onto. Defaults to the project's basis.
    tolerance : float, default 0.02
        The residual a fit may leave.
    name : str, optional
        What to call the result.

    Returns
    -------
    str
        The name the result was added under, unique within
        the project — a clash gets a numbered suffix.
    """
    from .core.correlate import project_shapes

    source = self.name_of(source)
    onto = None if onto is None else self.name_of(onto)
    basis = ((onto, self[onto]) if onto is not None
             else self._basis_shapes())
    if basis is None:
        raise ValueError('name the set to project onto, or declare a '
                         'Basis holding one')
    basis_name, basis_shapes = basis
    home = self.geometry_for(basis_name)
    theirs = self.geometry_for(source)
    if home is None or theirs is None:
        raise ValueError('both shape sets need a geometry — link one '
                         'to each side')
    projected, report = project_shapes(self[source], theirs[1],
                                       basis_shapes, home[1],
                                       tolerance=tolerance)
    # how it was made travels with it: matched and dropped counts,
    # the worst distance accepted
    projected.projection_report = report
    return self._derive(basis_name, projected,
                        name or f'{source} @ Basis DOFs')
match_modes
match_modes(
    first: str,
    second: str,
    *,
    pairs: Iterable[tuple[int, int]] | None = None,
    macs: Iterable[float] | None = None,
    threshold: float = 0.7,
    name: str = "Matched Modes",
) -> str

Commit matched mode pairs (the comparison screen's +).

With pairs those pairs exactly; otherwise each of first's modes takes its best partner in second when the MAC clears threshold. Comparing across geometries goes through the projection first, as the screen does.

Parameters:

Name Type Description Default
first str

One shape set, by name.

required
second str

The other shape set, by name.

required
pairs iterable of tuple of int

Explicit (first, second) index pairs, overriding the automatic matching.

None
macs iterable of float

MAC values for those pairs.

None
threshold float

The lowest MAC an automatic pairing may have.

0.7
name str

What to call the result.

'Matched Modes'

Returns:

Type Description
str

The name the result was added under, unique within the project — a clash gets a numbered suffix.

Source code in src/visualdynamics/project.py
def match_modes(self, first: str, second: str, *,
                pairs: Iterable[tuple[int, int]] | None = None,
                macs: Iterable[float] | None = None,
                threshold: float = 0.7,
                name: str = 'Matched Modes') -> str:
    """Commit matched mode pairs (the comparison screen's **+**).

    With `pairs` those pairs exactly; otherwise each of `first`'s
    modes takes its best partner in `second` when the MAC clears
    `threshold`. Comparing across geometries goes through the
    projection first, as the screen does.

    Parameters
    ----------
    first : str
        One shape set, by name.
    second : str
        The other shape set, by name.
    pairs : iterable of tuple of int, optional
        Explicit (first, second) index pairs, overriding the
        automatic matching.
    macs : iterable of float, optional
        MAC values for those pairs.
    threshold : float, default 0.7
        The lowest MAC an automatic pairing may have.
    name : str, default 'Matched Modes'
        What to call the result.

    Returns
    -------
    str
        The name the result was added under, unique within
        the project — a clash gets a numbered suffix.
    """
    import numpy as np

    from .core.matches import MatchedModes

    first, second = self.name_of(first), self.name_of(second)
    if pairs is None or macs is None:
        matrix = self.comparison_mac(first, second)
        if pairs is None:
            pairs = [(row, int(np.argmax(matrix[row])))
                     for row in range(matrix.shape[0])
                     if matrix[row].max() >= threshold]
        pairs = [(int(row), int(column)) for row, column in pairs]
        # the displayed comparison's own values, which a
        # name-matched recompute would not reproduce
        macs = [float(matrix[row, column]) for row, column in pairs]
    pairs = [(int(row), int(column)) for row, column in pairs]
    macs = [float(mac) for mac in macs]
    first_home = self.geometry_for(first)
    second_home = self.geometry_for(second)
    matched = MatchedModes(
        first, second, pairs, macs,
        first_geometry=first_home[0] if first_home else None,
        second_geometry=second_home[0] if second_home else None)
    # Unlinked, unlike every other derived object. A link group is a
    # side of the comparison, and this object *is* the comparison —
    # it names a set on each side, so putting it in one of them
    # claims it belongs to the half it is measuring against the
    # other. It carries its own two geometries (`first_geometry`,
    # `second_geometry`), so it needs no group to find them.
    #
    # Linking it by hand still works, for anyone who wants it
    # bracketed with a side.
    return self.add(name, matched)
comparison_mac
comparison_mac(first: str, second: str) -> ndarray

The MAC between two shape sets as the comparison screen shows it: across geometries the second set is projected onto the first's DOFs, because matching DOF names across geometries would trust them to mean the same directions.

Parameters:

Name Type Description Default
first str

One shape set, by name.

required
second str

The other shape set, by name.

required

Returns:

Type Description
ndarray

The MAC matrix, first's shapes down the rows and second's across the columns.

Source code in src/visualdynamics/project.py
def comparison_mac(self, first: str, second: str) -> np.ndarray:
    """The MAC between two shape sets as the comparison screen
    shows it: across geometries the second set is projected onto
    the first's DOFs, because matching DOF *names* across
    geometries would trust them to mean the same directions.

    Parameters
    ----------
    first : str
        One shape set, by name.
    second : str
        The other shape set, by name.

    Returns
    -------
    numpy.ndarray
        The MAC matrix, first's shapes down the
        rows and second's across the columns.
    """
    from .core.correlate import project_shapes
    from .core.shapes import cross_mac

    first, second = self.name_of(first), self.name_of(second)
    home, theirs = self.geometry_for(first), self.geometry_for(second)
    if (home is None or theirs is None or home[1] is theirs[1]):
        return cross_mac(self[first], self[second])
    projected, _report = project_shapes(self[second], theirs[1],
                                        self[first], home[1])
    return cross_mac(self[first], projected)
plot_mac
plot_mac(
    first: Any, second: Any = None, **kwargs: Any
) -> Any

The MAC picture the comparison screen draws: first against itself, or against second — projected across geometries exactly as comparison_mac does it, which a shape set's own plot_mac cannot, since it compares by DOF name and knows no geometry.

Parameters:

Name Type Description Default
first str or ShapeSet

One shape set, by name or as the object.

required
second str or ShapeSet

The other. Absent, the auto-MAC.

None
**kwargs Any

Passed to the drawing: path= renders to a file, bars=True is the 3-D reading (then screenshot=), theme, title, size, show.

{}

Returns:

Type Description
object

Whatever the drawing returns — a window, an image.

Source code in src/visualdynamics/project.py
def plot_mac(self, first: Any, second: Any = None,
             **kwargs: Any) -> Any:
    """The MAC picture the comparison screen draws: `first`
    against itself, or against `second` — projected across
    geometries exactly as `comparison_mac` does it, which a
    shape set's own `plot_mac` cannot, since it compares by DOF
    name and knows no geometry.

    Parameters
    ----------
    first : str or ShapeSet
        One shape set, by name or as the object.
    second : str or ShapeSet, optional
        The other. Absent, the auto-MAC.
    **kwargs
        Passed to the drawing: `path=` renders to a file,
        `bars=True` is the 3-D reading (then `screenshot=`),
        `theme`, `title`, `size`, `show`.

    Returns
    -------
    object
        Whatever the drawing returns — a window, an image.
    """
    from .plot import plot_mac_matrix

    first = self.name_of(first)
    if second is None:
        return plot_mac_matrix(self[first].frequency,
                               self[first].auto_mac(), **kwargs)
    second = self.name_of(second)
    return plot_mac_matrix(self[first].frequency,
                           self.comparison_mac(first, second),
                           column_frequencies=self[second].frequency,
                           **kwargs)
merge
merge(*names: str, name: str | None = None) -> str

Combine compatible objects into one (Merge).

Same concrete type only, and each kind has its own rule about what may join: geometries need disjoint node ids, shape sets the same DOF cover, data arrays an identical abscissa. The merged object replaces its parts.

Parameters:

Name Type Description Default
*names str

The objects to act on, by name.

()
name str

What to call the merged object.

None

Returns:

Type Description
str

The name the result was added under, unique within the project — a clash gets a numbered suffix.

Source code in src/visualdynamics/project.py
def merge(self, *names: str, name: str | None = None) -> str:
    """Combine compatible objects into one (Merge).

    Same concrete type only, and each kind has its own rule about
    what may join: geometries need disjoint node ids, shape sets
    the same DOF cover, data arrays an identical abscissa. The
    merged object replaces its parts.

    Parameters
    ----------
    *names : str
        The objects to act on, by name.
    name : str, optional
        What to call the merged object.

    Returns
    -------
    str
        The name the result was added under, unique within
        the project — a clash gets a numbered suffix.
    """
    from .core.merge import merge as merge_objects

    names = tuple(self.name_of(n) for n in names)
    merged = merge_objects([self[n] for n in names])
    group = self.group_of(names[0]) or []
    self.remove(*names)
    added = self.add(name or names[0], merged)
    rest = [member for member in group if member in self]
    if rest:
        self.link(added, *rest)
    return added
export
export(
    name: str,
    path: str | PathLike,
    unit_system: Any = None,
    **kwargs: Any,
) -> str

Write an object to a foreign format, chosen by suffix — every registered writer, .unv, .exo, .npz, .bdf, .afu/.ati/.ash, .xlsx, .3mf, .stl, .vdreport and the rest of io.exporters() (Export).

Parameters:

Name Type Description Default
name str

The object to write.

required
path str or PathLike

Where to write it. The format follows the extension.

required
unit_system UnitSystem

Units to write in. Defaults to the project's own.

None
**kwargs Any

Passed through to the exporter.

{}

Returns:

Type Description
str

The path written.

Source code in src/visualdynamics/project.py
def export(self, name: str, path: str | os.PathLike,
           unit_system: Any = None, **kwargs: Any) -> str:
    """Write an object to a foreign format, chosen by suffix —
    every registered writer, `.unv`, `.exo`, `.npz`, `.bdf`,
    `.afu`/`.ati`/`.ash`, `.xlsx`, `.3mf`, `.stl`, `.vdreport` and
    the rest of `io.exporters()` (Export).

    Parameters
    ----------
    name : str
        The object to write.
    path : str or os.PathLike
        Where to write it. The format follows the extension.
    unit_system : UnitSystem, optional
        Units to write in. Defaults to the project's own.
    **kwargs
        Passed through to the exporter.

    Returns
    -------
    str
        The path written.
    """
    from .io import export_file

    name = self.name_of(name)
    export_file(self[name], str(path), unit_system=unit_system,
                **kwargs)
    return str(path)
generate_report
generate_report(
    template: str = "modal", name: str = "Report"
) -> str

Build a report from a starter template, bound symbolically to this project's structure (Generate Report).

Parameters:

Name Type Description Default
template str

Which starter to build: 'modal', 'random', 'shock', 'transient', 'sine', 'sysid' or 'empty' — or a saved template, by the name it was saved under in the templates folder or by the path of a .vdreport file (a report exported on its own; io.report_template).

'modal'
name str

What to call the report object.

'Report'

Returns:

Type Description
str

The name the result was added under, which is unique within the project — a clash gets a numbered suffix.

Source code in src/visualdynamics/project.py
def generate_report(self, template: str = 'modal',
                    name: str = 'Report') -> str:
    """Build a report from a starter template, bound symbolically
    to this project's structure (Generate Report).

    Parameters
    ----------
    template : str, default 'modal'
        Which starter to build: 'modal', 'random', 'shock',
        'transient', 'sine', 'sysid' or 'empty' — or a saved
        template, by the name it was saved under in the templates
        folder or by the path of a `.vdreport` file (a report
        exported on its own; `io.report_template`).
    name : str, default 'Report'
        What to call the report object.

    Returns
    -------
    str
        The name the result was added under, which is unique
        within the project — a clash gets a numbered suffix.
    """
    from .core.report import (
        Report,
        modal_template,
        random_template,
        shock_template,
        sine_template,
        sysid_template,
        transient_template,
    )

    builders = {'modal': modal_template, 'random': random_template,
                'shock': shock_template, 'transient': transient_template,
                'sine': sine_template, 'sysid': sysid_template}
    if template in builders:
        report = builders[template](self, links=self.links)
    elif template == 'empty':
        report = Report('Report')
    else:
        import pathlib

        from .io import report_template

        saved = dict(report_template.saved_templates())
        path = pathlib.Path(saved.get(str(template), str(template)))
        if not (path.name.endswith(report_template.SUFFIX)
                and path.is_file()):
            raise ValueError(
                f'unknown template {template!r}: not a built-in '
                f'({", ".join([*builders, "empty"])}), a saved '
                f'template ({", ".join(saved) or "none saved"}) or a '
                f'{report_template.SUFFIX} file')
        report = report_template.load(path)
    return self.add(name, report)
export_report
export_report(
    name: str, path: str | PathLike, unit_system: Any = None
) -> str

Write a report as one self-contained HTML file (Export).

Parameters:

Name Type Description Default
name str

The report object to render.

required
path str or PathLike

Where to write the self-contained HTML file.

required
unit_system UnitSystem

Units to render in. Defaults to the project's own.

None

Returns:

Type Description
str

The path written.

Source code in src/visualdynamics/project.py
def export_report(self, name: str, path: str | os.PathLike,
                  unit_system: Any = None) -> str:
    """Write a report as one self-contained HTML file (Export).

    Parameters
    ----------
    name : str
        The report object to render.
    path : str or os.PathLike
        Where to write the self-contained HTML file.
    unit_system : UnitSystem, optional
        Units to render in. Defaults to the project's own.

    Returns
    -------
    str
        The path written.
    """
    from .report import render_html

    name = self.name_of(name)
    html = render_html(self[name], self, unit_system, links=self.links)
    path = str(path)
    with open(path, 'w', encoding='utf-8') as out:
        out.write(html)
    return path
table
table(name: Any) -> tuple[list[str], list[list[str]]]

(headers, rows) for an object that reads as a table.

The instrumentation of a channel table, the identified parameters of a shape set — the same rows the report prints, so a script and a report cannot disagree about what is in one.

Parameters:

Name Type Description Default
name str or object

The object to tabulate.

required

Returns:

Type Description
tuple of (list of str, list of list of str)

The column headings and the rows, both as text.

Source code in src/visualdynamics/project.py
def table(self, name: Any) -> tuple[list[str], list[list[str]]]:
    """(headers, rows) for an object that reads as a table.

    The instrumentation of a channel table, the identified
    parameters of a shape set — the same rows the report prints,
    so a script and a report cannot disagree about what is in one.

    Parameters
    ----------
    name : str or object
        The object to tabulate.

    Returns
    -------
    tuple of (list of str, list of list of str)
        The column headings and the rows, both as text.
    """
    from .core.tables import table_of

    name = self.name_of(name)
    built = table_of(self[name])
    if built is None:
        raise ValueError(f'{name} is a {type(self[name]).__name__}, '
                         'which does not read as a table')
    return built
plot
plot(name: str, **kwargs: Any) -> Any

Plot an object the way the GUI plots it: data as curves, a geometry as its scene, a shape set as its auto-MAC.

Parameters:

Name Type Description Default
name str

The object to draw.

required
**kwargs Any

Passed through to the object's own plot method.

{}

Returns:

Type Description
object

Whatever the underlying plot call returns.

Source code in src/visualdynamics/project.py
def plot(self, name: str, **kwargs: Any) -> Any:
    """Plot an object the way the GUI plots it: data as curves, a
    geometry as its scene, a shape set as its auto-MAC.

    Parameters
    ----------
    name : str
        The object to draw.
    **kwargs
        Passed through to the object's own plot method.

    Returns
    -------
    object
        Whatever the underlying plot call returns.
    """
    obj = self[self.name_of(name)]
    plot = getattr(obj, 'plot', None)
    if plot is None:
        raise TypeError(f'{name!r} is a {type(obj).__name__}, which '
                        'has no plot')
    return plot(**kwargs)
animate
animate(name: str, mode: int = 0, **kwargs: Any) -> Any

A mode shape — or a complex spectrum's operating deflection — moving on the geometry it answers to.

Parameters:

Name Type Description Default
name str

The shape set to animate.

required
mode int

Which mode, by index.

0
**kwargs Any

Passed through to the scene.

{}

Returns:

Type Description
object

The plotter the animation is running in.

Source code in src/visualdynamics/project.py
def animate(self, name: str, mode: int = 0, **kwargs: Any) -> Any:
    """A mode shape — or a complex spectrum's operating deflection —
    moving on the geometry it answers to.

    Parameters
    ----------
    name : str
        The shape set to animate.
    mode : int, default 0
        Which mode, by index.
    **kwargs
        Passed through to the scene.

    Returns
    -------
    object
        The plotter the animation is running in.
    """
    name = self.name_of(name)
    home = self.geometry_for(name)
    if home is None:
        raise ValueError(f'{name!r} has no geometry — link one')
    target = self[name]
    if isinstance(target, ShapeSet):
        return target.animate(home[1], mode, **kwargs)
    # spectra pick a line by `frequency=`, not a mode number, and a
    # positional 0 must not read as 0 Hz
    return target.animate(home[1], **kwargs)
name_of
name_of(target: Any) -> str

The name an object goes by here; a name passes through.

Verbs take either, so project.compute_psds('Time History') and project.compute_psds(project.basis.time_history) are the same call.

Parameters:

Name Type Description Default
target str or object

A name, or an object the project holds.

required

Returns:

Type Description
str

The name it is stored under.

Source code in src/visualdynamics/project.py
def name_of(self, target: Any) -> str:
    """The name an object goes by here; a name passes through.

    Verbs take either, so `project.compute_psds('Time History')`
    and `project.compute_psds(project.basis.time_history)` are the
    same call.

    Parameters
    ----------
    target : str or object
        A name, or an object the project holds.

    Returns
    -------
    str
        The name it is stored under.
    """
    if isinstance(target, str):
        return target
    for name, obj in self.items():
        if obj is target:
            return name
    raise ValueError(f'that {type(target).__name__} is not in this '
                     f'project — add it first')
extract_sine
extract_sine(
    source: Any, specification: Any = None
) -> list[str]

Each specification tone's level, read out of a recording (Extract Sine Levels) — one object per tone, because each tone sweeps its own frequencies on its own clock.

The specification is found in the project when not named — the one SineSweepSpecification there is — and each result is linked to the recording it was read from.

Parameters:

Name Type Description Default
source str or object

The sine level set or run to read.

required
specification str or object

The sweep specification to extract against. Defaults to the project's own, when it holds exactly one.

None

Returns:

Type Description
list of str

The names of the levels added, one per tone.

Source code in src/visualdynamics/project.py
def extract_sine(self, source: Any,
                 specification: Any = None) -> list[str]:
    """Each specification tone's level, read out of a recording
    (Extract Sine Levels) — one object per tone, because each tone
    sweeps its own frequencies on its own clock.

    The specification is found in the project when not named —
    the one SineSweepSpecification there is — and each result is
    linked to the recording it was read from.

    Parameters
    ----------
    source : str or object
        The sine level set or run to read.
    specification : str or object, optional
        The sweep specification to extract against. Defaults to
        the project's own, when it holds exactly one.

    Returns
    -------
    list of str
        The names of the levels added, one per tone.
    """
    from .core.sine import extract_sine

    source = self.name_of(source)
    if specification is None:
        spec = self.sine_sweep_specification
    else:
        spec = self[self.name_of(specification)]
    levels = extract_sine(self[source], spec)
    return [self._derive(source, levels, 'Sine Levels')]
stale
stale() -> dict[str, str]

{derived name: why} for everything whose source's settings have moved since it was computed.

A missing source, or a derivation this bookkeeping predates, answers nothing — absence of evidence is not staleness.

Source code in src/visualdynamics/project.py
def stale(self) -> dict[str, str]:
    """{derived name: why} for everything whose source's settings
    have moved since it was computed.

    A missing source, or a derivation this bookkeeping predates,
    answers nothing — absence of evidence is not staleness.
    """
    out = {}
    for name, record in self.provenance.items():
        if name not in self or record.get('source') not in self:
            continue
        now = self._analysis_state(record['source'], record['verb'],
                                   record.get('params'))
        was = record.get('state')
        if was is None or now is None:
            continue
        if _state_tuple(now) != _state_tuple(was):
            out[name] = _state_story(was, now)
    return out
refresh
refresh(name: Any) -> str

Recompute a derived object in place, under its own name.

The links, the report's bindings and the grids all key on the name, so replacing the value under it is what keeps every reference honest. Anything derived from this object goes stale by content, which is the cascade — refreshed one badge at a time, or all at once, but always by a person.

Parameters:

Name Type Description Default
name str or object

The derived object to recompute, in place and under its own name.

required

Returns:

Type Description
str

The name refreshed.

Source code in src/visualdynamics/project.py
def refresh(self, name: Any) -> str:
    """Recompute a derived object in place, under its own name.

    The links, the report's bindings and the grids all key on the
    name, so replacing the value under it is what keeps every
    reference honest. Anything derived from *this* object goes
    stale by content, which is the cascade — refreshed one badge
    at a time, or all at once, but always by a person.

    Parameters
    ----------
    name : str or object
        The derived object to recompute, in place and under
        its own name.

    Returns
    -------
    str
        The name refreshed.
    """
    name = self.name_of(name)
    record = self.provenance.get(name)
    if record is None:
        raise ValueError(f'{name!r} records no derivation to re-run')
    source = record['source']
    if source not in self:
        raise ValueError(
            f"{name!r} was computed from {source!r}, which is gone")
    verb, params = record['verb'], record.get('params', {})
    rebuilt = _RECOMPUTE[verb](self, source, params)
    self[name] = rebuilt
    record['state'] = self._analysis_state(source, verb, params)
    return name
refresh_stale
refresh_stale() -> list[str]

Refresh everything stale, sources before their dependents, until nothing is — the project row's one click.

Source code in src/visualdynamics/project.py
def refresh_stale(self) -> list[str]:
    """Refresh everything stale, sources before their dependents,
    until nothing is — the project row's one click."""
    done: list[str] = []
    # bounded: each pass refreshes at least one or stops, and a
    # refresh can only newly stale things derived from it
    for _ in range(len(self.provenance) + 1):
        waiting = self.stale()
        if not waiting:
            break
        for name in self.ordered_names():
            if name in waiting:
                done.append(self.refresh(name))
    return done
save
save(path: str | PathLike) -> str

Write the whole project to one .vdyn file.

Parameters:

Name Type Description Default
path str or PathLike

Where to write the .vdyn file.

required

Returns:

Type Description
str

The path written.

Source code in src/visualdynamics/project.py
def save(self, path: str | os.PathLike) -> str:
    """Write the whole project to one .vdyn file.

    Parameters
    ----------
    path : str or os.PathLike
        Where to write the `.vdyn` file.

    Returns
    -------
    str
        The path written.
    """
    from .io import save_test

    save_test(str(path), self.name, dict(self),
              active_geometry=self.active_geometry,
              project_type=self.project_type, links=self.links,
              provenance=self.provenance)
    return str(path)
open classmethod
open(path: str | PathLike) -> Project

Read a .vdyn project back.

Source code in src/visualdynamics/project.py
@classmethod
def open(cls, path: str | os.PathLike) -> Project:
    """Read a .vdyn project back."""
    from .io import load

    loaded = load(str(path))
    if isinstance(loaded, Project):
        # whatever the loader did on the way — construct, add,
        # link — the session's story starts here: one line that
        # reproduces this state exactly
        loaded.journal = [
            f'project = visualdynamics.Project.open({str(path)!r})']
        return loaded
    raise ValueError(f'{path} holds a single object, not a project')
journal_as
journal_as(line: str | None)

Record a stretch of front-end work as one replaying line.

The GUI imports a file by building the objects itself and adding them one by one; journalled verb by verb, that stretch is a pile of not-replayable comments — when the honest record is the single import_file call a script would make. Inside the stretch every verb stays quiet, exactly as verbs nested in verbs do; the line lands only when the stretch succeeds.

Parameters:

Name Type Description Default
line str or None

The line that replays the stretch — None to record nothing at all.

required

Returns:

Type Description
None
Source code in src/visualdynamics/project.py
@contextlib.contextmanager
def journal_as(self, line: str | None):
    """Record a stretch of front-end work as one replaying line.

    The GUI imports a file by building the objects itself and
    adding them one by one; journalled verb by verb, that stretch
    is a pile of not-replayable comments — when the honest record
    is the single `import_file` call a script would make. Inside
    the stretch every verb stays quiet, exactly as verbs nested in
    verbs do; the line lands only when the stretch succeeds.

    Parameters
    ----------
    line : str or None
        The line that replays the stretch — None to record
        nothing at all.

    Returns
    -------
    None
    """
    self._journal_depth += 1
    try:
        yield
    finally:
        self._journal_depth -= 1
    if line is not None:
        self.journal.append(line)
record_setting
record_setting(
    target: Any, attribute: str, value: Any
) -> None

A settings write, journalled the way a script would make it.

The front ends' funnel: the GUI stores analysis settings by assignment — a dragged averaging span, a filter corner, the shock windows — and those writes are session acts as much as any verb. A repeated write to the same slot replaces its own last line, so a session of nudging settles to the one assignment that stands rather than a line per keystroke.

Parameters:

Name Type Description Default
target str or object

The object written to, by name or as itself.

required
attribute str

Which settings attribute was stored.

required
value Any

What was stored; its repr must rebuild it, which every settings dataclass here guarantees.

required

Returns:

Type Description
None
Source code in src/visualdynamics/project.py
def record_setting(self, target: Any, attribute: str,
                   value: Any) -> None:
    """A settings write, journalled the way a script would make it.

    The front ends' funnel: the GUI stores analysis settings by
    assignment — a dragged averaging span, a filter corner, the
    shock windows — and those writes are session acts as much as
    any verb. A repeated write to the same slot replaces its own
    last line, so a session of nudging settles to the one
    assignment that stands rather than a line per keystroke.

    Parameters
    ----------
    target : str or object
        The object written to, by name or as itself.
    attribute : str
        Which settings attribute was stored.
    value : Any
        What was stored; its repr must rebuild it, which every
        settings dataclass here guarantees.

    Returns
    -------
    None
    """
    try:
        name = self.name_of(target)
    except (KeyError, ValueError):
        return                     # not (or no longer) in the project
    prefix = f'project[{name!r}].{attribute} = '
    line = prefix + repr(value)
    if self.journal and self.journal[-1].startswith(prefix):
        self.journal[-1] = line
    else:
        self.journal.append(line)
record_call
record_call(
    target: Any, method: str, *args: Any, **kwargs: Any
) -> None

A method call on an object, journalled as a script makes it.

The front ends' funnel for object verbs that are not Project verbs — a traceline added to a geometry, a photo renamed — each an act of the session the console must speak (Brandon, 2026-08-30: adding a traceline said nothing).

Parameters:

Name Type Description Default
target str or object

The object acted on, by name or as itself.

required
method str

The method a script would call.

required
*args Any

The call's arguments; their reprs must rebuild them.

()
**kwargs Any

Keyword arguments, same rule.

{}

Returns:

Type Description
None
Source code in src/visualdynamics/project.py
def record_call(self, target: Any, method: str, *args: Any,
                **kwargs: Any) -> None:
    """A method call on an object, journalled as a script makes it.

    The front ends' funnel for object verbs that are not Project
    verbs — a traceline added to a geometry, a photo renamed —
    each an act of the session the console must speak (Brandon,
    2026-08-30: adding a traceline said nothing).

    Parameters
    ----------
    target : str or object
        The object acted on, by name or as itself.
    method : str
        The method a script would call.
    *args : Any
        The call's arguments; their reprs must rebuild them.
    **kwargs : Any
        Keyword arguments, same rule.

    Returns
    -------
    None
    """
    try:
        name = self.name_of(target)
    except (KeyError, ValueError):
        return
    shown = [self._journal_arg(a) for a in args]
    shown += [f'{key}={self._journal_arg(value)}'
              for key, value in kwargs.items()]
    self.journal.append(
        f'project[{name!r}].{method}({", ".join(shown)})')
session_script
session_script() -> str

This sitting's acts as a runnable Python script.

The journal joined under its imports: every verb that ran and every setting stored — clicked in the GUI or called from a script — recorded as the line that reproduces it, so a session worked up by hand can be replayed, adapted, or kept. Reads and refusals are absent on purpose: the script is what happened to the project, and a verb that raised changed nothing.

Returns:

Type Description
str

A Python script; running it rebuilds this session's project from the same inputs.

Source code in src/visualdynamics/project.py
def session_script(self) -> str:
    """This sitting's acts as a runnable Python script.

    The journal joined under its imports: every verb that ran and
    every setting stored — clicked in the GUI or called from a
    script — recorded as the line that reproduces it, so a session
    worked up by hand can be replayed, adapted, or kept. Reads and
    refusals are absent on purpose: the script is what *happened
    to the project*, and a verb that raised changed nothing.

    Returns
    -------
    str
        A Python script; running it rebuilds this session's
        project from the same inputs.
    """
    head = ['import visualdynamics']
    head += [imported for token, imported in self._SCRIPT_IMPORTS
             if any(token in line for line in self.journal)]
    return '\n'.join([*head, '', *self.journal])

UnitsRequired

UnitsRequired(
    message: str, required: Sequence[str] = ("length_unit",)
)

Bases: UnitError

Raised when an operation needs units that have not been defined.

Source code in src/visualdynamics/units.py
def __init__(self, message: str,
             required: Sequence[str] = ('length_unit',)) -> None:
    super().__init__(message)
    self.required: tuple[str, ...] = tuple(required)

UnitSystem dataclass

UnitSystem(
    name: str,
    units: dict = dict(),
    base: UnitSystem | None = None,
)

A named mapping of dimension -> display unit.

base is the coherent system this one came from — the same object for a coherent system, and the parent for one carrying display-only overrides such as accelerations in g. Exports use it, because no foreign format can record "g" as a unit.

Methods:

Name Description
unit

Display unit for a dimension or dimension expression.

transform

(scale, offset) converting a display value to SI.

factor

SI-per-display-unit scale (offset-free dimensions only).

from_si

Convert SI values to this system's display unit for dimension.

to_si

Convert values from this system's units into SI.

label

Display unit text; empty when the dimension is undefined.

label_text

Display unit for plain-text output, with real exponents: in/s².

label_ascii

Display unit in plain ASCII, exponents as carets: in/s^2.

label_html

Display unit as HTML — a stacked fraction when it has one.

with_units

A copy of this system with per-dimension unit overrides.

Attributes:

Name Type Description
coherent UnitSystem

This system with display-only overrides stripped.

Attributes
coherent property
coherent: UnitSystem

This system with display-only overrides stripped.

Methods:
unit
unit(dimension: str) -> str

Display unit for a dimension or dimension expression.

Expressions compose from the base units: with in-lbf-s, 'acceleration/force' -> '(in/s**2)/lbf'.

Parameters:

Name Type Description Default
dimension str

A dimension tag, such as 'acceleration' or 'force'.

required

Returns:

Type Description
str

The unit string for this dimension.

Source code in src/visualdynamics/units.py
def unit(self, dimension: str) -> str:
    """Display unit for a dimension or dimension expression.

    Expressions compose from the base units: with in-lbf-s,
    'acceleration/force' -> '(in/s**2)/lbf'.

    Parameters
    ----------
    dimension : str
        A dimension tag, such as 'acceleration' or 'force'.

    Returns
    -------
    str
        The unit string for this dimension.
    """
    if dimension == UNKNOWN:
        return ''
    if dimension in self.units:
        return self.units[dimension]
    numerator, denominator = [], []
    for name, power in parse_dimension(dimension):
        unit = self.units.get(name)
        if unit is None:
            raise UnitError(
                f"Unit system {self.name!r} has no unit for dimension {name!r}")
        label = f'({unit})' if ('/' in unit or '*' in unit) else unit
        if abs(power) != 1:
            label += f'**{abs(power)}'
        (numerator if power > 0 else denominator).append(label)
    out = '*'.join(numerator) if numerator else '1'
    for label in denominator:
        out += f'/{label}'
    return out
transform
transform(dimension: str) -> tuple[float, float]

(scale, offset) converting a display value to SI.

Parameters:

Name Type Description Default
dimension str

A dimension tag, such as 'acceleration' or 'force'.

required

Returns:

Type Description
tuple of (float, float)

The scale and offset taking SI to display — an offset matters for temperature and nothing else.

Source code in src/visualdynamics/units.py
def transform(self, dimension: str) -> tuple[float, float]:
    """(scale, offset) converting a display value to SI.

    Parameters
    ----------
    dimension : str
        A dimension tag, such as 'acceleration' or 'force'.

    Returns
    -------
    tuple of (float, float)
        The scale and offset taking SI to display —
        an offset matters for temperature and nothing else.
    """
    if dimension == UNKNOWN:
        return 1.0, 0.0
    if dimension in self.units:
        return si_transform(self.units[dimension], dimension)
    scale = 1.0
    for name, power in parse_dimension(dimension):
        part_scale, part_offset = si_transform(self.units[name], name)
        if part_offset:
            raise UnitError(
                f"Dimension {dimension!r} combines the offset unit "
                f"{self.units[name]!r}; use an absolute unit instead")
        scale *= part_scale ** power
    return scale, 0.0
factor
factor(dimension: str) -> float

SI-per-display-unit scale (offset-free dimensions only).

Parameters:

Name Type Description Default
dimension str

A dimension tag, such as 'acceleration' or 'force'.

required

Returns:

Type Description
float

What an SI value is multiplied by to display it.

Source code in src/visualdynamics/units.py
def factor(self, dimension: str) -> float:
    """SI-per-display-unit scale (offset-free dimensions only).

    Parameters
    ----------
    dimension : str
        A dimension tag, such as 'acceleration' or 'force'.

    Returns
    -------
    float
        What an SI value is multiplied by to display it.
    """
    scale, offset = self.transform(dimension)
    if offset:
        raise UnitError(
            f"Display unit for {dimension!r} has an offset; use "
            "from_si()/to_si()")
    return scale
from_si
from_si(values: ArrayLike, dimension: str) -> Any

Convert SI values to this system's display unit for dimension.

Parameters:

Name Type Description Default
values array_like

Values in SI.

required
dimension str

A dimension tag, such as 'acceleration' or 'force'.

required

Returns:

Type Description
array_like

The same values in this system's units.

Source code in src/visualdynamics/units.py
def from_si(self, values: ArrayLike, dimension: str) -> Any:
    """Convert SI values to this system's display unit for `dimension`.

    Parameters
    ----------
    values : array_like
        Values in SI.
    dimension : str
        A dimension tag, such as 'acceleration' or 'force'.

    Returns
    -------
    array_like
        The same values in this system's units.
    """
    scale, offset = self.transform(dimension)
    return (values - offset) / scale
to_si
to_si(values: ArrayLike, dimension: str) -> Any

Convert values from this system's units into SI.

Parameters:

Name Type Description Default
values array_like

Values in this system's units.

required
dimension str

A dimension tag, such as 'acceleration'.

required

Returns:

Type Description
array_like

The same values in SI.

Source code in src/visualdynamics/units.py
def to_si(self, values: ArrayLike, dimension: str) -> Any:
    """Convert values from this system's units into SI.

    Parameters
    ----------
    values : array_like
        Values in this system's units.
    dimension : str
        A dimension tag, such as 'acceleration'.

    Returns
    -------
    array_like
        The same values in SI.
    """
    scale, offset = self.transform(dimension)
    return values * scale + offset
label
label(dimension: str) -> str

Display unit text; empty when the dimension is undefined.

Parameters:

Name Type Description Default
dimension str

A dimension tag, such as 'acceleration' or 'force'.

required

Returns:

Type Description
str

The unit's name in this system.

Source code in src/visualdynamics/units.py
def label(self, dimension: str) -> str:
    """Display unit text; empty when the dimension is undefined.

    Parameters
    ----------
    dimension : str
        A dimension tag, such as 'acceleration' or 'force'.

    Returns
    -------
    str
        The unit's name in this system.
    """
    return self.unit(dimension)
label_text
label_text(dimension: str) -> str

Display unit for plain-text output, with real exponents: in/s².

Parameters:

Name Type Description Default
dimension str

A dimension tag, such as 'acceleration' or 'force'.

required

Returns:

Type Description
str

The label as plain text.

Source code in src/visualdynamics/units.py
def label_text(self, dimension: str) -> str:
    """Display unit for plain-text output, with real exponents: in/s².

    Parameters
    ----------
    dimension : str
        A dimension tag, such as 'acceleration' or 'force'.

    Returns
    -------
    str
        The label as plain text.
    """
    return self._slash_label(
        dimension, _exponents_to_unicode,
        lambda text, power: text + _superscript(str(power)))
label_ascii
label_ascii(dimension: str) -> str

Display unit in plain ASCII, exponents as carets: in/s^2.

For renderers that quietly drop what they cannot draw: VTK's 3-D axis titles lose unicode superscripts in every text mode, so label_text's (in/s²)²/Hz read (in/s)/Hz off the waterfall — wrong by two squarings, with nothing saying so.

Parameters:

Name Type Description Default
dimension str

A dimension tag, such as 'acceleration' or 'force'.

required

Returns:

Type Description
str

The label with no unicode, for renderers that drop it.

Source code in src/visualdynamics/units.py
def label_ascii(self, dimension: str) -> str:
    """Display unit in plain ASCII, exponents as carets: in/s^2.

    For renderers that quietly drop what they cannot draw: VTK's
    3-D axis titles lose unicode superscripts in every text mode,
    so `label_text`'s (in/s²)²/Hz read (in/s)/Hz off the waterfall
    — wrong by two squarings, with nothing saying so.

    Parameters
    ----------
    dimension : str
        A dimension tag, such as 'acceleration' or 'force'.

    Returns
    -------
    str
        The label with no unicode, for renderers that drop it.
    """
    return self._slash_label(dimension, _exponents_to_ascii,
                             lambda text, power: f'{text}^{power}')
label_html
label_html(dimension: str) -> str

Display unit as HTML — a stacked fraction when it has one.

Parameters:

Name Type Description Default
dimension str

A dimension tag, such as 'acceleration' or 'force'.

required

Returns:

Type Description
str

The label with HTML superscripts.

Source code in src/visualdynamics/units.py
def label_html(self, dimension: str) -> str:
    """Display unit as HTML — a stacked fraction when it has one.

    Parameters
    ----------
    dimension : str
        A dimension tag, such as 'acceleration' or 'force'.

    Returns
    -------
    str
        The label with HTML superscripts.
    """
    parts = self._unit_parts(dimension)
    if not parts:
        return ''
    numerator, denominator = _render_parts(
        parts, _exponents_to_html,
        lambda text, power: f'{text}<sup>{power}</sup>')
    numerator, denominator = numerator[0] or '1', denominator[0]
    if not denominator:
        return numerator
    return (
        '<table cellpadding="0" cellspacing="0" style="display:inline">'
        f'<tr><td align="center" style="border-bottom:1px solid">'
        f'{numerator}</td></tr>'
        f'<tr><td align="center">{denominator}</td></tr></table>')
with_units
with_units(
    name: str | None = None, **overrides
) -> UnitSystem

A copy of this system with per-dimension unit overrides.

Example: IN_LBF_S.with_units(acceleration='g') displays acceleration in g while everything else stays inch-pound-second.

Parameters:

Name Type Description Default
name str

What to call the derived system.

None
**overrides

Dimension/unit pairs to change.

{}

Returns:

Type Description
UnitSystem

A copy with those units replaced.

Source code in src/visualdynamics/units.py
def with_units(self, name: str | None = None, **overrides) -> UnitSystem:
    """A copy of this system with per-dimension unit overrides.

    Example: IN_LBF_S.with_units(acceleration='g') displays
    acceleration in g while everything else stays inch-pound-second.

    Parameters
    ----------
    name : str, optional
        What to call the derived system.
    **overrides
        Dimension/unit pairs to change.

    Returns
    -------
    UnitSystem
        A copy with those units replaced.
    """
    for dimension, unit in overrides.items():
        si_transform(unit, dimension)  # validates dimension and unit
    return UnitSystem(name or f'{self.name} (custom)',
                      {**self.units, **overrides}, base=self.coherent)

Functions:

check_compatibility

check_compatibility(
    objects: Mapping[str, Any],
    geometry_name: str | None = None,
    links: Sequence[Mapping[str, Any]] | None = None,
) -> Report

Check every object in a test against the geometry it answers to.

objects maps name -> object. An object linked into a group that holds a geometry is judged against that geometry — a FEM shape set beside its own FEM mesh is consistent, whichever geometry is active. Everything else is judged against geometry_name (the active geometry; without it the first geometry found). Returns a Report.

Source code in src/visualdynamics/compatibility.py
def check_compatibility(objects: Mapping[str, Any],
                        geometry_name: str | None = None,
                        links: Sequence[Mapping[str, Any]] | None = None
                        ) -> Report:
    """Check every object in a test against the geometry it answers to.

    `objects` maps name -> object. An object linked into a group that
    holds a geometry is judged against *that* geometry — a FEM shape
    set beside its own FEM mesh is consistent, whichever geometry is
    active. Everything else is judged against `geometry_name` (the
    active geometry; without it the first geometry found). Returns a
    Report.
    """
    from .core.geometry import Geometry

    geometries = {name: obj for name, obj in objects.items()
                  if isinstance(obj, Geometry)}
    if geometry_name not in geometries:
        geometry_name = next(iter(geometries), None)

    def home(name: str) -> str | None:
        for group in links or []:
            if name in group.get('members', ()):
                linked = next((member for member in group['members']
                               if member in geometries), None)
                if linked is not None:
                    return linked
                break
        return geometry_name

    def companions(name: str) -> Mapping[str, Any]:
        # a modal object answers to its group's shape sets, or to any
        # in the project while it is not yet linked
        for group in links or []:
            if name in group.get('members', ()):
                return {member: objects[member] for member in group['members']
                        if member in objects}
        return objects

    report = Report(geometry_name=geometry_name)
    for name, obj in objects.items():
        if modal_object(obj) is not None:
            issue = check_modal(name, obj, companions(name))
        elif geometry_name is None:
            continue
        else:
            against = home(name)
            issue = check_object(name, obj, geometries.get(against), against)
        if issue is not None:
            report.issues[name] = issue
    return report

frequency_axis

frequency_axis(mode: str | bool | None = ...) -> str

Read or set how frequency axes are drawn: 'log', 'linear', or 'default' (each kind of data by its own convention).

Parameters:

Name Type Description Default
mode ('log', 'linear', 'default')

The choice to make. Omitted, the current one is read back. True and False stand for 'log' and 'linear'; None for 'default'.

'log'

Returns:

Type Description
str

The choice in force after the call.

Source code in src/visualdynamics/core/data.py
def frequency_axis(mode: str | bool | None = ...) -> str:
    """Read or set how frequency axes are drawn: 'log', 'linear', or
    'default' (each kind of data by its own convention).

    Parameters
    ----------
    mode : {'log', 'linear', 'default'} or bool or None, optional
        The choice to make. Omitted, the current one is read back.
        True and False stand for 'log' and 'linear'; None for
        'default'.

    Returns
    -------
    str
        The choice in force after the call.
    """
    global _FREQUENCY_AXIS
    if mode is not ...:
        if mode in ('default', None):
            _FREQUENCY_AXIS = None
        elif mode in ('log', True):
            _FREQUENCY_AXIS = True
        elif mode in ('linear', False):
            _FREQUENCY_AXIS = False
        else:
            raise ValueError(f"frequency axis is 'log', 'linear' or "
                             f"'default', not {mode!r}")
    return {None: 'default', True: 'log', False: 'linear'}[_FREQUENCY_AXIS]

export_file

export_file(
    obj: Any,
    path: str | PathLike,
    format: str | None = None,
    unit_system: UnitSystem | None = None,
    **kwargs: Any,
) -> None

Write obj to a foreign format, chosen by name or by suffix.

unit_system is the system to write in; without one the stored values go out as they are. Raises ValueError naming what the object can be written as, since "cannot export" is nearly always a question of which format.

Source code in src/visualdynamics/io/exporters.py
def export_file(obj: Any, path: str | os.PathLike, format: str | None = None,
                unit_system: UnitSystem | None = None,
                **kwargs: Any) -> None:
    """Write `obj` to a foreign format, chosen by name or by suffix.

    `unit_system` is the system to write in; without one the stored values
    go out as they are. Raises ValueError naming what the object *can* be
    written as, since "cannot export" is nearly always a question of which
    format.
    """
    path = str(path)
    available = exporters(obj)
    if format is not None:
        for exporter in _EXPORTERS:
            if exporter.name == format:
                if not exporter.handles(obj):
                    raise ValueError(
                        f'{exporter.name} cannot write {type(obj).__name__}; '
                        f'it takes {[e.name for e in available]}')
                _refuse_modal(exporter, obj)
                return exporter.save(obj, path, unit_system=unit_system,
                                     **kwargs)
        raise ValueError(f'No exporter named {format!r}; '
                         f'available: {[e.name for e in _EXPORTERS]}')
    for exporter in available:
        if path.endswith(exporter.suffix):
            _refuse_modal(exporter, obj)
            return exporter.save(obj, path, unit_system=unit_system,
                                 **kwargs)
    raise ValueError(
        f'Nothing writes {type(obj).__name__} to {path}; it can be written '
        f'as {[(e.name, e.suffix) for e in available]}')

from_sep005

from_sep005(
    timeseries: dict[str, Any] | list[dict[str, Any]],
) -> Any

SEP 005 timeseries into TimeHistory objects.

history = visualdynamics.from_sep005({'data': y, 'fs': 256.0,
                                      'name': 'run 4',
                                      'unit_str': 'm/s²'})

One dict returns one TimeHistory; a list — the standard's form for several series — returns {name: TimeHistory}, numbering a repeated name the way the project tree would.

unit_str entries that parse are declared on the object (values converted to SI, exactly as define_units would), because the producer stated them; one that does not parse leaves that channel's values raw with the claim kept in dimension_hint, where quantity also lands when there is no unit at all. Nothing is ever scaled by a guess.

Refused, with the reason: a series with no data, with neither fs nor time, a time vector of the wrong length, or a channel_name list that does not match the channel count.

Source code in src/visualdynamics/io/sep005.py
def from_sep005(timeseries: dict[str, Any] | list[dict[str, Any]]
                ) -> Any:
    """SEP 005 timeseries into `TimeHistory` objects.

        history = visualdynamics.from_sep005({'data': y, 'fs': 256.0,
                                              'name': 'run 4',
                                              'unit_str': 'm/s²'})

    One dict returns one `TimeHistory`; a list — the standard's form
    for several series — returns ``{name: TimeHistory}``, numbering a
    repeated name the way the project tree would.

    ``unit_str`` entries that parse are *declared* on the object
    (values converted to SI, exactly as `define_units` would), because
    the producer stated them; one that does not parse leaves that
    channel's values raw with the claim kept in `dimension_hint`, where
    ``quantity`` also lands when there is no unit at all. Nothing is
    ever scaled by a guess.

    Refused, with the reason: a series with no ``data``, with neither
    ``fs`` nor ``time``, a ``time`` vector of the wrong length, or a
    ``channel_name`` list that does not match the channel count.
    """
    if isinstance(timeseries, dict):
        return _one(timeseries)
    out: dict[str, Any] = {}
    for series in timeseries:
        name = str(series.get('name', 'Time History')) or 'Time History'
        unique, n = name, 1
        while unique in out:
            n += 1
            unique = f'{name} ({n})'
        out[unique] = _one(series)
    return out

import_file

import_file(
    path: str | PathLike,
    format: str | None = None,
    progress: Any | None = None,
    **kwargs: Any,
) -> Any

Import a foreign file, returning the visualdynamics object it contains.

Units may be declared here (e.g. length_unit='m') for sources that do not carry them; without a declaration the object imports unit-less, holding the file's raw values until define_units() is called. format forces a specific importer by name. progress is a (done, total) callable, honoured where the reader can count — a project file's objects — and quietly unused where it cannot: a foreign file is one read, and nothing inside netCDF or UFF parsing reports fractions worth relaying.

Source code in src/visualdynamics/io/__init__.py
def import_file(path: str | os.PathLike, format: str | None = None,
                progress: Any | None = None, **kwargs: Any) -> Any:
    """Import a foreign file, returning the visualdynamics object it contains.

    Units may be declared here (e.g. length_unit='m') for sources that do not
    carry them; without a declaration the object imports unit-less, holding
    the file's raw values until `define_units()` is called.
    `format` forces a specific importer by name. `progress` is a
    (done, total) callable, honoured where the reader can count — a
    project file's objects — and quietly unused where it cannot: a
    foreign file is one read, and nothing inside netCDF or UFF parsing
    reports fractions worth relaying.
    """
    path = str(path)
    if path.endswith('.vdyn'):
        return load(path, progress=progress)
    if format is not None:
        for imp in _IMPORTERS:
            if imp.name == format:
                return imp.load(path, **kwargs)
        raise ValueError(f"No importer named {format!r}; "
                         f"available: {[i.name for i in _IMPORTERS]}")
    for imp in _IMPORTERS:
        if imp.sniff(path):
            return imp.load(path, **kwargs)
    raise ValueError(f"No importer recognizes {path}")

importers

importers() -> list[Importer]

Every format visualdynamics can read, in the order they are tried.

Source code in src/visualdynamics/io/__init__.py
def importers() -> list[Importer]:
    """Every format visualdynamics can read, in the order they are tried."""
    return list(_IMPORTERS)

load

load(
    path: str | PathLike,
    progress: Callable[[int, int], None] | None = None,
) -> Any

Load a .vdyn file: the object it contains, or a whole test.

progress is called as (objects loaded, objects in the file) — once up front with 0 and once per object — because a project file is minutes of someone's day and the reader is the only thing that knows how far along it is. A single-object file reports nothing: one object is one step, and a bar with one step is a light bulb.

Source code in src/visualdynamics/io/native.py
def load(path: str | os.PathLike,
         progress: Callable[[int, int], None] | None = None) -> Any:
    """Load a .vdyn file: the object it contains, or a whole test.

    `progress` is called as (objects loaded, objects in the file) —
    once up front with 0 and once per object — because a project file
    is minutes of someone's day and the reader is the only thing that
    knows how far along it is. A single-object file reports nothing:
    one object is one step, and a bar with one step is a light bulb.
    """
    import h5py

    with h5py.File(path, 'r') as f:
        # A newer stamp means a newer Visual Dynamics wrote fields this
        # reader has no idea exist, and half-loading someone's project
        # quietly is worse than telling them to update. A missing stamp
        # means the file is not ours at all — every writer stamps.
        if 'visualdynamics_schema' not in f.attrs:
            raise ValueError(f'{path} is not a Visual Dynamics file '
                             '(no schema stamp)')
        written = int(f.attrs['visualdynamics_schema'])
        if written > SCHEMA_VERSION:
            raise ValueError(
                f'{path} was written by a newer Visual Dynamics '
                f'(schema {written}; this build reads up to '
                f'{SCHEMA_VERSION}). Update to open it.')
        if 'objects' in f:
            objects = {}
            keys = sorted(f['objects'])
            if progress is not None:
                progress(0, len(keys))
            for done, key in enumerate(keys, start=1):
                group = f['objects'][key]
                objects[group.attrs['name']] = (
                    _LOADERS[group.attrs['kind']](group))
                if progress is not None:
                    progress(done, len(keys))
            import json
            return Project(f.attrs['test_name'], objects,
                           f.attrs['active_geometry'] or None,
                           f.attrs['project_type'] or None,
                           json.loads(f.attrs['links']),
                           provenance=json.loads(f.attrs['provenance']))
        for group_name, loader in _LOADERS.items():
            if group_name in f:
                return loader(f[group_name])
        raise ValueError(f"No recognized content in {path}")

register_importer

register_importer(
    name: str,
    description: str,
    sniff: Callable,
    load: Callable,
    project_type: Callable | None = None,
) -> None

Teach visualdynamics a format. Registered ones are tried in order, so a reader added later is asked last.

Source code in src/visualdynamics/io/__init__.py
def register_importer(name: str, description: str, sniff: Callable,
                      load: Callable,
                      project_type: Callable | None = None) -> None:
    """Teach visualdynamics a format. Registered ones are tried in order, so a
    reader added later is asked last."""
    _IMPORTERS.append(Importer(name, description, sniff, load, project_type))

save

save(obj: Any, path: str | PathLike) -> None

Save a visualdynamics object to a .vdyn (HDF5) file.

Source code in src/visualdynamics/io/native.py
def save(obj: Any, path: str | os.PathLike) -> None:
    """Save a visualdynamics object to a .vdyn (HDF5) file."""
    import h5py

    group_name, saver = _saver_for(obj)
    with h5py.File(_visualdynamics_path(path), 'w') as f:
        f.attrs['visualdynamics_schema'] = SCHEMA_VERSION
        saver(obj, f.create_group(group_name))

random_vibration_report

random_vibration_report(
    run: str | PathLike,
    path: str | PathLike | None = None,
    *,
    per_octave: int | None = None,
    unit_system: Any = None,
) -> str

A Rattlesnake random vibration run in, an HTML report out.

visualdynamics.random_vibration_report('run.nc4')

The whole workflow in one call: import, PSDs, octave bands, multiple coherence, the Random Vibration report, and the self-contained HTML. Returns the path written, which defaults to the run's own name with an .html extension.

Everything it does is random_vibration_run followed by generate_report and export_report; reach for those instead when the project is wanted afterwards — to add photographs or a geometry, to write the test summary, or to save it as .vdyn.

Source code in src/visualdynamics/project.py
def random_vibration_report(run: str | os.PathLike,
                            path: str | os.PathLike | None = None, *,
                            per_octave: int | None = None,
                            unit_system: Any = None) -> str:
    """A Rattlesnake random vibration run in, an HTML report out.

        visualdynamics.random_vibration_report('run.nc4')

    The whole workflow in one call: import, PSDs, octave bands, multiple
    coherence, the Random Vibration report, and the self-contained HTML.
    Returns the path written, which defaults to the run's own name with
    an `.html` extension.

    Everything it does is `random_vibration_run` followed by
    `generate_report` and `export_report`; reach for those instead when
    the project is wanted afterwards — to add photographs or a geometry,
    to write the test summary, or to save it as `.vdyn`.
    """
    project = random_vibration_run(run, per_octave)
    report = project.generate_report('random', name='Report')
    if path is None:
        path = os.path.splitext(str(run))[0] + '.html'
    return project.export_report(report, path, unit_system)

random_vibration_run

random_vibration_run(
    run: str | PathLike, per_octave: int | None = None
) -> Project

A Rattlesnake random vibration run, worked up into a project.

project = visualdynamics.random_vibration_run('run.nc4')

Every step the window would take on the way from a controller file to a finished project, in the order it takes them: import the run, average PSDs from the control time histories, band those onto proportional bands, and measure how much of each response the drives account for. The run says it is a random vibration test, so the project comes back declared as one.

The frames the spectra are averaged over are detected from the data itself when the file does not carry them, exactly as the bar's act does — so a script and a click reach the same numbers.

Source code in src/visualdynamics/project.py
def random_vibration_run(run: str | os.PathLike,
                         per_octave: int | None = None) -> Project:
    """A Rattlesnake random vibration run, worked up into a project.

        project = visualdynamics.random_vibration_run('run.nc4')

    Every step the window would take on the way from a controller file
    to a finished project, in the order it takes them: import the run,
    average PSDs from the control time histories, band those onto
    proportional bands, and measure how much of each response the drives
    account for. The run says it is a random vibration test, so the
    project comes back declared as one.

    The frames the spectra are averaged over are detected from the data
    itself when the file does not carry them, exactly as the bar's act
    does — so a script and a click reach the same numbers.
    """
    project = Project()
    project.import_file(run)
    history = next((name for name, obj in project.items()
                    if isinstance(obj, TimeHistory)), None)
    if history is None:
        raise ValueError(f'{run} holds no time data to work up')
    psds = project.compute_psds(history)
    project.compute_octave(psds, per_octave)
    project.compute_multiple_coherence(history)
    return project

convert

convert(
    values: ArrayLike, from_unit: str, to_unit: str
) -> Any

values from one unit to another, through SI.

Source code in src/visualdynamics/units.py
def convert(values: ArrayLike, from_unit: str, to_unit: str) -> Any:
    """`values` from one unit to another, through SI."""
    return from_si(to_si(values, from_unit), to_unit)

si_factor

si_factor(unit: str, dimension: str | None = None) -> float

Multiplier converting values in unit to SI.

Raises for affine units (degC, degF), which need to_si/from_si.

Source code in src/visualdynamics/units.py
def si_factor(unit: str, dimension: str | None = None) -> float:
    """Multiplier converting values in `unit` to SI.

    Raises for affine units (degC, degF), which need `to_si`/`from_si`.
    """
    scale, offset = si_transform(unit, dimension)
    if offset:
        raise UnitError(
            f"Unit {unit!r} has an offset; use to_si()/from_si() instead of a "
            "bare scale factor")
    return scale

launch_gui

launch_gui(
    *paths: str, in_process: bool | None = None
) -> Any

Launch the app, optionally importing files on the way in.

visualdynamics.launch_gui('test.vdyn', 'frfs.unv')

Normally this runs the window in this interpreter and blocks until it closes, and returns nothing — the window is gone, there is nothing to hand back. If pyqtgraph has already been bound to another Qt — import sdynpy does that, with PyQt5, and the choice cannot be undone in a running process — the app is started in a fresh process instead, and the Popen handle comes back straight away because that one is still running. So a session that has been using sdynpy still gets a window, and sdynpy's own plotting in that session is left alone.

in_process overrides the decision either way.

Source code in src/visualdynamics/__init__.py
def launch_gui(*paths: str, in_process: bool | None = None) -> Any:
    """Launch the app, optionally importing files on the way in.

        visualdynamics.launch_gui('test.vdyn', 'frfs.unv')

    Normally this runs the window in *this* interpreter and blocks
    until it closes, and returns nothing — the window is gone, there is
    nothing to hand back. If pyqtgraph has already been bound to another
    Qt — `import sdynpy` does that, with PyQt5, and the choice cannot be
    undone in a running process — the app is started in a fresh process
    instead, and the `Popen` handle comes back straight away because
    that one is still running. So a session that has been using sdynpy
    still gets a window, and sdynpy's own plotting in that session is
    left alone.

    `in_process` overrides the decision either way.
    """
    from .gui import qt_binding

    if in_process is None:
        in_process = qt_binding() in (None, 'PySide6')
    if in_process:
        from .gui import main

        # `main` hands back Qt's exit status, which `visualdynamics-gui` wants as
        # its own and nothing else does — it is always 0, since nothing
        # here ever calls exit() with anything else. Swallowed, so
        # closing the window from a prompt does not answer with a bare
        # `0` from the REPL echoing it.
        main(list(paths))
        return None
    return _spawn_gui(paths)