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, elements and element groups.

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.

View

The way a geometry opens in 3-D: where the eye is, seen from the

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.

mixed_run

A Rattlesnake random run with a sine sweep under it, worked up

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.

report_kind

Which one-call reports a Rattlesnake run gets: 'random', 'mixed',

run_report

A Rattlesnake run in, the report its type calls for out.

sine_report

A Rattlesnake sine sweep run in, an HTML report out.

sine_run

A Rattlesnake sine sweep run, worked up into a project.

system_id_report

A Rattlesnake system identification in, an HTML report out.

system_id_run

A Rattlesnake system identification, worked up into a project.

export_html

One figure, interactive, as a single self-contained HTML file.

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.

orientation

One channel's measured direction against a geometry.

derived_cells

The derived columns' cells for one row, as text: three

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')
    # Control and role are two questions, answered independently
    # (Brandon, 2026-09-26): what the controller drove the test to
    # match, and what the channel is to an FRF. A controlled force
    # can be an FRF's reference, a controlled voltage too, and
    # either can be a monitor. The schema once refused every
    # control channel that was not a response, as "the one nonsense
    # pair"; force and voltage control are why it was not one.
    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)
orientation
orientation(row: int, geometry: Any) -> tuple[ndarray | None, str | None, float | None]

One channel's measured direction against a geometry.

The DOF the row names — node and direction — read through the frame that node is measured in (Geometry.dof_direction), as a unit vector in the geometry's global system; the global axis it is nearest, signed ('X+', 'Z-'); and the angle between the two in degrees. What the derived columns show.

Parameters:

Name Type Description Default
row int

The channel's row.

required
geometry Geometry

The geometry the channel is measured on.

required

Returns:

Type Description
tuple

(vector, axis, angle), each None when the geometry cannot place the channel — a node it lacks, no direction, or a node measured in a frame that turns with position.

Source code in src/visualdynamics/core/channel_table.py
def orientation(self, row: int, geometry: Any
                ) -> tuple[np.ndarray | None, str | None, float | None]:
    """One channel's measured direction against a geometry.

    The DOF the row names — node and direction — read through the
    frame that node is measured in (`Geometry.dof_direction`), as
    a unit vector in the geometry's global system; the global axis
    it is nearest, signed ('X+', 'Z-'); and the angle between the
    two in degrees. What the derived columns show.

    Parameters
    ----------
    row : int
        The channel's row.
    geometry : Geometry
        The geometry the channel is measured on.

    Returns
    -------
    tuple
        (vector, axis, angle), each None when the geometry cannot
        place the channel — a node it lacks, no direction, or a
        node measured in a frame that turns with position.
    """
    if geometry is None:
        return None, None, None
    vector = geometry.dof_direction(self.dof_strings()[row])
    if vector is None:
        return None, None, None
    k = int(np.argmax(np.abs(vector)))
    axis = f'{AXES[k]}{"+" if vector[k] >= 0 else "-"}'
    angle = float(np.degrees(np.arccos(min(1.0, abs(float(vector[k]))))))
    return vector, axis, angle
derived_cells
derived_cells(row: int, geometry: Any) -> list[str]

The derived columns' cells for one row, as text: three components to three decimals, the nearest axis, the angle to a tenth of a degree; empty where the geometry cannot place the channel.

Parameters:

Name Type Description Default
row int

The channel's row.

required
geometry Geometry

The geometry the channel is measured on.

required

Returns:

Type Description
list of str

One cell per DERIVED_COLUMNS entry.

Source code in src/visualdynamics/core/channel_table.py
def derived_cells(self, row: int, geometry: Any) -> list[str]:
    """The derived columns' cells for one row, as text: three
    components to three decimals, the nearest axis, the angle to a
    tenth of a degree; empty where the geometry cannot place the
    channel.

    Parameters
    ----------
    row : int
        The channel's row.
    geometry : Geometry
        The geometry the channel is measured on.

    Returns
    -------
    list of str
        One cell per `DERIVED_COLUMNS` entry.
    """
    vector, axis, angle = self.orientation(row, geometry)
    if vector is None:
        return [''] * len(DERIVED_COLUMNS)
    return [f'{float(v):+.3f}' for v in vector] + [axis, f'{angle:.1f}']
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, normalized 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,
        normalized 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.

display_blocks

display_ordinate a block of records at a time.

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, colored 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 labeled 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; normalized 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 labeled 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; normalized 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 neighbors.

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 neighbors.
    """
    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)
    # converted a block of records at a time into slices of `out`:
    # gathering every asked-for record and scaling the gather were
    # two whole copies beside the answer, and on a 1.2 GB history
    # the conversion alone peaked at three times its output
    # (measured 2026-09-18, on the way to a 22 GB import that took
    # the application down at 67 GB)
    for start, stop, block in self.display_blocks(unit_system, rows):
        out[start:stop] = block
    return out
display_blocks
display_blocks(unit_system: UnitSystem, records: Iterable[int] | None = None, block_bytes: int | None = None)

display_ordinate a block of records at a time.

Yields (start, stop, values): positions within records and the converted rows for them, each block at most block_bytes (DISPLAY_BLOCK_BYTES by default) — so a reading that thins every record before it draws it, the 3-D stage above all, never holds a converted copy of the whole object. A record larger than the block is still one block: a row is not split.

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
block_bytes int

The most a block may hold, in bytes.

None

Yields:

Type Description
tuple of (int, int, numpy.ndarray)

The block's positions among records, and its values in display units.

Source code in src/visualdynamics/core/data.py
def display_blocks(self, unit_system: UnitSystem,
                   records: Iterable[int] | None = None,
                   block_bytes: int | None = None):
    """`display_ordinate` a block of records at a time.

    Yields `(start, stop, values)`: positions within `records` and
    the converted rows for them, each block at most `block_bytes`
    (`DISPLAY_BLOCK_BYTES` by default) — so a reading that thins
    every record before it draws it, the 3-D stage above all,
    never holds a converted copy of the whole object. A record
    larger than the block is still one block: a row is not split.

    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.
    block_bytes : int, optional
        The most a block may hold, in bytes.

    Yields
    ------
    tuple of (int, int, numpy.ndarray)
        The block's positions among `records`, and its values in
        display units.
    """
    rows = range(self.num_records) if records is None else list(records)
    limit = DISPLAY_BLOCK_BYTES if block_bytes is None else int(block_bytes)
    row_bytes = max(1, self.ordinate.shape[1] * self.ordinate.dtype.itemsize)
    per_block = max(1, limit // row_bytes)
    for start in range(0, len(rows), per_block):
        chunk = rows[start:start + per_block]
        block = np.empty((len(chunk), self.ordinate.shape[1]),
                         dtype=self.ordinate.dtype)
        by_dimension = {}
        for position, record in enumerate(chunk):
            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]]
            block[positions] = (source if dim == UNKNOWN
                                else unit_system.from_si(source, dim))
        yield start, start + len(chunk), block
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, colored 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, colored 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 synthesized 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 synthesized 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, elem_id: Ids | None = None, elem_type: ArrayLike | None = None, elem_color: ArrayLike | None = None, elem_conn: Sequence[ArrayLike] | None = None, elem_group: Ids | None = None, group_id: Ids | None = None, group_name: Sequence[str] | None = None, length_unit: str | None = None, group_properties: Mapping[int, Any] | None = None)

Nodes, coordinate systems, elements and element groups.

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

The arrays below are the storage; nodes, coordinate_systems, elements and groups 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 meters 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. (There are no tracelines (2026-09-30). A line drawn through nodes is an element group of two-node line elements with no properties — add_beams makes one, drawn_lines reads them back for the formats that keep tracelines apart — and the same element group becomes structure the moment its element group carries a section. One storage for one drawn idea; PLAN.md "Geometry by element family".) 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_group: Which element group each element belongs to, by element group id. group_id: The declared element groups. Unique, since elements name them. group_name: A name per element group — 'wing', 'arm front left'. This is where a mesh records that a region is a different part from its neighbor, 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.

dof_direction

A DOF's direction in global coordinates, through the frame

add_node

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

add_nodes

Append nodes at xyz (SI), numbered after the highest id —

add_coordinate_system

Append a coordinate system. Returns its id.

add_beams

A chain of two-node line elements through the given nodes, in

mixed_groups

{element group id: families} for every element group holding more than one

split_groups_by_family

One element group, one family: an element group holding elements of more than

attach_drawn_lines

Drawn lines from a format that keeps them apart from

is_drawn_line

Whether an element group is a drawn line: every element in it a

drawn_lines

The drawn lines, as the formats that keep tracelines apart

group_of

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

elements_in

The ids of the elements in an element group, named or numbered.

add_group

Declare an element group. Returns its id.

add_element

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

add_elements

Append elements — add_element for many at once, the nodes

renumber_node

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

renumber_group

Give an element group 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.

coincident_nodes

{node: the node it coincides with}: every node within

duplicate_elements

{element id: the element it is} for every solid element on

merge_duplicate_elements

Make elements that are one cell one element (duplicate_elements):

merge_coincident_nodes

Make nodes that are one point one node: every element and

delete_coordinate_systems

Remove coordinate systems, reassigning any node that used them.

delete_groups

Remove element groups with what they hold: their elements, and the

merge_refusal

Why these element groups cannot be one, or None when they can: they

merge_groups

One element group from several (Merge Element Groups, Brandon 2026-09-27): the

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 and elements.

plot_dofs

This geometry with labeled arrows at every DOF source

Attributes:

Name Type Description
opening_view View

The view it opens on in 3-D: its own view, else the default

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.

elements EntityView

Every element: ids, types, colors, nodes.

groups EntityView

Every element group: 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,
             elem_id: Ids | None = None,
             elem_type: ArrayLike | None = None,
             elem_color: ArrayLike | None = None,
             elem_conn: Sequence[ArrayLike] | None = None,
             elem_group: Ids | None = None,
             group_id: Ids | None = None,
             group_name: Sequence[str] | None = None,
             length_unit: str | None = None,
             group_properties: Mapping[int, Any] | 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)))


    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 element group each element belongs to, and what the element groups 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 neighbor. Read without keeping it, a file of
    # two element groups comes back as one anonymous group on the way
    # out.
    self.elem_group: IdArray = (
        _ids(elem_group, 'element group ids') if elem_group is not None
        else self._default(None, e, 1))
    found = (np.unique(self.elem_group) if e
             else np.array([], dtype=np.int64))
    self.group_id: IdArray = (
        _ids(group_id, 'element group ids', unique=True)
        if group_id is not None else found)
    self.group_name: list[str] = (list(group_name) if group_name is not None
                                  else [''] * len(self.group_id))
    # What each element group is made of, when the geometry is a model in
    # waiting: {element group id: fem.GroupProperties} — a material and a
    # thickness for an element group of plates, a material and a section for
    # an element group of beams. This is how every finite element format
    # states a structure, one property set per element group of one
    # element type, and it is what `fem.Model.from_geometry` builds
    # from (Brandon, 2026-09-25). Held here rather than in the
    # model so it is saved with the geometry and comes back. The
    # values are the FE module's own classes; this module only
    # carries them.
    self.group_properties: dict[int, Any] = (
        {int(k): v for k, v in group_properties.items()}
        if group_properties else {})
    self.elem_conn: list[IdArray] = [
        np.asarray(c, dtype=np.int64) for c in (elem_conn or [])]
    # a source states both what an element is and which nodes it
    # joins, and the two can contradict each other — a four-node
    # element carrying a triangle's descriptor drew as a triangle,
    # a node short (Brandon, 2026-09-20). The nodes are the
    # element; `reconcile_type` mends the label. Here rather than
    # in each reader, so every source is read the same way.
    if len(self.elem_type) == len(self.elem_conn):
        self.elem_type = np.array(
            [reconcile_type(int(code), len(conn))
             for code, conn in zip(self.elem_type, self.elem_conn)],
            dtype=np.int64) if len(self.elem_conn) else self.elem_type
    #: 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
    #: how it opens in 3-D (`View`); None opens on `DEFAULT_VIEW`
    self.view: View | None = None
    # one element group, one family (2026-09-30): a source that mixes them —
    # UNV 2412 and the sdynpy layout carry no element groups at all, so every
    # element arrives in one — is split on arrival rather than
    # refused, the same way a mislabeled type is mended above. Here
    # rather than in each reader, so every source is read the same way.
    self.split_groups_by_family()
    self.validate()
Attributes
opening_view property
opening_view: View

The view it opens on in 3-D: its own view, else the default isometric.

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.

elements property
elements: EntityView

Every element: ids, types, colors, nodes.

groups property
groups: EntityView

Every element group: ids and names.

An element group groups elements rather than holding them — which elements are in one is read off elem_group (elements_in), so moving an element between element groups 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. Element ids are labels — nothing
    # refers to them — 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 i, conn in enumerate(self.elem_conn):
        missing = set(conn.tolist()) - known
        if missing:
            raise ValueError(
                f"element {i} references unknown node ids {sorted(missing)}")
    if len(self.group_name) != len(self.group_id):
        raise ValueError(
            f'{len(self.group_name)} element group names for '
            f'{len(self.group_id)} element groups')
    _ids(self.group_id, 'element group ids', unique=True)
    stray = set(np.unique(self.elem_group).tolist()) - set(
        self.group_id.tolist())
    if stray:
        raise ValueError(
            'elements name element groups 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)}")
    # one element group, one family (2026-09-30): an element group's properties are
    # one kind, and the tree lists an element group under its family
    mixed = self.mixed_groups()
    if mixed:
        raise ValueError(
            'an element group holds one element family; '
            + ', '.join(f'element group {b} holds {" and ".join(fams)}'
                        for b, fams in mixed.items())
            + ' — split_groups_by_family() sorts them out')
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
dof_direction
dof_direction(dof: str) -> ndarray | None

A DOF's direction in global coordinates, through the frame its node is measured in.

'101X+' at a node whose node_disp_cs is rotated is not global X. The report's grid of control channels reads which global axis a channel is nearest and how far off it sits (Brandon, 2026-09-19), and this is where that is answered. A rotational DOF points along its axis, as the DOF arrows draw it.

Parameters:

Name Type Description Default
dof str

The DOF, such as '101X+' or '1313RZ-'.

required

Returns:

Type Description
ndarray or None

A unit vector, or None when the DOF names no node this geometry has, no axis (a node number alone), or a node measured in a cylindrical or spherical frame — whose local axes turn with the node's position, a reading this does not attempt rather than half-answer.

Source code in src/visualdynamics/core/geometry.py
def dof_direction(self, dof: str) -> np.ndarray | None:
    """A DOF's direction in global coordinates, through the frame
    its node is measured in.

    '101X+' at a node whose `node_disp_cs` is rotated is not global
    X. The report's grid of control channels reads which global
    axis a channel is nearest and how far off it sits (Brandon,
    2026-09-19), and this is where that is answered. A rotational
    DOF points along its axis, as the DOF arrows draw it.

    Parameters
    ----------
    dof : str
        The DOF, such as '101X+' or '1313RZ-'.

    Returns
    -------
    numpy.ndarray or None
        A unit vector, or None when the DOF names no node this
        geometry has, no axis (a node number alone), or a node
        measured in a cylindrical or spherical frame — whose local
        axes turn with the node's position, a reading this does
        not attempt rather than half-answer.
    """
    from .data import parse_dof

    node, direction = parse_dof(dof)
    direction = str(direction).upper()
    letter, sign = direction.lstrip('R')[:1], direction.lstrip('R')[1:]
    axis = {'X': 0, 'Y': 1, 'Z': 2}.get(letter)
    if node is None or axis is None or sign not in ('', '+', '-'):
        return None
    rows = np.flatnonzero(self.node_id == int(node))
    if not rows.size:
        return None
    frames = np.flatnonzero(self.cs_id == self.node_disp_cs[rows[0]])
    if frames.size:
        if int(self.cs_type[frames[0]]) != 0:
            return None
        vector = np.asarray(self.cs_matrix[frames[0], axis], dtype=float)
    else:
        # a frame the table does not list is the global one: a
        # geometry with no systems at all measures every node in it
        vector = np.eye(3)[axis]
    norm = float(np.linalg.norm(vector))
    if not norm:
        return None
    return (-1.0 if sign == '-' else 1.0) * vector / norm
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 color 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 color 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_nodes
add_nodes(xyz: ArrayLike) -> ndarray

Append nodes at xyz (SI), numbered after the highest id — add_node for many at once, one copy of each array rather than one per node, which is what keeps a meshed plane of thousands of nodes from costing seconds.

Parameters:

Name Type Description Default
xyz array_like

(n, 3) coordinates.

required

Returns:

Type Description
ndarray

The new nodes' identifiers, in order.

Source code in src/visualdynamics/core/geometry.py
def add_nodes(self, xyz: ArrayLike) -> np.ndarray:
    """Append nodes at `xyz` (SI), numbered after the highest id —
    `add_node` for many at once, one copy of each array rather than
    one per node, which is what keeps a meshed plane of thousands of
    nodes from costing seconds.

    Parameters
    ----------
    xyz : array_like
        (n, 3) coordinates.

    Returns
    -------
    ndarray
        The new nodes' identifiers, in order.
    """
    xyz = np.asarray(xyz, dtype=np.float64).reshape(-1, 3)
    first = self._next_id(self.node_id)
    ids = np.arange(first, first + len(xyz), dtype=np.int64)
    default_cs = int(self.cs_id[0]) if len(self.cs_id) else 1
    self.node_id = np.concatenate([self.node_id, ids])
    self.node_xyz = np.vstack([self.node_xyz, xyz])
    self.node_color = np.concatenate(
        [self.node_color, np.ones(len(xyz), dtype=self.node_color.dtype)])
    self.node_def_cs = np.concatenate(
        [self.node_def_cs, np.full(len(xyz), default_cs,
                                   dtype=self.node_def_cs.dtype)])
    self.node_disp_cs = np.concatenate(
        [self.node_disp_cs, np.full(len(xyz), default_cs,
                                    dtype=self.node_disp_cs.dtype)])
    return ids
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_beams
add_beams(node_ids: Ids, group: int | None = None, color: int = 1, elem_type: int = 21) -> int

A chain of two-node line elements through the given nodes, in order — what a traceline was (2026-09-30), and a run of beams once the element group carries a section. Returns the element group's id.

Parameters:

Name Type Description Default
node_ids sequence of int

The nodes the line runs through, in order; two at least.

required
group int

The element group the segments join. A new, unnamed element group when omitted — a drawn line is its own element group, named by what the line was called.

None
color int

The display color index, on every segment.

1
elem_type int

The two-node line code (ELEMENT_TYPES): a beam unless the source says rod, pipe or rigid bar.

21

Returns:

Type Description
int

The element group the segments are in.

Source code in src/visualdynamics/core/geometry.py
def add_beams(self, node_ids: Ids, group: int | None = None,
              color: int = 1, elem_type: int = 21) -> int:
    """A chain of two-node line elements through the given nodes, in
    order — what a traceline was (2026-09-30), and a run of beams
    once the element group carries a section. Returns the element group's id.

    Parameters
    ----------
    node_ids : sequence of int
        The nodes the line runs through, in order; two at least.
    group : int, optional
        The element group the segments join. A new, unnamed element group when
        omitted — a drawn line is its own element group, named by what
        the line was called.
    color : int, default 1
        The display color index, on every segment.
    elem_type : int, default 21
        The two-node line code (`ELEMENT_TYPES`): a beam unless
        the source says rod, pipe or rigid bar.

    Returns
    -------
    int
        The element group the segments are in.
    """
    nodes = [int(n) for n in node_ids]
    if len(nodes) < 2:
        raise ValueError('a line needs at least two nodes')
    unknown = set(nodes) - set(self.node_id.tolist())
    if unknown:
        raise ValueError(f'unknown nodes {sorted(unknown)}')
    if ELEMENT_TYPES.get(int(elem_type), (None, 0, None))[1:] != (2, 'line'):
        raise ValueError(f'{elem_type} is not a two-node line element type')
    from itertools import pairwise

    group = self.add_group() if group is None else int(group)
    for a, b in pairwise(nodes):
        self.add_element([a, b], elem_type=int(elem_type), color=color,
                         group=group)
    return group
mixed_groups
mixed_groups() -> dict[int, list[str]]

{element group id: families} for every element group holding more than one element family — none, in a geometry that keeps the rule.

Returns:

Type Description
dict of int to list of str
Source code in src/visualdynamics/core/geometry.py
def mixed_groups(self) -> dict[int, list[str]]:
    """{element group id: families} for every element group holding more than one
    element family — none, in a geometry that keeps the rule.

    Returns
    -------
    dict of int to list of str
    """
    found: dict[int, list[str]] = {}
    for code, group in zip(self.elem_type, self.elem_group):
        if int(code) not in ELEMENT_TYPES:
            continue                 # validate names the stray code
        family = element_family(int(code))
        families = found.setdefault(int(group), [])
        if family not in families:
            families.append(family)
    return {b: f for b, f in found.items() if len(f) > 1}
split_groups_by_family
split_groups_by_family() -> dict[int, list[int]]

One element group, one family: an element group holding elements of more than one family keeps its first family and each other family moves into a new element group named for it beside the old name. What a source without element groups — UNV 2412, the sdynpy layout — needs on the way in (Brandon, 2026-09-30: split on import rather than show one element group under two families and edit it twice).

Returns:

Type Description
dict of int to list of int

{old element group: [the new element groups made from it]}, empty when nothing was mixed.

Source code in src/visualdynamics/core/geometry.py
def split_groups_by_family(self) -> dict[int, list[int]]:
    """One element group, one family: an element group holding elements of more than
    one family keeps its first family and each other family moves
    into a new element group named for it beside the old name. What a
    source without element groups — UNV 2412, the sdynpy layout — needs on
    the way in (Brandon, 2026-09-30: split on import rather than
    show one element group under two families and edit it twice).

    Returns
    -------
    dict of int to list of int
        {old element group: [the new element groups made from it]}, empty when
        nothing was mixed.
    """
    made: dict[int, list[int]] = {}
    for group, families in self.mixed_groups().items():
        row = int(np.flatnonzero(self.group_id == group)[0])
        base = self.group_name[row]
        for family in families[1:]:
            label = FAMILY_LABELS[family].lower()
            new = self.add_group(f'{base} {label}'.strip())
            rows = [i for i, (code, b) in enumerate(
                zip(self.elem_type, self.elem_group))
                if int(b) == group and element_family(int(code)) == family]
            self.elem_group[rows] = new
            made.setdefault(group, []).append(new)
    return made
attach_drawn_lines
attach_drawn_lines(lines: Sequence[tuple[str, int, Sequence[Ids]]]) -> list[int]

Drawn lines from a format that keeps them apart from elements, each as its own element group of two-node line elements with no properties — the reader's half of drawn_lines.

Parameters:

Name Type Description Default
lines sequence of (name, color, chains)

One entry per line: what it was called, its color index, and its runs, each a sequence of node ids in drawing order (a UNV line that lifted the pen has several).

required

Returns:

Type Description
list of int

The element groups made, one per line.

Source code in src/visualdynamics/core/geometry.py
def attach_drawn_lines(self, lines: Sequence[tuple[str, int, Sequence[Ids]]]
                       ) -> list[int]:
    """Drawn lines from a format that keeps them apart from
    elements, each as its own element group of two-node line elements with
    no properties — the reader's half of `drawn_lines`.

    Parameters
    ----------
    lines : sequence of (name, color, chains)
        One entry per line: what it was called, its color index,
        and its runs, each a sequence of node ids in drawing order
        (a UNV line that lifted the pen has several).

    Returns
    -------
    list of int
        The element groups made, one per line.
    """
    groups = []
    for name, color, chains in lines:
        group = self.add_group(str(name))
        for chain in chains:
            self.add_beams(chain, group=group, color=int(color))
        groups.append(group)
    return groups
is_drawn_line
is_drawn_line(group: int) -> bool

Whether an element group is a drawn line: every element in it a two-node line element, and no properties on the element group — the one discriminator, on screen and in every file (PLAN.md "Geometry by element family"). An empty element group is not one.

Parameters:

Name Type Description Default
group int

The element group's identifier.

required

Returns:

Type Description
bool
Source code in src/visualdynamics/core/geometry.py
def is_drawn_line(self, group: int) -> bool:
    """Whether an element group is a drawn line: every element in it a
    two-node line element, and no properties on the element group — the
    one discriminator, on screen and in every file (PLAN.md
    "Geometry by element family"). An empty element group is not one.

    Parameters
    ----------
    group : int
        The element group's identifier.

    Returns
    -------
    bool
    """
    group = int(group)
    if group in self.group_properties:
        return False
    rows = np.flatnonzero(self.elem_group == group)
    if not len(rows):
        return False
    return all(ELEMENT_TYPES.get(int(self.elem_type[r]),
                                 (None, 0, None))[1:] == (2, 'line')
               for r in rows)
drawn_lines
drawn_lines() -> list[dict[str, Any]]

The drawn lines, as the formats that keep tracelines apart from elements write them: per drawn-line element group, in element group order, {'group', 'name', 'color', 'chains'} — the chains the element group's segments make when walked in element order, each a list of node ids, a new chain wherever a segment does not start where the last one ended. An element group read from one polyline gives that polyline back; one from a UNV line that lifted the pen gives its runs back under the one element group.

Returns:

Type Description
list of dict
Source code in src/visualdynamics/core/geometry.py
def drawn_lines(self) -> list[dict[str, Any]]:
    """The drawn lines, as the formats that keep tracelines apart
    from elements write them: per drawn-line element group, in element group order,
    `{'group', 'name', 'color', 'chains'}` — the chains the element group's
    segments make when walked in element order, each a list of
    node ids, a new chain wherever a segment does not start where
    the last one ended. An element group read from one polyline gives that
    polyline back; one from a UNV line that lifted the pen gives
    its runs back under the one element group.

    Returns
    -------
    list of dict
    """
    out = []
    for row, group in enumerate(self.group_id):
        group = int(group)
        if not self.is_drawn_line(group):
            continue
        rows = np.flatnonzero(self.elem_group == group)
        chains: list[list[int]] = []
        for r in rows:
            a, b = (int(n) for n in self.elem_conn[r][:2])
            if chains and chains[-1][-1] == a:
                chains[-1].append(b)
            else:
                chains.append([a, b])
        out.append({'group': group, 'name': self.group_name[row],
                    'color': int(self.elem_color[rows[0]]),
                    'chains': chains})
    return out
group_of
group_of(elem_id: int) -> str

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

Parameters:

Name Type Description Default
elem_id int

Which element.

required

Returns:

Type Description
str

The name of the element group it belongs to.

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

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

    Returns
    -------
    str
        The name of the element group it belongs to.
    """
    row = int(np.flatnonzero(self.elem_id == int(elem_id))[0])
    group = int(self.elem_group[row])
    where = np.flatnonzero(self.group_id == group)
    return self.group_name[int(where[0])] if len(where) else ''
elements_in
elements_in(group: int | str) -> list[int]

The ids of the elements in an element group, named or numbered.

Parameters:

Name Type Description Default
group int or str

An element group, 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, group: int | str) -> list[int]:
    """The ids of the elements in an element group, named or numbered.

    Parameters
    ----------
    group : int or str
        An element group, by identifier or by name.

    Returns
    -------
    list of int
        The identifiers of the elements it holds.
    """
    if isinstance(group, str):
        where = [i for i, name in enumerate(self.group_name)
                 if name == group]
        if not where:
            return []
        group = int(self.group_id[where[0]])
    return [int(self.elem_id[i])
            for i in np.flatnonzero(self.elem_group == int(group))]
add_group
add_group(name: str = '', group_id: int | None = None) -> int

Declare an element group. Returns its id.

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

Parameters:

Name Type Description Default
name str

What to call the element group.

''
group_id int

Its identifier. The next free one when omitted.

None

Returns:

Type Description
int

The element group's identifier.

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

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

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

    Returns
    -------
    int
        The element group's identifier.
    """
    group_id = (self._next_id(self.group_id) if group_id is None
                else int(group_id))
    if group_id in self.group_id:
        raise ValueError(f'element group {group_id} already exists')
    self.group_id = np.append(self.group_id, group_id)
    self.group_name.append(str(name))
    return group_id
add_element
add_element(node_ids: Ids, elem_type: int | None = None, color: int = 1, group: 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 color index.

1
group int

Which element group it belongs to. Left out, the first element group that holds this element's family, else a new one: an element group holds one family (2026-09-30), so a beam added to a mesh of plates goes in an element group of its own rather than theirs.

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, group: 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 color index.
    group : int, optional
        Which element group it belongs to. Left out, the first element group that
        holds this element's family, else a new one: an element group holds
        one family (2026-09-30), so a beam added to a mesh of
        plates goes in an element group of its own rather than theirs.

    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}')
    family = element_family(int(elem_type))
    if group is None:
        held_by = {int(b): element_family(int(code))
                   for code, b in zip(self.elem_type, self.elem_group)}
        group = next((b for b in self.group_id.tolist()
                      if held_by.get(int(b), family) == family), None)
        if group is None:
            group = self.add_group()
    group = int(group)
    # one element group, one family: the element group's properties are one kind
    rows = np.flatnonzero(self.elem_group == group)
    if len(rows):
        held = element_family(int(self.elem_type[rows[0]]))
        if held != family:
            raise ValueError(
                f'element group {group} holds {held}; a {family[:-1]} goes in '
                'an element group of its own')
    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 group not in self.group_id.tolist():
        self.group_id = np.append(self.group_id, group)
        self.group_name.append('')
    self.elem_group = np.append(self.elem_group, group)
    self.elem_conn.append(nodes)
    return len(self.elem_conn) - 1
add_elements
add_elements(connectivity: Sequence[Ids], elem_types: Ids, groups: Ids) -> None

Append elements — add_element for many at once, the nodes checked once for all of them.

Parameters:

Name Type Description Default
connectivity sequence of sequence of int

Each element's nodes, in order.

required
elem_types sequence of int

Each element's type code.

required
groups sequence of int

The element group each belongs to; an element group not declared yet is.

required

Returns:

Type Description
None
Source code in src/visualdynamics/core/geometry.py
def add_elements(self, connectivity: Sequence[Ids],
                 elem_types: Ids, groups: Ids) -> None:
    """Append elements — `add_element` for many at once, the nodes
    checked once for all of them.

    Parameters
    ----------
    connectivity : sequence of sequence of int
        Each element's nodes, in order.
    elem_types : sequence of int
        Each element's type code.
    groups : sequence of int
        The element group each belongs to; an element group not declared yet is.

    Returns
    -------
    None
    """
    conn = [np.asarray([int(n) for n in element], dtype=np.int64)
            for element in connectivity]
    if not conn:
        return
    used = set(np.concatenate(conn).tolist())
    unknown = used - set(self.node_id.tolist())
    if unknown:
        raise ValueError(f'unknown nodes {sorted(unknown)[:10]}')
    types = [int(t) for t in elem_types]
    wrong = sorted(set(types) - set(ELEMENT_TYPES))
    if wrong:
        raise ValueError(f'unknown element type {wrong[0]}')
    groups = [int(b) for b in groups]
    # one element group, one family — the rule `add_element` keeps one element
    # at a time, kept here for many at once. Its absence let a plate
    # joined by name into an element group of bricks make one element group of both
    # (Brandon, 2026-10-02); checked before anything is appended, so
    # a refused call leaves the geometry as it was
    coming: dict[int, set[str]] = {}
    for group, code in zip(groups, types):
        coming.setdefault(group, set()).add(element_family(code))
    for group, families in coming.items():
        held = {element_family(int(code)) for code
                in self.elem_type[self.elem_group == group]}
        if len(held | families) > 1:
            raise ValueError(
                f'element group {group} would hold '
                f'{" and ".join(sorted(held | families))}: an element group holds '
                'one element family')
    for group in dict.fromkeys(groups):
        if group not in self.group_id.tolist():
            self.group_id = np.append(self.group_id, group)
            self.group_name.append('')
    first = self._next_id(self.elem_id)
    self.elem_id = np.concatenate(
        [self.elem_id, np.arange(first, first + len(conn))]).astype(np.int64)
    self.elem_type = np.concatenate([self.elem_type, types]).astype(np.int64)
    self.elem_color = np.concatenate(
        [self.elem_color, np.ones(len(conn))]).astype(np.int64)
    self.elem_group = np.concatenate([self.elem_group, groups]).astype(np.int64)
    self.elem_conn.extend(conn)
renumber_node
renumber_node(row: int, node_id: int) -> None

Give a node a new id, carrying its 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 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.elem_conn:
        conn[conn == old] = node_id
renumber_group
renumber_group(row: int, group_id: int) -> None

Give an element group a new id, carrying its elements over.

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

Parameters:

Name Type Description Default
row int

Which element group, by row.

required
group_id int

Its new identifier.

required

Returns:

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

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

    Parameters
    ----------
    row : int
        Which element group, by row.
    group_id : int
        Its new identifier.

    Returns
    -------
    None
    """
    group_id = int(group_id)
    clash = np.flatnonzero(self.group_id == group_id)
    if len(clash) and clash[0] != row:
        raise ValueError(f'element group {group_id} already exists')
    old = int(self.group_id[row])
    self.group_id[row] = group_id
    self.elem_group[self.elem_group == old] = group_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.

An 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 dependents 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.

    An 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
        dependents 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, 'elements': 0}

    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_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, 'elements': len(orphan_elements)}
coincident_nodes
coincident_nodes(tolerance: float) -> dict[int, int]

{node: the node it coincides with}: every node within tolerance of another, mapped to the lowest id among those it is joined to — the one a merge keeps. Chains join: a within tolerance of b and b of c are one point.

Parameters:

Name Type Description Default
tolerance float

How close two nodes must be to be one point, as the coordinates are held: meters once units are defined.

required

Returns:

Type Description
dict of int to int

Only the nodes that would go, each to the node it becomes.

Source code in src/visualdynamics/core/geometry.py
def coincident_nodes(self, tolerance: float) -> dict[int, int]:
    """{node: the node it coincides with}: every node within
    `tolerance` of another, mapped to the lowest id among those it is
    joined to — the one a merge keeps. Chains join: a within tolerance
    of b and b of c are one point.

    Parameters
    ----------
    tolerance : float
        How close two nodes must be to be one point, as the
        coordinates are held: meters once units are defined.

    Returns
    -------
    dict of int to int
        Only the nodes that would go, each to the node it becomes.
    """
    from scipy.spatial import cKDTree

    if len(self.node_id) < 2:
        return {}
    pairs = cKDTree(self.node_xyz).query_pairs(float(tolerance))
    parent = list(range(len(self.node_id)))

    def root(i: int) -> int:
        while parent[i] != i:
            parent[i] = parent[parent[i]]
            i = parent[i]
        return i

    for i, j in pairs:
        a, b = root(i), root(j)
        if a != b:
            parent[max(a, b)] = min(a, b)
    groups: dict[int, list[int]] = {}
    for i in range(len(self.node_id)):
        groups.setdefault(root(i), []).append(i)
    out = {}
    for members in groups.values():
        if len(members) < 2:
            continue
        ids = sorted(int(self.node_id[i]) for i in members)
        for other in ids[1:]:
            out[other] = ids[0]
    return out
duplicate_elements
duplicate_elements() -> dict[int, int]

{element id: the element it is} for every solid element on exactly the nodes of an earlier one, of the same type — two bricks filling one cell. The lowest id is the one that stays.

Solids only: two beams on one line can be two members, and a plate on a plate's nodes is how a doubler or a layer is modeled, but no two solids fill one cell on purpose.

Two element groups that overlap and share their nodes there — the bars of an X cross-section — put two elements in every cell of the overlap: the region counted twice, its stiffness and mass doubled, and every face of each pair drawn as interior, so the middle of the X vanished from the view (Brandon, 2026-10-02).

Source code in src/visualdynamics/core/geometry.py
def duplicate_elements(self) -> dict[int, int]:
    """{element id: the element it is} for every solid element on
    exactly the nodes of an earlier one, of the same type — two
    bricks filling one cell. The lowest id is the one that stays.

    Solids only: two beams on one line can be two members, and a
    plate on a plate's nodes is how a doubler or a layer is modeled,
    but no two solids fill one cell on purpose.

    Two element groups that overlap and share their nodes there — the bars
    of an X cross-section — put two elements in every cell of the
    overlap: the region counted twice, its stiffness and mass
    doubled, and every face of each pair drawn as interior, so the
    middle of the X vanished from the view (Brandon, 2026-10-02).
    """
    first: dict[tuple, int] = {}
    out: dict[int, int] = {}
    for k, conn in enumerate(self.elem_conn):
        if element_family(int(self.elem_type[k])) not in SOLID_FAMILIES:
            continue
        key = (int(self.elem_type[k]), tuple(sorted(int(n) for n in conn)))
        eid = int(self.elem_id[k])
        if key in first:
            out[eid] = first[key]
        else:
            first[key] = eid
    return out
merge_duplicate_elements
merge_duplicate_elements() -> int

Make elements that are one cell one element (duplicate_elements): the later of each pair is removed, the earlier — and its element group — stays. Returns how many were removed.

Source code in src/visualdynamics/core/geometry.py
def merge_duplicate_elements(self) -> int:
    """Make elements that are one cell one element (`duplicate_elements`):
    the later of each pair is removed, the earlier — and its element group —
    stays. Returns how many were removed."""
    going = self.duplicate_elements()
    if going:
        self.delete_elements(list(going))
    return len(going)
merge_coincident_nodes
merge_coincident_nodes(tolerance: float) -> dict[str, int]

Make nodes that are one point one node: every element and element naming a node within tolerance of another is renamed to the lowest id among them, and the rest are removed. Plates connect only where they share nodes, so this is what ties a model built from planes together at its corners.

Refused, with the element named, when the tolerance would fold an element onto itself — two of its own corners within it — since that is a tolerance larger than the mesh, not a coincidence. Elements the merge leaves on exactly the same nodes are one cell filled twice, and are made one (merge_duplicate_elements).

Parameters:

Name Type Description Default
tolerance float

How close two nodes must be to be one, as the coordinates are held: meters once units are defined.

required

Returns:

Type Description
dict of str to int

'merged', the nodes removed, 'into', the nodes they became, and 'duplicates', the elements removed for filling a cell another already filled.

Source code in src/visualdynamics/core/geometry.py
def merge_coincident_nodes(self, tolerance: float) -> dict[str, int]:
    """Make nodes that are one point one node: every element and
    element naming a node within `tolerance` of another is renamed
    to the lowest id among them, and the rest are removed. Plates
    connect only where they share nodes, so this is what ties a model
    built from planes together at its corners.

    Refused, with the element named, when the tolerance would fold an
    element onto itself — two of its own corners within it — since
    that is a tolerance larger than the mesh, not a coincidence.
    Elements the merge leaves on exactly the same nodes are one cell
    filled twice, and are made one (`merge_duplicate_elements`).

    Parameters
    ----------
    tolerance : float
        How close two nodes must be to be one, as the coordinates are
        held: meters once units are defined.

    Returns
    -------
    dict of str to int
        'merged', the nodes removed, 'into', the nodes they became,
        and 'duplicates', the elements removed for filling a cell
        another already filled.
    """
    mapping = self.coincident_nodes(tolerance)
    if not mapping:
        duplicates = self.merge_duplicate_elements()
        return {'merged': 0, 'into': 0, 'duplicates': duplicates}
    for i, conn in enumerate(self.elem_conn):
        renamed = [mapping.get(int(n), int(n)) for n in conn]
        if len(set(renamed)) < len(renamed):
            raise ValueError(
                f'a tolerance of {tolerance:g} folds element '
                f'{int(self.elem_id[i])} onto itself — two of its own '
                'nodes are within it; use a smaller tolerance')
    self.elem_conn = [np.asarray([mapping.get(int(n), int(n))
                                  for n in conn], dtype=np.int64)
                      for conn in self.elem_conn]
    keep = ~np.isin(self.node_id, list(mapping))
    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]
    duplicates = self.merge_duplicate_elements()
    self.validate()
    return {'merged': len(mapping), 'into': len(set(mapping.values())),
            'duplicates': duplicates}
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 dependents 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
        dependents 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_groups
delete_groups(group_ids: Ids) -> dict[str, int]

Remove element groups with what they hold: their elements, and the nodes no element outside them uses.

Deleting a part deletes the part (Brandon, 2026-09-27). It used to move a deleted element group's elements into the first element group left, which made a delete a merge; merging is its own act now (merge_groups). A node an element of another element group also uses stays, so a neighboring part is not cut into along the line the two share.

Parameters:

Name Type Description Default
group_ids int or sequence of int

The identifiers, one or many.

required

Returns:

Type Description
dict of str to int

How many element groups, elements and nodes went.

Source code in src/visualdynamics/core/geometry.py
def delete_groups(self, group_ids: Ids) -> dict[str, int]:
    """Remove element groups with what they hold: their elements, and the
    nodes no element outside them uses.

    Deleting a part deletes the part (Brandon, 2026-09-27). It used to
    move a deleted element group's elements into the first element group left, which
    made a delete a merge; merging is its own act now
    (`merge_groups`). A node an element of another element group also uses
    stays, so a neighboring part is not cut into along the line the
    two share.

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

    Returns
    -------
    dict of str to int
        How many element groups, elements and nodes went.
    """
    wanted = {int(group) for group in group_ids}
    keep = ~np.isin(self.group_id, list(wanted))
    removed = int((~keep).sum())
    if not removed:
        return {'groups': 0, 'elements': 0, 'nodes': 0}
    inside, outside = set(), set()
    doomed = []
    for row, conn in enumerate(self.elem_conn):
        nodes = {int(n) for n in conn}
        if int(self.elem_group[row]) in wanted:
            inside |= nodes
            doomed.append(int(self.elem_id[row]))
        else:
            outside |= nodes
    self.delete_elements(doomed)
    gone = self.delete_nodes(sorted(inside - outside))
    self.group_name = [name for name, k in zip(self.group_name, keep) if k]
    for group in wanted:                 # a deleted element group's properties go with it
        self.group_properties.pop(group, None)
    self.group_id = self.group_id[keep]
    return {'groups': removed, 'elements': len(doomed),
            'nodes': gone['nodes']}
merge_refusal
merge_refusal(group_ids: Ids) -> str | None

Why these element groups cannot be one, or None when they can: they must hold the same element types and carry the same properties. A merged element group is given one material and one thickness or section, so merging different ones would change the model without saying so.

Parameters:

Name Type Description Default
group_ids sequence of int

The element groups, by identifier.

required

Returns:

Type Description
str or None
Source code in src/visualdynamics/core/geometry.py
def merge_refusal(self, group_ids: Ids) -> str | None:
    """Why these element groups cannot be one, or None when they can: they
    must hold the same element types and carry the same properties.
    A merged element group is given one material and one thickness or
    section, so merging different ones would change the model
    without saying so.

    Parameters
    ----------
    group_ids : sequence of int
        The element groups, by identifier.

    Returns
    -------
    str or None
    """
    ids = list(dict.fromkeys(int(b) for b in group_ids))
    if len(ids) < 2:
        return 'select two element groups or more to merge'
    missing = [b for b in ids if b not in self.group_id.tolist()]
    if missing:
        return f'no element group {missing[0]}'
    kinds = {frozenset(int(t) for t in
                       self.elem_type[self.elem_group == group])
             for group in ids}
    if len(kinds) > 1:
        return 'the element groups hold different element types'
    held = [self.group_properties.get(group) for group in ids]
    if any(other != held[0] for other in held[1:]):
        return ('the element groups differ in material, thickness or section')
    return None
merge_groups
merge_groups(group_ids: Ids) -> dict[str, int]

One element group from several (Merge Element Groups, Brandon 2026-09-27): the elements of the rest moved into the first, the rest removed — refused, with the reason, unless merge_refusal allows it.

Parameters:

Name Type Description Default
group_ids sequence of int

The element groups; the first keeps its id, name and properties.

required

Returns:

Type Description
dict of str to int

'into', the element group kept; 'groups', how many were merged into it; 'elements', how many elements moved.

Source code in src/visualdynamics/core/geometry.py
def merge_groups(self, group_ids: Ids) -> dict[str, int]:
    """One element group from several (Merge Element Groups, Brandon 2026-09-27): the
    elements of the rest moved into the first, the rest removed —
    refused, with the reason, unless `merge_refusal` allows it.

    Parameters
    ----------
    group_ids : sequence of int
        The element groups; the first keeps its id, name and properties.

    Returns
    -------
    dict of str to int
        'into', the element group kept; 'groups', how many were merged into
        it; 'elements', how many elements moved.
    """
    reason = self.merge_refusal(group_ids)
    if reason is not None:
        raise ValueError(reason)
    ids = list(dict.fromkeys(int(b) for b in group_ids))
    into, others = ids[0], set(ids[1:])
    moved = np.isin(self.elem_group, list(others))
    self.elem_group[moved] = into
    keep = ~np.isin(self.group_id, list(others))
    self.group_name = [name for name, k in zip(self.group_name, keep) if k]
    for group in others:
        self.group_properties.pop(group, None)
    self.group_id = self.group_id[keep]
    return {'into': into, 'groups': len(others),
            'elements': int(moved.sum())}
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 dependents 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
        dependents 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_group = self.elem_group[keep]
    # An element group whose last element has gone is still an element group: exodus
    # files carry empty ones, and forgetting the name would lose on a
    # round trip exactly what element groups 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 and elements.

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 and elements.

    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 labeled 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 labeled 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: _Bands, 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.

areas

area over several bands at once: the comparison is

written

Which lines say something: finite, and for a requirement

extent

(low, high) this spectrum speaks for, in hertz.

to_octave

This spectrum integrated onto proportional bands.

coherence

The ordinary coherence of every cross term this CPSD holds:

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)
    self._take_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), color 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), color
    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.
    """
    return self._area_of(np.asarray(self.ordinate[record]), low, high)
areas
areas(record: int, lows: ArrayLike, highs: ArrayLike) -> ndarray

area over several bands at once: the comparison is judged cell by cell, and a thousand cells must not cost a thousand passes over the lines.

Parameters:

Name Type Description Default
record int

Which record.

required
lows array - like

The bands' edges, paired.

required
highs array - like

The bands' edges, paired.

required

Returns:

Type Description
ndarray

One area per band; NaN where nothing is written.

Source code in src/visualdynamics/core/data.py
def areas(self, record: int, lows: ArrayLike, highs: ArrayLike
          ) -> np.ndarray:
    """`area` over several bands at once: the comparison is
    judged cell by cell, and a thousand cells must not cost a
    thousand passes over the lines.

    Parameters
    ----------
    record : int
        Which record.
    lows, highs : array-like
        The bands' edges, paired.

    Returns
    -------
    numpy.ndarray
        One area per band; NaN where nothing is written.
    """
    return self._areas_of(np.asarray(self.ordinate[record]), lows, highs)
written
written(record: int | None = None) -> ndarray

Which lines say something: finite, and for a requirement positive — a controller writes zero or NaN on every line it did not control, and those lines are not a requirement of nothing. Over one record, or any record when none is named.

Parameters:

Name Type Description Default
record int

The record; every record when omitted.

None

Returns:

Type Description
ndarray

A boolean per line.

Source code in src/visualdynamics/core/data.py
def written(self, record: int | None = None) -> np.ndarray:
    """Which lines say something: finite, and for a requirement
    positive — a controller writes zero or NaN on every line it did
    not control, and those lines are not a requirement of nothing.
    Over one record, or any record when none is named.

    Parameters
    ----------
    record : int, optional
        The record; every record when omitted.

    Returns
    -------
    numpy.ndarray
        A boolean per line.
    """
    values = np.real(np.atleast_2d(np.asarray(self.ordinate)))
    if record is not None:
        values = values[[int(record)]]
    return np.isfinite(values).any(axis=0)
extent
extent(record: int | None = None) -> tuple[float, float] | None

(low, high) this spectrum speaks for, in hertz.

For a density per bin, the outer edges of the written bins — a line stands for its whole bin. For a curve between breakpoints, the first and last written points. What octave banding clips its end bands to, and what a comparison is judged over (Brandon, 2026-09-19): a controller's target is stored on every FFT line to Nyquist and speaks only where it is written.

Parameters:

Name Type Description Default
record int

The record; the union over every record when omitted.

None

Returns:

Type Description
tuple of float, or None

None when nothing is written above zero hertz.

Source code in src/visualdynamics/core/data.py
def extent(self, record: int | None = None) -> tuple[float, float] | None:
    """(low, high) this spectrum speaks for, in hertz.

    For a density per bin, the outer edges of the written bins — a
    line stands for its whole bin. For a curve between breakpoints,
    the first and last written points. What octave banding clips
    its end bands to, and what a comparison is judged over
    (Brandon, 2026-09-19): a controller's target is stored on
    every FFT line to Nyquist and speaks only where it is written.

    Parameters
    ----------
    record : int, optional
        The record; the union over every record when omitted.

    Returns
    -------
    tuple of float, or None
        None when nothing is written above zero hertz.
    """
    frequencies = np.asarray(self.abscissa, dtype=float)
    said = self.written(record) & np.isfinite(frequencies)
    if self.interpolation == 'log_log':
        said &= frequencies > 0.0
        if not said.any():
            return None
        return float(frequencies[said].min()), float(frequencies[said].max())
    left, right = self.bin_bounds()
    said &= right > 0.0
    if not said.any():
        return None
    return float(left[said].min()), float(right[said].max())
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 center would throw away everything between the centers.

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 center would throw away everything between the centers.

    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 resample

    frequencies = np.asarray(self.abscissa, dtype=float)
    per_octave, centers, widths, bounds = self._octave_grid(
        per_octave, low, high)
    banded = resample(frequencies, self.ordinate, bounds, widths,
                      source=self.bin_bounds())
    out = Psd(centers, 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
coherence
coherence() -> Coherence

The ordinary coherence of every cross term this CPSD holds: |S_pq|² / (S_pp S_qq), from the cross spectrum and the two autospectra beside it.

Banded or not — a coherence of an octave-banded CPSD is the banded quantity, each band's averaged cross spectrum against its averaged autospectra, and keeps the bands so it draws flat across them the way the CPSD does (2026-09-26, for the band-average paper). It is not the average of a narrowband coherence over the band, which would weight a band's lines equally whatever their power.

Returns:

Type Description
Coherence

One record per cross term, response against reference.

Source code in src/visualdynamics/core/data.py
def coherence(self) -> Coherence:
    """The ordinary coherence of every cross term this CPSD holds:
    |S_pq|² / (S_pp S_qq), from the cross spectrum and the two
    autospectra beside it.

    Banded or not — a coherence of an octave-banded CPSD is the
    banded quantity, each band's averaged cross spectrum against its
    averaged autospectra, and keeps the bands so it draws flat
    across them the way the CPSD does (2026-09-26, for the
    band-average paper). It is not the average of a narrowband
    coherence over the band, which would weight a band's lines
    equally whatever their power.

    Returns
    -------
    Coherence
        One record per cross term, response against reference.
    """
    if self.reference_dof is None:
        raise ValueError('coherence needs a CPSD: records with a '
                         'reference as well as a response')
    autos = {}
    for i, (p, q) in enumerate(zip(self.response_dof, self.reference_dof,
                                   strict=True)):
        if p == q:
            autos.setdefault(str(p), i)
    rows, responses, references = [], [], []
    ordinate = np.asarray(self.ordinate)
    for i, (p, q) in enumerate(zip(self.response_dof, self.reference_dof,
                                   strict=True)):
        if p == q:
            continue
        if str(p) not in autos or str(q) not in autos:
            continue
        with np.errstate(divide='ignore', invalid='ignore'):
            value = (np.abs(ordinate[i]) ** 2
                     / (np.real(ordinate[autos[str(p)]])
                        * np.real(ordinate[autos[str(q)]])))
        rows.append(np.clip(np.nan_to_num(value, nan=0.0), 0.0, 1.0))
        responses.append(p)
        references.append(q)
    if not rows:
        raise ValueError('no cross term has both its autospectra here')
    return Coherence(np.asarray(self.abscissa), np.asarray(rows),
                     response_dof=responses, reference_dof=references,
                     bandwidth=self.bandwidth,
                     comment=(f'coherence of {self.comment}'.strip()
                              if self.comment else 'coherence'))

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. For complex shapes it is modal A, the first-order scaling, which is what synthesize_frf reads it as. Complex when an imported source carried it complex — 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.

For real shapes, the second-order residue form

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 — exact for mass-normalized shapes, with modal_mass carrying any other scaling. For complex shapes, the first-order form, a pole and its conjugate:

H_jk(f) = sum_r (iw)^power [ psi_jr psi_kr / (A_r (iw - l_r))
              + conj(psi_jr psi_kr / A_r) / (iw - conj(l_r)) ]

with the pole l_r = -z_r w_r + i w_r sqrt(1 - z_r^2) and modal_mass read as modal A, the scaling a complex mode carries (unity when a set has none). The two are the same FRF for a real mode whose modal A is 2i w_d m_r, with w_d the damped frequency, which is how they connect.

Which form is decided by the shapes alone, complex or real, as sdynpy decides it. The second-order form cannot represent a complex mode: however modal_mass is set, it gives the conjugate pole the residue -R where the structure has conj(R), and with modal A in modal_mass it is off by 2i w_d outright — every imported complex set synthesized an answer rotated 90 degrees and 2 w_d times too small, until 2026-09-23. Modal A is conjugated in the second term, where sdynpy's is not; the two agree whenever modal A is real, and only the conjugate is right when it is not — sdynpy is 2.5% of peak off the exact FRF on the complex modal A of the test system (measured 2026-09-24).

power picks the response quantity: 0 displacement per force, 1 velocity, 2 acceleration. It applies inside the sum because a real 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.

Both forms are checked against the direct inverse of the dynamic stiffness — proportionally damped for real shapes, and not proportionally damped for complex ones — and against sdynpy's own synthesis, all to rounding (tests/test_frf_synthesis_exact.py, tests/test_modal_frf_oracle.py).

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 synthesize 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 synthesized FRFs, one row per response and drive pair.

References
  1. Ewins, D. J. (2000). Modal Testing: Theory, Practice and Application, 2nd ed. Research Studies Press. The residue form for real modes, and why non-proportional damping makes the modes complex.
  2. Maia, N. M. M., & Silva, J. M. M. (1997). Theoretical and Experimental Modal Analysis. Research Studies Press. The first-order (state-space) form, its conjugate pole pairs, and modal A as the scaling that goes with them.
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.

    For real shapes, the second-order residue form

        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 — exact for mass-normalized shapes, with
    `modal_mass` carrying any other scaling. For complex shapes, the
    first-order form, a pole and its conjugate:

        H_jk(f) = sum_r (iw)^power [ psi_jr psi_kr / (A_r (iw - l_r))
                      + conj(psi_jr psi_kr / A_r) / (iw - conj(l_r)) ]

    with the pole l_r = -z_r w_r + i w_r sqrt(1 - z_r^2) and
    `modal_mass` read as **modal A**, the scaling a complex mode
    carries (unity when a set has none). The two are the same FRF
    for a real mode whose modal A is 2i w_d m_r, with w_d the damped
    frequency, which is how they connect.

    Which form is decided by the shapes alone, complex or real, as
    sdynpy decides it. The second-order form cannot represent a
    complex mode: however `modal_mass` is set, it gives the
    conjugate pole the residue -R where the structure has conj(R),
    and with modal A in `modal_mass` it is off by 2i w_d outright —
    every imported complex set synthesized an answer rotated 90
    degrees and 2 w_d times too small, until 2026-09-23. Modal A is
    conjugated in the second term, where sdynpy's is not; the two
    agree whenever modal A is real, and only the conjugate is right
    when it is not — sdynpy is 2.5% of peak off the exact FRF on the
    complex modal A of the test system (measured 2026-09-24).

    `power` picks the response quantity: 0 displacement per force, 1
    velocity, 2 acceleration. It applies inside the sum because a
    real 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.

    Both forms are checked against the direct inverse of the dynamic
    stiffness — proportionally damped for real shapes, and not
    proportionally damped for complex ones — and against sdynpy's own
    synthesis, all to rounding (`tests/test_frf_synthesis_exact.py`,
    `tests/test_modal_frf_oracle.py`).

    `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 synthesize 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 synthesized FRFs, one row per response and drive pair.

    References
    ----------
    1. Ewins, D. J. (2000). *Modal Testing: Theory, Practice and
       Application*, 2nd ed. Research Studies Press. The residue
       form for real modes, and why non-proportional damping makes
       the modes complex.
    2. Maia, N. M. M., & Silva, J. M. M. (1997). *Theoretical and
       Experimental Modal Analysis*. Research Studies Press. The
       first-order (state-space) form, its conjugate pole pairs,
       and modal A as the scaling that goes with them.
    """
    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]
    zeta = self.damping[picked]
    numerator = (1j * omega) ** power
    out = np.empty((len(response_dof), len(frequencies)),
                   dtype=np.complex128)
    if self.is_complex:
        # complex so an overdamped entry gives a real pole pair
        # rather than nan
        pole = (-zeta * omega_r
                + 1j * omega_r * np.sqrt((1.0 - zeta ** 2).astype(complex)))
        # an undamped mode's own resonance line divides by zero; the
        # infinity is the right answer there, not worth a warning
        with np.errstate(divide='ignore', invalid='ignore'):
            ratio = numerator[None, :] / (1j * omega[None, :] - pole[:, None])
            mirror = numerator[None, :] / (1j * omega[None, :]
                                           - np.conj(pole)[:, None])
        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 + np.conj(residues) @ mirror
        return out
    denominator = ((omega_r ** 2)[:, None] - (omega ** 2)[None, :]
                   + 2j * (zeta * omega_r)[:, None] * omega[None, :])
    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
    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.

Methods:

Name Description
reading_of

How a specification written at these frequencies reads:

written

Which lines the requirement is written on: finite and

to_octave

This specification integrated onto proportional bands, its

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
Methods:
reading_of staticmethod
reading_of(frequencies: ArrayLike) -> str

How a specification written at these frequencies reads: 'bin', a density per line, for many lines on an even grid — a controller's target on its FFT lines — and 'log_log', breakpoints of a power law, for a few unevenly spaced points (Brandon, 2026-09-19: "breakpoint specifications have very few frequency lines and I would expect an uneven spacing of them"). Both octave-band and narrowband specifications step; only a breakpoint curve is drawn as the law between its points.

Parameters:

Name Type Description Default
frequencies array_like

The specification's frequency lines, ascending.

required

Returns:

Type Description
str

'bin' or 'log_log'.

Source code in src/visualdynamics/core/data.py
@staticmethod
def reading_of(frequencies: ArrayLike) -> str:
    """How a specification written at these frequencies reads:
    'bin', a density per line, for many lines on an even grid — a
    controller's target on its FFT lines — and 'log_log', breakpoints
    of a power law, for a few unevenly spaced points (Brandon,
    2026-09-19: "breakpoint specifications have very few frequency
    lines and I would expect an uneven spacing of them"). Both
    octave-band and narrowband specifications step; only a
    breakpoint curve is drawn as the law between its points.

    Parameters
    ----------
    frequencies : array_like
        The specification's frequency lines, ascending.

    Returns
    -------
    str
        'bin' or 'log_log'.
    """
    f = np.asarray(frequencies, dtype=float)
    if f.size < Specification.LINES_AT_LEAST:
        return 'log_log'
    steps = np.diff(f)
    even = np.all(np.abs(steps - np.median(steps))
                  <= 1e-6 * max(abs(float(np.median(steps))), 1e-12))
    return 'bin' if even else 'log_log'
written
written(record: int | None = None) -> ndarray

Which lines the requirement is written on: finite and positive. A controller writes zero on the lines it did not control, and zero is not a requirement of silence.

Parameters:

Name Type Description Default
record int

The record; every record when omitted.

None

Returns:

Type Description
ndarray

A boolean per line.

Source code in src/visualdynamics/core/data.py
def written(self, record: int | None = None) -> np.ndarray:
    """Which lines the requirement is written on: finite and
    positive. A controller writes zero on the lines it did not
    control, and zero is not a requirement of silence.

    Parameters
    ----------
    record : int, optional
        The record; every record when omitted.

    Returns
    -------
    numpy.ndarray
        A boolean per line.
    """
    values = np.atleast_2d(np.asarray(self.ordinate))
    if record is not None:
        values = values[[int(record)]]
    magnitude = np.abs(values) if np.iscomplexobj(values) else np.real(values)
    with np.errstate(invalid='ignore'):
        return (np.isfinite(magnitude) & (magnitude > 0.0)).any(axis=0)
to_octave
to_octave(per_octave: int | None = None, low: float | None = None, high: float | None = None) -> Specification

This specification integrated onto proportional bands, its warning and abort limits with it.

The same area rule as Psd.to_octave, read the way the object is: a specification computed on lines is a density and each line is a bin; one written at breakpoints is a power law between them, and each band takes the exact area under that law (compliance.log_log_area) over the part of the band the specification covers, divided by the band's width. The limits are curves written the same way and go through the same rule, so a band's warning line stands in the same relation to its target as the breakpoints did. Cross terms of a breakpoint specification are not power laws (a phase is not), so they are read onto a fine log grid the way the authoring sheet reads them — magnitude log–log, phase straight — and integrated there.

The result is a density per band (interpolation 'bin', the bands' widths carried), which is what a banded measurement is compared against band for band. Brandon, 2026-09-18: the specification and the measurement it judges should be convertible alike, so a compliance can be read on bands at both ends — reversing the earlier reasoning (PLAN.md, "Octave bands") that a written curve needed no banding because the comparison integrates it exactly; it does, and a banded specification is still the object a person asks to see and hand on.

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
Specification

The same power and the same limits, 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) -> Specification:
    """This specification integrated onto proportional bands, its
    warning and abort limits with it.

    The same area rule as `Psd.to_octave`, read the way the object
    is: a specification computed on lines is a density and each
    line is a bin; one written at breakpoints is a power law
    between them, and each band takes the exact area under that law
    (`compliance.log_log_area`) over the part of the band the
    specification covers, divided by the band's width. The limits
    are curves written the same way and go through the same rule,
    so a band's warning line stands in the same relation to its
    target as the breakpoints did. Cross terms of a breakpoint
    specification are not power laws (a phase is not), so they are
    read onto a fine log grid the way the authoring sheet reads
    them — magnitude log–log, phase straight — and integrated there.

    The result is a density per band (`interpolation` 'bin', the
    bands' widths carried), which is what a banded measurement is
    compared against band for band. Brandon, 2026-09-18: the
    specification and the measurement it judges should be
    convertible alike, so a compliance can be read on bands at both
    ends — reversing the earlier reasoning (PLAN.md, "Octave
    bands") that a written curve needed no banding because the
    comparison integrates it exactly; it does, and a banded
    specification is still the object a person asks to see and
    hand on.

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

    Returns
    -------
    Specification
        The same power and the same limits, arranged on
        proportional bands.
    """
    from .compliance import log_log_area, written_band
    from .octave import resample

    frequencies = np.asarray(self.abscissa, dtype=float)
    rows = np.atleast_2d(self.ordinate)
    if self.interpolation == 'log_log':
        # the grid spans what the curve is written over — its first
        # breakpoint to its last — not bins inferred between
        # breakpoints, which put the first band at the midpoint of
        # the first segment and lost everything below it
        spans = [written_band(frequencies, np.abs(row)) for row in rows]
        spans = [span for span in spans if span is not None]
        if not spans:
            raise ValueError('octave bands need a specification written '
                             'over a range of frequencies above zero')
        from .octave import PER_OCTAVE, bands

        per_octave = PER_OCTAVE if per_octave is None else int(per_octave)
        low = min(span[0] for span in spans) if low is None else float(low)
        high = (max(span[1] for span in spans) if high is None
                else float(high))
        centers, widths, bounds = bands(low, high, per_octave)

        def band_real(row):
            out = np.full(len(centers), np.nan)
            band = written_band(frequencies, row)
            if band is None:
                return out
            for k in range(len(centers)):
                lo = max(bounds[k], band[0])
                hi = min(bounds[k + 1], band[1])
                if hi > lo:
                    out[k] = log_log_area(frequencies, row, lo, hi) \
                        / widths[k]
            return out

        def band_complex(row):
            from .author import read_at_lines

            out = np.full(len(centers), np.nan, dtype=complex)
            band = written_band(frequencies, np.abs(row))
            if band is None:
                return out
            for k in range(len(centers)):
                lo = max(bounds[k], band[0])
                hi = min(bounds[k + 1], band[1])
                if hi > lo:
                    grid = np.geomspace(lo, hi, self._CROSS_POINTS)
                    values = read_at_lines(frequencies, row, grid)
                    out[k] = np.trapezoid(values, grid) / widths[k]
            return out

        banded = np.array([
            band_complex(row) if np.iscomplexobj(row) and np.any(row.imag)
            else band_real(np.real(row)) for row in rows])
        limits = {name: np.array([band_real(row) for row in values])
                  for name, values in self.limits.items()}
    else:
        per_octave, centers, widths, bounds = self._octave_grid(
            per_octave, low, high)
        source = self.bin_bounds()
        banded = resample(frequencies, rows, bounds, widths, source=source)
        limits = {}
        for name, values in self.limits.items():
            values = np.atleast_2d(values)
            # a limit row that is all NaN is a cross term's, which
            # has no limit of its own; the integration would read
            # its NaN as nothing and hand back zeros
            unwritten = ~np.isfinite(values).any(axis=1)
            out = resample(frequencies, values, bounds, widths,
                           source=source).astype(float)
            out[unwritten] = np.nan
            limits[name] = out
    out = Specification(
        centers, 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',
        **limits)
    out.interpolation = 'bin'
    out.scale_db = self.scale_db
    constraints = getattr(self, 'band_constraints', None)
    if constraints:
        out.band_constraints = dict(constraints)
    return out

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.

default_averaging

The averaging a view of this record opens on.

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_sine_extraction

The extraction setting a record starts from: automatic

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.

default_role

The role a channel measuring quantity has when nothing

channel_identities

Every channel as (DOF, quantity), in first-record order —

channel_roles

Every channel's role, {(DOF, quantity): role}: the one

reference_channels

The channels an FRF or a coherence takes as references, as

response_channels

The channels an FRF or a coherence explains, as (DOF,

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 = scipy_fft.rfft(workers=-1, x=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.

A record that already carries an averaging — a controller's own recipe from an import, or one set by hand — keeps its frame length, window, overlap and detrend: only the start and the count are worked out (Brandon, 2026-09-19: Detect must not override the frame length, window and overlap the controller used). A bare record gets the detector's own recipe.

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`.

    A record that already carries an averaging — a controller's own
    recipe from an import, or one set by hand — keeps its frame
    length, window, overlap and detrend: only the start and the
    count are worked out (Brandon, 2026-09-19: Detect must not
    override the frame length, window and overlap the controller
    used). A bare record gets the detector's own recipe.

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

    Returns
    -------
    Averaging
        Parameters worked out from the record itself.
    """
    from dataclasses import replace

    from .detect import suggest

    own = self.averaging
    if own is not None:
        kwargs.setdefault('frame_length', own.frame_length)
        kwargs.setdefault('window', own.window)
        kwargs.setdefault('overlap', own.overlap)
    found = suggest(self, **kwargs)
    if own is not None:
        found = replace(found, detrend=own.detrend,
                        window_parameter=own.window_parameter)
    return found
default_averaging
default_averaging() -> Averaging

The averaging a view of this record opens on.

The record's own when its start was chosen — by a file's import that detected it, a drag, a typed number. Otherwise the detector's answer, keeping whatever recipe the record carries (frame length, window, overlap), as suggest_averaging does — unless that answer holds fewer frames than the record's own asks, when the record's own stands: a burst record has no settled stretch to find, and the detector once returned four of the twenty averages a modal survey asked for (2026-09-19). A bare record gets the detector's recipe; one that cannot be framed at all, each record as one frame (Brandon, 2026-09-30: the view should open where Detect would put it, not at t = 0).

Returns:

Type Description
Averaging
Source code in src/visualdynamics/core/data.py
def default_averaging(self) -> Averaging:
    """The averaging a view of this record opens on.

    The record's own when its start was chosen — by a file's import
    that detected it, a drag, a typed number. Otherwise the
    detector's answer, keeping whatever recipe the record carries
    (frame length, window, overlap), as `suggest_averaging` does —
    unless that answer holds fewer frames than the record's own
    asks, when the record's own stands: a burst record has no
    settled stretch to find, and the detector once returned four of
    the twenty averages a modal survey asked for (2026-09-19). A
    bare record gets the detector's recipe; one that cannot be
    framed at all, each record as one frame (Brandon, 2026-09-30:
    the view should open where Detect would put it, not at t = 0).

    Returns
    -------
    Averaging
    """
    from .averaging import Averaging

    own = self.averaging
    if own is not None and own.start_set:
        return own
    try:
        found = self.suggest_averaging()
    except ValueError:
        return (own if own is not None
                else Averaging.for_records(len(self.abscissa)))
    if own is not None and found.frames < own.frames:
        return own
    return found
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_sine_extraction
suggest_sine_extraction(specification: Any = None) -> Any

The extraction setting a record starts from: automatic smoothing — chosen from the recording against the specification when the levels are read — with the clock refinement on.

Parameters:

Name Type Description Default
specification SineSweepSpecification

The sweep the levels would be read against. When given, the smoothing the automatic would choose is worked out now and rides the setting as chosen, so the sine view can show it before anything is extracted.

None

Returns:

Type Description
SineExtraction
Source code in src/visualdynamics/core/data.py
def suggest_sine_extraction(self, specification: Any = None) -> Any:
    """The extraction setting a record starts from: automatic
    smoothing — chosen from the recording against the
    specification when the levels are read — with the clock
    refinement on.

    Parameters
    ----------
    specification : SineSweepSpecification, optional
        The sweep the levels would be read against. When given,
        the smoothing the automatic would choose is worked out now
        and rides the setting as `chosen`, so the sine view can
        show it before anything is extracted.

    Returns
    -------
    SineExtraction
    """
    from .sine import SineExtraction, suggest_cycles

    setting = SineExtraction()
    if specification is not None:
        from dataclasses import replace

        setting = replace(setting, chosen=suggest_cycles(
            self, specification, setting.target_db))
    return setting
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 judgment, 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 judgment, 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) except at DC and Nyquist, which have no mirror image to fold in and so are |X|²/(fs·N) — as scipy's welch has it, and what makes the spectrum's total the record's mean square. The frames' powers are 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) except at DC
    and Nyquist, which have no mirror image to fold in and so are
    |X|²/(fs·N) — as scipy's welch has it, and what makes the
    spectrum's total the record's mean square. The frames' powers
    are 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 = scipy_fft.rfft(workers=-1, x=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), not doubled at DC and Nyquist, 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), not doubled at DC and Nyquist, 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: scipy_fft.rfft(workers=-1, x=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))
default_role classmethod
default_role(quantity: str) -> str

The role a channel measuring quantity has when nothing has said otherwise.

Parameters:

Name Type Description Default
quantity str

What the channel measures, as ordinate_dim names it.

required

Returns:

Type Description
str

'reference', 'response' or 'monitor'.

Source code in src/visualdynamics/core/data.py
@classmethod
def default_role(cls, quantity: str) -> str:
    """The role a channel measuring `quantity` has when nothing
    has said otherwise.

    Parameters
    ----------
    quantity : str
        What the channel measures, as `ordinate_dim` names it.

    Returns
    -------
    str
        'reference', 'response' or 'monitor'.
    """
    if quantity in cls.REFERENCE_DIMS:
        return 'reference'
    if quantity in cls.RESPONSE_DIMS:
        return 'response'
    return 'monitor'
channel_identities
channel_identities() -> list[tuple[str, str]]

Every channel as (DOF, quantity), in first-record order — the identity roles names a channel by.

Source code in src/visualdynamics/core/data.py
def channel_identities(self) -> list[tuple[str, str]]:
    """Every channel as (DOF, quantity), in first-record order —
    the identity `roles` names a channel by."""
    return list(dict.fromkeys(zip(self.response_dof, self.ordinate_dim)))
channel_roles
channel_roles(stated: bool = True) -> dict[tuple[str, str], str]

Every channel's role, {(DOF, quantity): role}: the one reading FRFs, multiple coherence and the grid's Role column share. What roles says, and for a channel it does not name, the default from the quantity (default_role): a force a reference, a motion a response, anything else a monitor.

Parameters:

Name Type Description Default
stated bool

False gives the guess alone, whatever roles says — what the fingerprint compares against, so stating the roles the guess would give changes nothing derived.

True

Returns:

Type Description
dict of (str, str) to str

Every channel's role, in first-record order.

Source code in src/visualdynamics/core/data.py
def channel_roles(self, stated: bool = True
                  ) -> dict[tuple[str, str], str]:
    """Every channel's role, {(DOF, quantity): role}: the one
    reading FRFs, multiple coherence and the grid's Role column
    share. What `roles` says, and for a channel it does not name,
    the default from the quantity (`default_role`): a force a
    reference, a motion a response, anything else a monitor.

    Parameters
    ----------
    stated : bool, default True
        False gives the guess alone, whatever `roles` says — what
        the fingerprint compares against, so stating the roles the
        guess would give changes nothing derived.

    Returns
    -------
    dict of (str, str) to str
        Every channel's role, in first-record order.
    """
    stated = {(str(dof), str(quantity)): str(role) for (dof, quantity),
              role in ((self.roles or {}) if stated else {}).items()}
    return {identity: stated.get(identity,
                                 self.default_role(identity[1]))
            for identity in self.channel_identities()}
reference_channels
reference_channels(references: Sequence[Any] | None = None) -> list[tuple[str, str]]

The channels an FRF or a coherence takes as references, as (DOF, quantity) pairs.

Omitted, the channels whose role is 'reference' (channel_roles). A call may instead name them, as a script does — as (DOF, quantity) pairs, or by bare DOF, where a DOF picks the excitation channel at the point (the force at a drive point, not the accelerometer beside it, which is what a controller computes against) and only where there is none does the DOF alone decide.

Parameters:

Name Type Description Default
references sequence of str or (str, str)

References to use in place of the roles.

None

Returns:

Type Description
list of (str, str)

The reference channels, each once; a name that matches no channel is left out, and the caller says so.

Source code in src/visualdynamics/core/data.py
def reference_channels(self, references: Sequence[Any] | None = None
                       ) -> list[tuple[str, str]]:
    """The channels an FRF or a coherence takes as references, as
    (DOF, quantity) pairs.

    Omitted, the channels whose role is 'reference'
    (`channel_roles`). A call may instead name them, as a script
    does — as (DOF, quantity) pairs, or by bare DOF, where a DOF
    picks the excitation channel at the point (the force at a drive
    point, not the accelerometer beside it, which is what a
    controller computes against) and only where there is none does
    the DOF alone decide.

    Parameters
    ----------
    references : sequence of str or (str, str), optional
        References to use in place of the roles.

    Returns
    -------
    list of (str, str)
        The reference channels, each once; a name that matches no
        channel is left out, and the caller says so.
    """
    identities = self.channel_identities()
    if references is None:
        roles = self.channel_roles()
        return [identity for identity in identities
                if roles[identity] == 'reference']
    chosen: list[tuple[str, str]] = []
    for item in references:
        if isinstance(item, str):
            at = [identity for identity in identities if identity[0] == item]
            driven = [identity for identity in at
                      if identity[1] in self.EXCITATION_DIMS]
            picked = (driven or at)[:1]
        else:
            dof, quantity = item
            picked = [identity for identity in identities
                      if identity == (str(dof), str(quantity))]
        for identity in picked:
            if identity not in chosen:
                chosen.append(identity)
    return chosen
response_channels
response_channels(references: Sequence[Any] | None = None) -> list[tuple[str, str]]

The channels an FRF or a coherence explains, as (DOF, quantity) pairs: every channel that is neither a reference nor a monitor. With references named by the call, a channel whose role is 'reference' but that the call left out is a response — a script asking for one drive of two gets the other explained — and a monitor stays out either way.

Parameters:

Name Type Description Default
references sequence of str or (str, str)

As reference_channels takes them.

None

Returns:

Type Description
list of (str, str)
Source code in src/visualdynamics/core/data.py
def response_channels(self, references: Sequence[Any] | None = None
                      ) -> list[tuple[str, str]]:
    """The channels an FRF or a coherence explains, as (DOF,
    quantity) pairs: every channel that is neither a reference nor a
    monitor. With `references` named by the call, a channel whose
    role is 'reference' but that the call left out is a response —
    a script asking for one drive of two gets the other explained —
    and a monitor stays out either way.

    Parameters
    ----------
    references : sequence of str or (str, str), optional
        As `reference_channels` takes them.

    Returns
    -------
    list of (str, str)
    """
    drives = set(self.reference_channels(references))
    roles = self.channel_roles()
    return [identity for identity in self.channel_identities()
            if identity not in drives and roles[identity] != 'monitor']
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.

References

The coupled multi-reference H2 form is Rocklin, Crowley and Vold's; the rest set out the three estimators and their errors.

  1. Rocklin, G. T., Crowley, J., & Vold, H. (1985). "A comparison of H1, H2 and Hv frequency response functions." Proceedings of the International Modal Analysis Conference (IMAC).
  2. Ewins, D. J. (2000). Modal Testing: Theory, Practice and Application, 2nd ed. Research Studies Press. Which estimator biases which way, and why the bias is worst exactly where the measurement matters.
  3. Bendat, J. S., & Piersol, A. G. (2010). Random Data: Analysis and Measurement Procedures, 4th ed. Wiley. The single- and multiple-input frequency response estimates and their errors.
  4. Van Huffel, S., & Vandewalle, J. (1991). The Total Least Squares Problem: Computational Aspects and Analysis. SIAM. The fit Hv performs, and what it means to weigh error in one measured quantity against error in another.
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.

    References
    ----------
    The coupled multi-reference H2 form is Rocklin, Crowley and
    Vold's; the rest set out the three estimators and their
    errors.

    1. Rocklin, G. T., Crowley, J., & Vold, H. (1985). "A
       comparison of H1, H2 and Hv frequency response functions."
       *Proceedings of the International Modal Analysis Conference
       (IMAC)*.
    2. Ewins, D. J. (2000). *Modal Testing: Theory, Practice and
       Application*, 2nd ed. Research Studies Press. Which
       estimator biases which way, and why the bias is worst
       exactly where the measurement matters.
    3. Bendat, J. S., & Piersol, A. G. (2010). *Random Data:
       Analysis and Measurement Procedures*, 4th ed. Wiley. The
       single- and multiple-input frequency response estimates and
       their errors.
    4. Van Huffel, S., & Vandewalle, J. (1991). *The Total Least
       Squares Problem: Computational Aspects and Analysis*. SIAM.
       The fit Hv performs, and what it means to weigh error in
       one measured quantity against error in another.
    """
    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.

References

The multiple coherence function is standard; these are where it and the bias of an estimate of it are set out.

  1. Bendat, J. S., & Piersol, A. G. (2010). Random Data: Analysis and Measurement Procedures, 4th ed. Wiley. Multiple-input systems: the multiple coherence function, read as the fraction of a response's power the whole set of references accounts for.
  2. Carter, G. C., Knapp, C. H., & Nuttall, A. H. (1973). "Estimation of the magnitude-squared coherence function via overlapped fast Fourier transform processing." IEEE Transactions on Audio and Electroacoustics, 21(4), 337-344. doi:10.1109/TAU.1973.1162496 The bias of a coherence estimate against the number of averages, which is why a coherence from too few frames reads high.
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.

    References
    ----------
    The multiple coherence function is standard; these are where
    it and the bias of an estimate of it are set out.

    1. Bendat, J. S., & Piersol, A. G. (2010). *Random Data:
       Analysis and Measurement Procedures*, 4th ed. Wiley.
       Multiple-input systems: the multiple coherence function,
       read as the fraction of a response's power the whole set of
       references accounts for.
    2. Carter, G. C., Knapp, C. H., & Nuttall, A. H. (1973).
       "Estimation of the magnitude-squared coherence function via
       overlapped fast Fourier transform processing." *IEEE
       Transactions on Audio and Electroacoustics*, 21(4),
       337-344.
       [doi:10.1109/TAU.1973.1162496](https://doi.org/10.1109/TAU.1973.1162496)
       The bias of a coherence estimate against the number of
       averages, which is why a coherence from too few frames
       reads high.
    """
    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

View

View(eye: ArrayLike = (1.0, 1.0, 1.0), up: ArrayLike = (0.0, 0.0, 1.0))

The way a geometry opens in 3-D: where the eye is, seen from the model's center, and which way is up on screen.

Only a direction: every view is fitted to what it shows, so there is no distance or zoom to keep. Set once on a geometry, it is how the app opens it and everything drawn on it, how Reset View returns, and how the report's scenes and exported figures are drawn (Brandon, 2026-09-27) — a model built Y-up opens upright without its nodes being turned.

Parameters:

Name Type Description Default
eye sequence of 3 floats

The direction from the model toward the eye, in the global frame; any length.

(1, 1, 1)
up sequence of 3 floats

Which way is up on screen. Only its part across the line of sight counts, so it need not be exactly perpendicular to eye.

(0, 0, 1)

Examples:

A model built with Y up, seen from the front, above and to the right:

>>> geometry.view = View(eye=(1, 1, -1), up=(0, 1, 0))

Methods:

Name Description
basis

(right, up) on screen, as unit vectors in the global frame —

Source code in src/visualdynamics/core/geometry.py
def __init__(self, eye: ArrayLike = (1.0, 1.0, 1.0),
             up: ArrayLike = (0.0, 0.0, 1.0)) -> None:
    # kept as written, so a view reads back the way it was stated;
    # every use goes through `_unit`
    self.eye: tuple[float, ...] = tuple(
        float(v) for v in np.asarray(eye, dtype=float).reshape(3))
    self.up: tuple[float, ...] = tuple(
        float(v) for v in np.asarray(up, dtype=float).reshape(3))
    eye_length = float(np.linalg.norm(self.eye))
    if not np.isfinite(eye_length) or eye_length == 0.0:
        raise ValueError('a view needs a direction to look from')
    up_length = float(np.linalg.norm(self.up))
    if not np.isfinite(up_length) or up_length == 0.0:
        raise ValueError('a view needs a direction for up')
    # the part of `up` across the line of sight is what the screen
    # shows as up; along it, there is none to show
    if np.linalg.norm(np.cross(self.eye, self.up)) < \
            1e-9 * eye_length * up_length:
        raise ValueError('up cannot lie along the line of sight')
Methods:
basis
basis() -> tuple[tuple[float, ...], tuple[float, ...]]

(right, up) on screen, as unit vectors in the global frame — the camera's own: right is the line of sight crossed with up.

Source code in src/visualdynamics/core/geometry.py
def basis(self) -> tuple[tuple[float, ...], tuple[float, ...]]:
    """(right, up) on screen, as unit vectors in the global frame —
    the camera's own: right is the line of sight crossed with up."""
    eye, up = self._unit()
    return (tuple(float(v) for v in np.cross(up, eye)),
            tuple(float(v) for v in up))

Project

Project(name: str = 'Project', objects: dict[str, Any] | None = None, active_geometry: str | None = None, project_type: str | None = None, object_groups: Iterable[ObjectGroup] | 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 object 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.

name_object_group

Name the object group an object belongs to, or unname it.

object_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.

sides

{side: [object names]} for the typed skeleton: the Basis by

missing

The typed skeleton's empty slots — what the tree shows gray.

object_group_with_role

The object group carrying a role, or None.

place

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

set_channel_role

Give one channel a role — reference, response or monitor —

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 object 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

merge_coincident_nodes

Make a geometry's coincident nodes one node (Merge Coincident

new_geometry

An empty geometry, to build a model in (the project's +):

set_view

Set the view a geometry opens on in 3-D, in the app, the report

add_plane

Add a meshed rectangle of plates to a geometry (Add Plane): a

tie_elements

Tie a patch of a geometry's elements rigidly to the part under

merge_groups

Merge a geometry's element groups into one (Merge Element Groups): the first

add_block

Add a meshed box of solid bricks to a geometry (Add Block):

solve_modes

The normal modes of a geometry whose element groups carry their

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

work_up

Every missing object the project's type expects, computed

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 file: .vdyn, .mat for

open

Read a project back, from .vdyn, .mat or an ESCDF .h5

journal_as

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

record_setting

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

record_call

A method call on an object, journaled 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.

object_group_selections list[Selection]

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

other Selection

The one object 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,
             object_groups: Iterable[ObjectGroup] | 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
    # ... and, since 2026-09-30, an optional 'name': what the group
    # is called, which an ESCDF activity needs and a tree can show
    self.object_groups: 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,
         **({'name': str(group['name'])} if group.get('name') else {})}
        for group in (object_groups or [])]
    #: the roles the last link moved, (history, channel, was, now)
    #: — a front end says so rather than letting FRFs move silently
    self.last_role_changes: list = []
    #: 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.

object_group_selections property
object_group_selections: list[Selection]

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

other property
other: Selection

The one object 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[ObjectGroup | None, list[str]]]

[(group or None, [names])] in the order the tree shows them: the Basis group first, then the other object 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[ObjectGroup | None, list[str]]]:
    """[(group or None, [names])] in the order the tree shows them:
    the Basis group first, then the other object 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.object_groups, 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 object 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
    object 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.object_groups += remap_links(
            result.object_groups, mapping,
            {group['role'] for group in self.object_groups 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 object 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 object 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.

Object 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.

    Object 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.object_groups:
        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 labeled 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 mislabeled is mislabeled 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, normalized 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 labeled 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 mislabeled is mislabeled 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, normalized 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, name: 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
name str

What to call the group (Brandon, 2026-09-30: an activity in the Engineering Sciences Common Data Format has a name, and a named group exports as one that keeps its identity). Left out, a group being merged into keeps the name it had.

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,
         name: 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.
    name : str, optional
        What to call the group (Brandon, 2026-09-30: an activity in
        the Engineering Sciences Common Data Format has a name, and
        a named group exports as one that keeps its identity). Left
        out, a group being merged into keeps the name it had.

    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.object_groups:
        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 blocks_a_link, 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)
        # a few points the geometry does not draw are a test's
        # virtual channels, not a different structure: the
        # indicator names them and the link stands
        if blocks_a_link(issue):
            raise ValueError(f'cannot link: {issue.message}')
    kept_name = name or next(
        (group.get('name') for group in self.object_groups
         if any(n in group['members'] for n in names) and group.get('name')),
        None)
    self.object_groups = kept + [{'members': merged, 'role': role,
                          **({'name': kept_name} if kept_name else {})}]
    if role is not None:
        self.set_role(merged[0], role)
    # a channel table joining time data it describes brings its
    # roles; the history is the truth from then on
    self._sync_linked_roles(merged)
    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.object_groups = [
        {'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.object_groups)
        # 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.object_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.object_groups]
    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.object_groups = before
        raise
name_object_group
name_object_group(member: str, name: str | None) -> list[str]

Name the object group an object belongs to, or unname it.

Parameters:

Name Type Description Default
member str

Any member of the group, by name.

required
name str or None

The group's new name; None removes it.

required

Returns:

Type Description
list of str

The group's members.

Source code in src/visualdynamics/project.py
def name_object_group(self, member: str, name: str | None) -> list[str]:
    """Name the object group an object belongs to, or unname it.

    Parameters
    ----------
    member : str
        Any member of the group, by name.
    name : str or None
        The group's new name; None removes it.

    Returns
    -------
    list of str
        The group's members.
    """
    for group in self.object_groups:
        if member in group['members']:
            if name:
                group['name'] = str(name)
            else:
                group.pop('name', None)
            return list(group['members'])
    raise KeyError(f'{member!r} is in no object group')
object_group_of
object_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 object group, or None when it is in no group.

Source code in src/visualdynamics/project.py
def object_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 object 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 object 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 object 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 gray 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 gray 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.object_groups:
        if group['role']:
            out.setdefault(group['role'],
                           []).extend(group['members'])
    return out
sides
sides() -> dict[str, list[str]]

{side: [object names]} for the typed skeleton: the Basis by its role, and under OTHER_SIDE every member of every group that is not the Basis — the other side has no name, so it is read off the groups rather than declared. What the tree's gray slots and missing are computed from.

Returns:

Type Description
dict of str to list of str
Source code in src/visualdynamics/project.py
def sides(self) -> dict[str, list[str]]:
    """{side: [object names]} for the typed skeleton: the Basis by
    its role, and under `OTHER_SIDE` every member of every group
    that is not the Basis — the other side has no name, so it is
    read off the groups rather than declared. What the tree's gray
    slots and `missing` are computed from.

    Returns
    -------
    dict of str to list of str
    """
    from .core.report import OTHER_SIDE

    sides = dict(self.placed())
    others = [name for group in self.object_groups if group['role'] != 'Basis'
              for name in group['members']]
    if others:
        sides[OTHER_SIDE] = others
    return sides
missing
missing() -> list[tuple[str, type, str, int, bool, str | None]]

The typed skeleton's empty slots — what the tree shows gray.

Each is project_expectations' (label, class, icon key, ordinal, optional, side). An untyped project expects nothing. Until a Basis is declared every object counts for every slot: the window places what arrives, a script may never, and a script's project with everything in it and no groups is not one with nothing in it.

Returns:

Type Description
list of tuple
Source code in src/visualdynamics/project.py
def missing(self) -> list[tuple[str, type, str, int, bool, str | None]]:
    """The typed skeleton's empty slots — what the tree shows gray.

    Each is `project_expectations`' (label, class, icon key,
    ordinal, optional, side). An untyped project expects nothing.
    Until a Basis is declared every object counts for every slot:
    the window places what arrives, a script may never, and a
    script's project with everything in it and no groups is not
    one with nothing in it.

    Returns
    -------
    list of tuple
    """
    from .core.report import missing_expectations

    if not self.project_type:
        return []
    sides = self.sides()
    return missing_expectations(self.project_type, dict(self.items()),
                                sides if 'Basis' in sides else None)
object_group_with_role
object_group_with_role(role: str) -> ObjectGroup | None

The object 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
ObjectGroup or None

That group, or None if unset.

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

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

    Returns
    -------
    ObjectGroup or None
        That group, or None if unset.
    """
    return next((group for group in self.object_groups
                 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.object_groups 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.object_groups]
        self.unlink(name)
        try:
            if not others:
                self.object_groups = self.object_groups + [
                    {'members': [name], 'role': None}]
                return [name]
            return self.link(*others[0]['members'], name)
        except (ValueError, KeyError):
            self.object_groups = before
            raise
    group = self.object_group_with_role(role)
    if group is not None and name in group['members']:
        return list(group['members'])
    before = [dict(existing) for existing in self.object_groups]
    self.unlink(name)
    group = self.object_group_with_role(role)
    try:
        if group is None:
            self.object_groups = self.object_groups + [
                {'members': [name], 'role': None}]
            self.set_role(name, role)
            return [name]
        return self.link(*group['members'], name, role=role)
    except (ValueError, KeyError):
        self.object_groups = before
        raise
set_channel_role
set_channel_role(source: Any, dof: str, quantity: str, role: str) -> list[str]

Give one channel a role — reference, response or monitor — on a time history or on a channel table, and on whatever is linked with it.

The time history's role is the truth: it is what FRFs and multiple coherence read. A channel table linked with a history whose channels it describes shows the history's roles, so a change made on either lands on both (Brandon, 2026-09-25: the channel table is just a pointer to the linked time data's role). A channel table with no such history holds its own roles, as it always did.

Parameters:

Name Type Description Default
source str or object

The time history or channel table, by name or as itself.

required
dof str

The channel's degree of freedom, '101Z+'.

required
quantity str

What it measures, as a history's ordinate_dim or a table's channel type names it ('acceleration', 'force', 'length' for a displacement).

required
role str

'reference', 'response' or 'monitor'.

required

Returns:

Type Description
list of str

The objects whose roles changed.

Source code in src/visualdynamics/project.py
def set_channel_role(self, source: Any, dof: str, quantity: str,
                     role: str) -> list[str]:
    """Give one channel a role — reference, response or monitor —
    on a time history or on a channel table, and on whatever is
    linked with it.

    The time history's role is the truth: it is what FRFs and
    multiple coherence read. A channel table linked with a history
    whose channels it describes shows the history's roles, so a
    change made on either lands on both (Brandon, 2026-09-25: *the
    channel table is just a pointer to the linked time data's
    role*). A channel table with no such history holds its own
    roles, as it always did.

    Parameters
    ----------
    source : str or object
        The time history or channel table, by name or as itself.
    dof : str
        The channel's degree of freedom, '101Z+'.
    quantity : str
        What it measures, as a history's `ordinate_dim` or a
        table's channel type names it ('acceleration', 'force',
        'length' for a displacement).
    role : str
        'reference', 'response' or 'monitor'.

    Returns
    -------
    list of str
        The objects whose roles changed.
    """
    from .core.channel_table import ROLES

    if role not in ROLES:
        raise ValueError(f'{role!r} is not a role: ' + ', '.join(ROLES))
    name = self.name_of(source)
    obj = self[name]
    asked = (str(dof), str(quantity))
    if isinstance(obj, TimeHistory):
        targets = [(name, asked)]
    elif isinstance(obj, ChannelTable):
        rows = [row for row, own in enumerate(_table_identities(obj, None))
                if own == asked]
        if not rows:
            raise ValueError(f'{name} has no channel {dof} ({quantity})')
        # the table row names a channel of each linked history —
        # through its channel type, or its DOF alone when the type
        # is blank and the DOF has one channel there
        targets = [(other, mapped[row]) for other, mapped
                   in self._role_partners(name) for row in rows
                   if mapped[row] is not None]
        if not targets:
            # no time data it describes: the table's own record
            for row in rows:
                obj.set_cell('role', row, role)
            return [name]
    else:
        raise TypeError(f'{name!r} is neither time data nor a channel '
                        'table')
    for history_name, identity in targets:
        if identity not in self[history_name].channel_identities():
            raise ValueError(f'{history_name} has no channel {identity[0]} '
                             f'({identity[1]})')
    changed = []
    for history_name, identity in targets:
        history = self[history_name]
        roles = history.channel_roles()
        roles[identity] = role
        history.roles = roles
        changed.append(history_name)
        changed += self._push_roles(history_name)
    return list(dict.fromkeys(changed))
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 labeled.

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 labeled.
    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 an object group')
    if role is not None:
        for other in self.object_groups:
            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.object_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[ObjectGroup]) -> None

Take on the object 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 ObjectGroup

Object 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[ObjectGroup]) -> None:
    """Take on the object 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 ObjectGroup
        Object groups from another project, merged into this one's.

    Returns
    -------
    None
    """
    self.object_groups = list(self.object_groups) + [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; a specification with its warning and abort limits banded the same way.

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;
    a specification with its warning and abort limits banded the
    same way.

    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 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:

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-normalize 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-normalize 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', {}))
merge_coincident_nodes
merge_coincident_nodes(source: Any, tolerance: float | None = None) -> dict

Make a geometry's coincident nodes one node (Merge Coincident Nodes): elements renamed to the lowest id at each point, the rest removed. Plates connect only where they share nodes, so this is what ties planes meshed apart at the lines where they meet.

Refused while an object linked with the geometry names a node the merge would remove — its data would point at a node that is gone; merge first, then measure or solve.

Parameters:

Name Type Description Default
source str or object

The geometry, by name or as the object itself.

required
tolerance float

How close two nodes must be to be one, in meters (as the geometry holds its coordinates). Defaults to a millionth of the geometry's size.

None

Returns:

Type Description
dict

'merged', the nodes removed, and 'into', the nodes they became.

Source code in src/visualdynamics/project.py
def merge_coincident_nodes(self, source: Any,
                           tolerance: float | None = None) -> dict:
    """Make a geometry's coincident nodes one node (Merge Coincident
    Nodes): elements renamed to the lowest id at each
    point, the rest removed. Plates connect only where they share
    nodes, so this is what ties planes meshed apart at the lines where
    they meet.

    Refused while an object linked with the geometry names a node the
    merge would remove — its data would point at a node that is gone;
    merge first, then measure or solve.

    Parameters
    ----------
    source : str or object
        The geometry, by name or as the object itself.
    tolerance : float, optional
        How close two nodes must be to be one, in meters (as the
        geometry holds its coordinates). Defaults to a millionth of
        the geometry's size.

    Returns
    -------
    dict
        'merged', the nodes removed, and 'into', the nodes they
        became.
    """
    name = self.name_of(source)
    geometry = self[name]
    if not isinstance(geometry, Geometry):
        raise TypeError(f'{name!r} is not a geometry')
    if tolerance is None:
        low, high = geometry.extent
        tolerance = 1e-6 * float(np.linalg.norm(high - low) or 1.0)
    going = geometry.coincident_nodes(tolerance)
    for other in self.object_group_of(name) or []:
        obj = self.get(other)
        dofs = [*(getattr(obj, 'response_dof', None) or []),
                *(getattr(obj, 'reference_dof', None) or []),
                *(getattr(obj, 'coordinate', None) or [])]
        if other == name or not dofs:
            continue
        named = {int(''.join(ch for ch in str(dof) if ch.isdigit()) or -1)
                 for dof in dofs}
        clash = sorted(named & set(going))
        if clash:
            raise ValueError(
                f'{other} names node {clash[0]}, which the merge would '
                'remove — merge before measuring or solving, or unlink '
                'it first')
    return geometry.merge_coincident_nodes(tolerance)
new_geometry
new_geometry(name: str = 'Geometry', *, unit: str = 'm') -> str

An empty geometry, to build a model in (the project's +): planes added with add_plane, links in the app's add mode.

Parameters:

Name Type Description Default
name str

What to call it.

'Geometry'
unit str

Its length unit. Coordinates are held in SI either way; this is the unit it remembers it was built in.

'm'

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 new_geometry(self, name: str = 'Geometry', *,
                 unit: str = 'm') -> str:
    """An empty geometry, to build a model in (the project's **+**):
    planes added with `add_plane`, links in the app's add mode.

    Parameters
    ----------
    name : str, default 'Geometry'
        What to call it.
    unit : str, default 'm'
        Its length unit. Coordinates are held in SI either way; this
        is the unit it remembers it was built in.

    Returns
    -------
    str
        The name the result was added under, unique within the
        project — a clash gets a numbered suffix.
    """
    return self.add(name, Geometry(node_id=[], node_xyz=np.empty((0, 3)),
                                   length_unit=unit))
set_view
set_view(source: Any, view: View | None = None) -> None

Set the view a geometry opens on in 3-D, in the app, the report and every exported figure.

The app's Set Default View captures the view on screen; from a script it is stated, a model built Y-up seen from the front and above, say. Everything drawn on the geometry — its shapes, an ODS, the DOF arrows — opens on it too, and Reset View returns to it. The nodes are not turned: only how the model is looked at (Brandon, 2026-09-27).

Parameters:

Name Type Description Default
source str or Geometry

The geometry, by name or as the object itself.

required
view View

Where the eye is, seen from the model's center, and which way is up; None goes back to the default isometric (from +X+Y+Z, Z up).

None

Returns:

Type Description
None

Examples:

>>> from visualdynamics import View
>>> project.set_view('BARC', View(eye=(1, 1, -1), up=(0, 1, 0)))
Source code in src/visualdynamics/project.py
def set_view(self, source: Any, view: View | None = None) -> None:
    """Set the view a geometry opens on in 3-D, in the app, the report
    and every exported figure.

    The app's Set Default View captures the view on screen; from a
    script it is stated, a model built Y-up seen from the front and
    above, say. Everything drawn on the geometry — its shapes, an
    ODS, the DOF arrows — opens on it too, and Reset View returns to
    it. The nodes are not turned: only how the model is looked at
    (Brandon, 2026-09-27).

    Parameters
    ----------
    source : str or Geometry
        The geometry, by name or as the object itself.
    view : View, optional
        Where the eye is, seen from the model's center, and which way
        is up; None goes back to the default isometric (from +X+Y+Z,
        Z up).

    Returns
    -------
    None

    Examples
    --------
    >>> from visualdynamics import View
    >>> project.set_view('BARC', View(eye=(1, 1, -1), up=(0, 1, 0)))  # doctest: +SKIP
    """
    name = self.name_of(source)
    geometry = self[name]
    if not isinstance(geometry, Geometry):
        raise TypeError(f'{name!r} is not a geometry')
    if view is not None and not isinstance(view, View):
        raise TypeError('view must be a View, or None for the default')
    geometry.view = view
add_plane
add_plane(source: Any, corner: Any, edge_a: Any, edge_b: Any, size: float, group: str = '', *, unit: str = 'm', tolerance: float | None = None) -> dict

Add a meshed rectangle of plates to a geometry (Add Plane): a corner, two perpendicular edges and an element size, each edge divided evenly into the whole number of elements nearest that size (mesh.plane). Its nodes that fall on nodes already there become them, so planes meeting along a line are tied there, and the nodes already there keep their ids (mesh.join).

Planes given the same element group name are one element group — the five walls of a box, each named 'box', are the box, given its material once in the Element Groups table.

Parameters:

Name Type Description Default
source str or object

The geometry, by name or as the object itself.

required
corner array_like

One corner, (x, y, z).

required
edge_a array_like

The two edges from that corner, as vectors; perpendicular.

required
edge_b array_like

The two edges from that corner, as vectors; perpendicular.

required
size float

The element size aimed at.

required
group str

The element group the plates go in, by name: an existing element group of that name, or a new one.

''
unit str

The unit the lengths above are in. A geometry whose units are not defined takes them as given.

'm'
tolerance float

How close a node must be to one already there to be it, in meters. Defaults to a millionth of the size of the two together.

None

Returns:

Type Description
dict

'added', the nodes added; 'shared', the plane's nodes that fell on nodes already there; 'elements', the plates added; 'groups', the element group they went into.

Source code in src/visualdynamics/project.py
def add_plane(self, source: Any, corner: Any, edge_a: Any, edge_b: Any,
              size: float, group: str = '', *, unit: str = 'm',
              tolerance: float | None = None) -> dict:
    """Add a meshed rectangle of plates to a geometry (Add Plane): a
    corner, two perpendicular edges and an element size, each edge
    divided evenly into the whole number of elements nearest that
    size (`mesh.plane`). Its nodes that fall on nodes already there
    become them, so planes meeting along a line are tied there, and
    the nodes already there keep their ids (`mesh.join`).

    Planes given the same element group name are one element group — the five walls
    of a box, each named 'box', are the box, given its material once
    in the Element Groups table.

    Parameters
    ----------
    source : str or object
        The geometry, by name or as the object itself.
    corner : array_like
        One corner, (x, y, z).
    edge_a, edge_b : array_like
        The two edges from that corner, as vectors; perpendicular.
    size : float
        The element size aimed at.
    group : str, optional
        The element group the plates go in, by name: an existing element group of
        that name, or a new one.
    unit : str, default 'm'
        The unit the lengths above are in. A geometry whose units
        are not defined takes them as given.
    tolerance : float, optional
        How close a node must be to one already there to be it, in
        meters. Defaults to a millionth of the size of the two
        together.

    Returns
    -------
    dict
        'added', the nodes added; 'shared', the plane's nodes that
        fell on nodes already there; 'elements', the plates added;
        'groups', the element group they went into.
    """
    from .core import mesh

    name = self.name_of(source)
    geometry = self[name]
    if not isinstance(geometry, Geometry):
        raise TypeError(f'{name!r} is not a geometry')
    defined = geometry.units_defined or not geometry.num_nodes
    part = mesh.plane(corner, edge_a, edge_b, size, group,
                      unit=unit if defined else None)
    return mesh.join(geometry, part, tolerance)
tie_elements
tie_elements(source: Any, elements: Any, to: Any, group: str | None = None) -> dict

Tie a patch of a geometry's elements rigidly to the part under it (Tie): each node of the patch linked by a rigid, massless link to the nearest node of to — a bolted joint, from the elements its washer covers (mesh.tie).

Parameters:

Name Type Description Default
source str or object

The geometry, by name or as the object itself.

required
elements sequence of int

The patch, by element id.

required
to str, int or sequence of int

An element group, by name or id, or a second patch, by element ids.

required
group str

The element group the links go into, made rigid if new. Defaults to the geometry's first rigid element group, or a new one named 'ties'.

None

Returns:

Type Description
dict

'links', how many were added; 'shared', patch nodes the target already holds; 'group', where the links went.

Source code in src/visualdynamics/project.py
def tie_elements(self, source: Any, elements: Any, to: Any,
                 group: str | None = None) -> dict:
    """Tie a patch of a geometry's elements rigidly to the part under
    it (Tie): each node of the patch linked by a rigid, massless link
    to the nearest node of `to` — a bolted joint, from the elements
    its washer covers (`mesh.tie`).

    Parameters
    ----------
    source : str or object
        The geometry, by name or as the object itself.
    elements : sequence of int
        The patch, by element id.
    to : str, int or sequence of int
        An element group, by name or id, or a second patch, by element ids.
    group : str, optional
        The element group the links go into, made rigid if new. Defaults to
        the geometry's first rigid element group, or a new one named 'ties'.

    Returns
    -------
    dict
        'links', how many were added; 'shared', patch nodes the
        target already holds; 'group', where the links went.
    """
    from .core import mesh

    name = self.name_of(source)
    geometry = self[name]
    if not isinstance(geometry, Geometry):
        raise TypeError(f'{name!r} is not a geometry')
    to = to if isinstance(to, (str, int)) else [int(e) for e in to]
    return mesh.tie(geometry, [int(e) for e in elements], to, group)
merge_groups
merge_groups(source: Any, groups: Any) -> dict

Merge a geometry's element groups into one (Merge Element Groups): the first keeps its id, name and properties and the others' elements move into it — refused unless they hold the same element types and carry the same material and thickness or section (Geometry.merge_refusal).

Parameters:

Name Type Description Default
source str or object

The geometry, by name or as the object itself.

required
groups sequence of int

The element groups, by id; the first is the one kept.

required

Returns:

Type Description
dict

'into', the element group kept; 'groups', how many merged into it; 'elements', how many elements moved.

Source code in src/visualdynamics/project.py
def merge_groups(self, source: Any, groups: Any) -> dict:
    """Merge a geometry's element groups into one (Merge Element Groups): the first
    keeps its id, name and properties and the others' elements move
    into it — refused unless they hold the same element types and
    carry the same material and thickness or section
    (`Geometry.merge_refusal`).

    Parameters
    ----------
    source : str or object
        The geometry, by name or as the object itself.
    groups : sequence of int
        The element groups, by id; the first is the one kept.

    Returns
    -------
    dict
        'into', the element group kept; 'groups', how many merged into it;
        'elements', how many elements moved.
    """
    name = self.name_of(source)
    geometry = self[name]
    if not isinstance(geometry, Geometry):
        raise TypeError(f'{name!r} is not a geometry')
    return geometry.merge_groups([int(b) for b in groups])
add_block
add_block(source: Any, corner: Any, edge_a: Any, edge_b: Any, edge_c: Any, size: float, group: str = '', *, unit: str = 'm', holes: Any = (), hole_group: str | None = None, tolerance: float | None = None) -> dict

Add a meshed box of solid bricks to a geometry (Add Block): a corner, three perpendicular edges and an element size, each edge divided evenly into the whole number of elements nearest that size (mesh.block), with cylindrical holes cut the way a structured mesh cuts them. Its nodes that fall on nodes already there become them, so blocks meeting over a face are tied there (mesh.join).

Blocks given the same element group name are one element group — the rails and uprights of a frame, each named 'frame', are the frame, given its material once in the Element Groups table.

Parameters:

Name Type Description Default
source str or object

The geometry, by name or as the object itself.

required
corner array_like

One corner, (x, y, z).

required
edge_a array_like

The three edges from that corner, as vectors; perpendicular.

required
edge_b array_like

The three edges from that corner, as vectors; perpendicular.

required
edge_c array_like

The three edges from that corner, as vectors; perpendicular.

required
size float

The element size aimed at.

required
group str

The element group the bricks go in, by name: an existing group of that name, or a new one.

''
unit str

The unit the lengths above are in. A geometry whose units are not defined takes them as given.

'm'
holes sequence of tuple

Holes as mesh.block takes them: (center, radius, axis) or (center, radius, axis, depth).

()
hole_group str

The element group the holes' bricks go to — an insert's — or None to leave them out.

None
tolerance float

How close a node must be to one already there to be it, in meters. Defaults to a millionth of the size of the two together.

None

Returns:

Type Description
dict

'added', the nodes added; 'shared', the box's nodes that fell on nodes already there; 'elements', the bricks added; 'groups', the element groups they went into.

Source code in src/visualdynamics/project.py
def add_block(self, source: Any, corner: Any, edge_a: Any, edge_b: Any,
              edge_c: Any, size: float, group: str = '', *,
              unit: str = 'm', holes: Any = (),
              hole_group: str | None = None,
              tolerance: float | None = None) -> dict:
    """Add a meshed box of solid bricks to a geometry (Add Block):
    a corner, three perpendicular edges and an element size, each
    edge divided evenly into the whole number of elements nearest
    that size (`mesh.block`), with cylindrical holes cut the way a
    structured mesh cuts them. Its nodes that fall on nodes already
    there become them, so blocks meeting over a face are tied there
    (`mesh.join`).

    Blocks given the same element group name are one element group —
    the rails and uprights of a frame, each named 'frame', are the
    frame, given its material once in the Element Groups table.

    Parameters
    ----------
    source : str or object
        The geometry, by name or as the object itself.
    corner : array_like
        One corner, (x, y, z).
    edge_a, edge_b, edge_c : array_like
        The three edges from that corner, as vectors; perpendicular.
    size : float
        The element size aimed at.
    group : str, optional
        The element group the bricks go in, by name: an existing
        group of that name, or a new one.
    unit : str, default 'm'
        The unit the lengths above are in. A geometry whose units
        are not defined takes them as given.
    holes : sequence of tuple, optional
        Holes as `mesh.block` takes them: (center, radius, axis) or
        (center, radius, axis, depth).
    hole_group : str, optional
        The element group the holes' bricks go to — an insert's —
        or None to leave them out.
    tolerance : float, optional
        How close a node must be to one already there to be it, in
        meters. Defaults to a millionth of the size of the two
        together.

    Returns
    -------
    dict
        'added', the nodes added; 'shared', the box's nodes that
        fell on nodes already there; 'elements', the bricks added;
        'groups', the element groups they went into.
    """
    from .core import mesh

    name = self.name_of(source)
    geometry = self[name]
    if not isinstance(geometry, Geometry):
        raise TypeError(f'{name!r} is not a geometry')
    defined = geometry.units_defined or not geometry.num_nodes
    part = mesh.block(corner, edge_a, edge_b, edge_c, size, group,
                      unit=unit if defined else None, holes=holes,
                      hole_name=hole_group)
    return mesh.join(geometry, part, tolerance)
solve_modes
solve_modes(source: Any, *, maximum_frequency: float | None = None, num_modes: int | None = None, damping: float = 0.0, name: str | None = None, progress: Any = None) -> str

The normal modes of a geometry whose element groups carry their properties (Solve Modes): the finite element model built from the element groups, solved, and the shapes added in the geometry's group.

The geometry is the model: each element group a material and a thickness or a section (fem.GroupProperties, set in the Element Groups table or on geometry.group_properties), every quad a plate, every triangle a triangle, every two-node line a beam (fem.Model.from_geometry). The solution is free-free unless the geometry says otherwise later; the six rigid-body modes come back at exactly 0 Hz with the elastic ones after them.

Parameters:

Name Type Description Default
source str or object

The geometry, by name or as the object itself.

required
maximum_frequency float

Solve for every mode up to this frequency, in Hz.

None
num_modes int

Or for this many modes, rigid ones included.

None
damping float

The fraction of critical damping every mode is given; a finite element model has none of its own.

0.0
name str

What to call the result. Defaults to the geometry's name with ' Modes' after it.

None
progress callable

Told (done, total) as the solve advances; the window's strip bar reads it, a script can print it.

None

Returns:

Type Description
str

The name the shape set was added under.

Source code in src/visualdynamics/project.py
def solve_modes(self, source: Any, *,
                maximum_frequency: float | None = None,
                num_modes: int | None = None, damping: float = 0.0,
                name: str | None = None, progress: Any = None) -> str:
    """The normal modes of a geometry whose element groups carry their
    properties (Solve Modes): the finite element model built from
    the element groups, solved, and the shapes added in the geometry's
    group.

    The geometry is the model: each element group a material and a thickness
    or a section (`fem.GroupProperties`, set in the Element Groups table or
    on `geometry.group_properties`), every quad a plate, every
    triangle a triangle, every two-node line a beam
    (`fem.Model.from_geometry`). The solution is free-free unless the
    geometry says otherwise later; the six rigid-body modes come
    back at exactly 0 Hz with the elastic ones after them.

    Parameters
    ----------
    source : str or object
        The geometry, by name or as the object itself.
    maximum_frequency : float, optional
        Solve for every mode up to this frequency, in Hz.
    num_modes : int, optional
        Or for this many modes, rigid ones included.
    damping : float, default 0.0
        The fraction of critical damping every mode is given; a
        finite element model has none of its own.
    name : str, optional
        What to call the result. Defaults to the geometry's name
        with ' Modes' after it.
    progress : callable, optional
        Told ``(done, total)`` as the solve advances; the window's
        strip bar reads it, a script can print it.

    Returns
    -------
    str
        The name the shape set was added under.
    """
    from .core.fem import Model

    source = self.name_of(source)
    geometry = self[source]
    if not isinstance(geometry, Geometry):
        raise TypeError(f'{source!r} is not a geometry')
    if not geometry.group_properties:
        raise ValueError(
            f'{source} has no element group properties: give each element group a '
            'material and a thickness or a section, in the Element Groups '
            'table or on geometry.group_properties')
    model = Model.from_geometry(geometry, name=source)
    shapes = model.eigensolution(maximum_frequency=maximum_frequency,
                                 num_modes=num_modes, damping=damping,
                                 progress=progress)
    params = {'maximum_frequency': maximum_frequency,
              'num_modes': num_modes, 'damping': damping}
    return self._derive(source, shapes, name or f'{source} Modes',
                        recipe=('solve_modes', params))
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 judgment — 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 judgment, 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 judgment — 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 judgment, 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. An object 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.object_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', marking: str | None = None, marking_color: str | None = None) -> 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' (a random-and-sine project makes 'random' and 'sine', one report each) — 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'
marking str

The banner across the top and bottom of every page — 'UNCLASSIFIED' unless said. A batch stamps its own (Brandon, 2026-10-02).

None
marking_color str

'ink' or 'red'.

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_report(self, template: str = 'modal',
                    name: str = 'Report', marking: str | None = None,
                    marking_color: str | None = None) -> 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' (a random-and-sine
        project makes 'random' and 'sine', one report each) — 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.
    marking : str, optional
        The banner across the top and bottom of every page —
        'UNCLASSIFIED' unless said. A batch stamps its own
        (Brandon, 2026-10-02).
    marking_color : str, optional
        'ink' or 'red'.

    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 (
        TEMPLATE_BUILDERS,
        Report,
    )

    builders = TEMPLATE_BUILDERS
    if template in builders:
        report = builders[template](self, object_groups=self.object_groups)
    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)
    if marking is not None:
        report.marking = str(marking)
    if marking_color is not None:
        report.marking_color = marking_color
    return self.add(name, report)
work_up
work_up() -> list[str]

Every missing object the project's type expects, computed from what is loaded, and the report last (the tree bar's Automatic; Brandon, 2026-09-30).

The skeleton's empty slots in their own order, each made from the objects already there the way the workflow guide makes it — PSDs from the time data, the octave bands from the PSDs and the specification, the coherence from the time data; the FRFs and a fitted shape set for a modal test; the levels for a sine sweep; the filtered record, its SRS and the motion chain for a shock; the two streams' PSDs on shared frames and the H1 plant for a system identification — and the typed report once they are in. A slot the loaded data cannot fill (a geometry, the photographs, a specification) is left gray; a computation the data refuses (no drive channel for a coherence) is skipped, and the rest still happen. Nothing already present is remade: pressed twice, the second press adds nothing.

One journal line, project.work_up(), the way merge is one line: the verbs it calls are the ones the guide teaches, and a script wanting them one at a time calls them.

Returns:

Type Description
list of str

The names added, in the order made; empty when the skeleton was already full.

Raises:

Type Description
ValueError

When the project has no type — there is no skeleton to fill.

Source code in src/visualdynamics/project.py
def work_up(self) -> list[str]:
    """Every missing object the project's type expects, computed
    from what is loaded, and the report last (the tree bar's
    **Automatic**; Brandon, 2026-09-30).

    The skeleton's empty slots in their own order, each made from
    the objects already there the way the workflow guide makes it
    — PSDs from the time data, the octave bands from the PSDs and
    the specification, the coherence from the time data; the FRFs
    and a fitted shape set for a modal test; the levels for a sine
    sweep; the filtered record, its SRS and the motion chain for a
    shock; the two streams' PSDs on shared frames and the H1 plant
    for a system identification — and the typed report once they
    are in. A slot the loaded data cannot fill (a geometry, the
    photographs, a specification) is left gray; a computation the
    data refuses (no drive channel for a coherence) is skipped, and
    the rest still happen. Nothing already present is remade:
    pressed twice, the second press adds nothing.

    One journal line, `project.work_up()`, the way `merge` is one
    line: the verbs it calls are the ones the guide teaches, and a
    script wanting them one at a time calls them.

    Returns
    -------
    list of str
        The names added, in the order made; empty when the
        skeleton was already full.

    Raises
    ------
    ValueError
        When the project has no type — there is no skeleton to
        fill.
    """
    from .core.data import Psd, Specification, TransientSpecification
    from .core.report import (
        PROJECT_TEMPLATES,
        TEMPLATE_BUILDERS,
        is_banded,
        project_expectations,
    )
    from .core.shapes import ShapeSet

    project_type = self.project_type
    if not project_type:
        raise ValueError('the project has no type, so there is nothing '
                         'to work up — Set Project Type says what a '
                         'finished project holds')
    added: list[str] = []

    def make(verb, *args, **kwargs):
        names = verb(*args, **kwargs)
        names = names if isinstance(names, list) else [names]
        added.extend(names)
        return names

    def on(side, cls):
        sides = self.sides()
        pool = sides.get(side, []) if 'Basis' in sides else self.names
        return [name for name in pool if isinstance(self[name], cls)]

    def source_of(name):
        return self.provenance.get(name, {}).get('source')

    def verb_of(name):
        return self.provenance.get(name, {}).get('verb')

    def derived(name, verb):
        return next((other for other in self.names
                     if verb_of(other) == verb
                     and source_of(other) == name), None)

    def recorded(side):
        # the imported record, ahead of anything derived from one
        histories = on(side, TimeHistory)
        histories = [name for name in histories
                     if not isinstance(self[name], TransientSpecification)]
        return next((name for name in histories
                     if verb_of(name) is None), None) or (
            histories[0] if histories else None)

    def filtered(side):
        record = recorded(side)
        if record is None:
            return None
        done = derived(record, 'filter_data')
        return done or make(self.filter_data, record)[0]

    for slot in project_expectations(project_type):
        if slot not in self.missing():
            continue
        _label, _cls, icon, ordinal, _optional, side = slot
        side = side or 'Basis'
        try:
            if icon == 'Psd':
                if project_type == 'System ID':
                    noise, driven = _quiet_and_driven(self)
                    # the excitation's frames are the detected ones,
                    # as `compute_frfs` adopts them, and the ambient
                    # borrows them on purpose: the ratio the report
                    # reads is only defined on lines both hold
                    if self[driven].averaging is None:
                        self[driven].averaging = \
                            self[driven].suggest_averaging()
                    if derived(driven, 'compute_psds') is None:
                        make(self.compute_psds, driven)
                    if noise and derived(noise, 'compute_psds') is None:
                        self[noise].averaging = self[driven].averaging
                        make(self.compute_psds, noise)
                    continue
                record = recorded(side)
                if record is not None:
                    make(self.compute_psds, record)
            elif icon == 'Specification':
                # a transient's target is a recording too, and its
                # spectrum is the requirement the run is read against
                target = next((name for name in on(side, TransientSpecification)
                               if derived(name, 'compute_psds') is None),
                              None)
                if target is not None:
                    make(self.compute_psds, target)
            elif icon == 'OctavePsd':
                plain = [name for name in on(side, Psd)
                         if not isinstance(self[name], Specification)
                         and not is_banded(self[name])]
                if plain:
                    make(self.compute_octave, plain[0])
            elif icon == 'OctaveSpecification':
                plain = [name for name in on(side, Specification)
                         if not is_banded(self[name])]
                if plain:
                    make(self.compute_octave, plain[0])
            elif icon == 'MultipleCoherence':
                record = (_quiet_and_driven(self)[1]
                          if project_type == 'System ID'
                          else recorded(side))
                if record is not None:
                    make(self.compute_multiple_coherence, record)
            elif icon == 'Frf':
                record = (_quiet_and_driven(self)[1]
                          if project_type == 'System ID'
                          else recorded(side))
                if record is not None:
                    # H1 for a system identification: the excitation
                    # is known and what noise there is sits on the
                    # response
                    make(self.compute_frfs, record,
                         *(['H1'] if project_type == 'System ID' else []))
            elif icon == 'ShapeSet' and side == 'Basis':
                frfs = on(side, Frf)
                if frfs:
                    make(self.fit_modes, frfs[0])
            elif icon == 'MatchedModes':
                from .core.report import OTHER_SIDE
                ours, theirs = on('Basis', ShapeSet), on(OTHER_SIDE, ShapeSet)
                if ours and theirs and ours[0] != theirs[0]:
                    make(self.match_modes, ours[0], theirs[0])
            elif icon == 'SineLevelSet':
                record = recorded(side)
                if record is not None:
                    make(self.extract_sine, record)
            elif icon == 'Srs':
                # the recommended order: the SRS is read from the
                # filtered record, so the filtering comes first even
                # though its slot is listed after
                record = filtered(side) if project_type == 'Shock' \
                    else recorded(side)
                if record is not None:
                    make(self.compute_srs, record)
            elif icon == 'TimeHistory' and ordinal > 1:
                # the shock's motion chain: filtered, then velocity,
                # then displacement, each from the one before
                chain = filtered(side)
                for _step in range(ordinal - 2):
                    if chain is None:
                        break
                    chain = derived(chain, 'integrate') or \
                        make(self.integrate, chain)[0]
            elif icon == 'Report':
                # the type's reports in order, each made once: chosen
                # by which are already here (by title), not by the
                # slot's number, so a random-and-sine project holding
                # its sine report gets the random one, not a second
                # sine (2026-10-05, two reports for that type)
                made = {self[name].title for name in self.names
                        if isinstance(self[name], Report)}
                wanted = next(
                    (template for template
                     in PROJECT_TEMPLATES[project_type]
                     if TEMPLATE_BUILDERS[template]({}, object_groups=[]).title
                     not in made), None)
                if wanted is not None:
                    make(self.generate_report, wanted)
        except ValueError:
            # what the data refuses (a coherence with no drive
            # channel) stays gray; the slots after it still fill
            continue
    return added
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, object_groups=self.object_groups)
    path = os.path.expanduser(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, progress: 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
progress callable

Told (done, total) as the extraction advances — the matched filter per tone, the smoothing ladder, then the solve's pieces per channel. The window's strip bar reads it; a script can print it. Anything it raises stops the extraction.

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,
                 progress: 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.
    progress : callable, optional
        Told ``(done, total)`` as the extraction advances — the
        matched filter per tone, the smoothing ladder, then the
        solve's pieces per channel. The window's strip bar reads
        it; a script can print it. Anything it raises stops the
        extraction.

    Returns
    -------
    list of str
        The names of the levels added, one per tone.
    """
    from dataclasses import replace

    from .core.sine import extract_sine

    source = self.name_of(source)
    if specification is None:
        spec = self.sine_sweep_specification
        spec_name = self.name_of(spec)
    else:
        spec_name = self.name_of(specification)
        spec = self[spec_name]
    history = self[source]
    # the settings are the history's own; with none set, the
    # suggestion is adopted the way `filter_data` adopts its filter,
    # so the button works before the sine view has been visited
    setting = history.sine_extraction
    if setting is None:
        setting = history.suggest_sine_extraction()
    levels = extract_sine(history, spec, cycles=setting.cycles,
                          refine=setting.refine,
                          target_db=setting.target_db,
                          progress=progress)
    # what the automatic chose rides the setting, so the view and
    # the journal say what was used
    history.sine_extraction = replace(setting, chosen=levels.cycles)
    return [self._derive(source, levels, 'Sine Levels',
                         recipe=('extract_sine',
                                 {'specification': spec_name}))]
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, **options: Any) -> str

Write the whole project to one file: .vdyn, .mat for the same layout in MATLAB's container, or .h5 for the Engineering Sciences Common Data Format.

Parameters:

Name Type Description Default
path str or PathLike

Where to write the file. A .mat suffix writes the project as MATLAB structs (io.matlab), .h5 (or .hdf5, or .escdf) as the standard's types with each object whole in an attachment (io.escdf_objects); anything else is .vdyn.

required
**options Any

Passed to a foreign writer: an ESCDF file's created_by.

{}

Returns:

Type Description
str

The path written.

Source code in src/visualdynamics/project.py
def save(self, path: str | os.PathLike, **options: Any) -> str:
    """Write the whole project to one file: `.vdyn`, `.mat` for
    the same layout in MATLAB's container, or `.h5` for the
    Engineering Sciences Common Data Format.

    Parameters
    ----------
    path : str or os.PathLike
        Where to write the file. A `.mat` suffix writes the project
        as MATLAB structs (`io.matlab`), `.h5` (or `.hdf5`, or
        `.escdf`) as the standard's types with each object whole in
        an attachment (`io.escdf_objects`); anything else is
        `.vdyn`.
    **options
        Passed to a foreign writer: an ESCDF file's `created_by`.

    Returns
    -------
    str
        The path written.
    """
    from .io import export_file, save_test
    from .io.escdf_objects import SUFFIXES

    if str(path).endswith('.mat'):
        export_file(self, str(path), **options)
        return str(path)
    if str(path).endswith(SUFFIXES):
        # by name: the writer's own suffix is `.h5`, and picking by
        # suffix would refuse the other two it keeps
        export_file(self, str(path), format='escdf', **options)
        return str(path)
    save_test(str(path), self.name, dict(self),
              active_geometry=self.active_geometry,
              project_type=self.project_type, object_groups=self.object_groups,
              provenance=self.provenance)
    return str(path)
open classmethod
open(path: str | PathLike) -> Project

Read a project back, from .vdyn, .mat or an ESCDF .h5 (.hdf5, .escdf).

Source code in src/visualdynamics/project.py
@classmethod
def open(cls, path: str | os.PathLike) -> Project:
    """Read a project back, from `.vdyn`, `.mat` or an ESCDF `.h5`
    (`.hdf5`, `.escdf`)."""
    from .io import import_file, load
    from .io.escdf_objects import SUFFIXES

    loaded = (import_file(str(path))
              if str(path).endswith(('.mat', *SUFFIXES))
              else 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; journaled 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; journaled 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, journaled 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, journaled 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, journaled as a script makes it.

The front ends' funnel for object verbs that are not Project verbs — a line added to a geometry, a photo renamed — each an act of the session the console must speak (Brandon, 2026-08-30: adding a line 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, journaled as a script makes it.

    The front ends' funnel for object verbs that are not Project
    verbs — a line added to a geometry, a photo renamed —
    each an act of the session the console must speak (Brandon,
    2026-08-30: adding a line 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, object_groups: 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.

Parameters:

Name Type Description Default
objects Mapping[str, Any]

The test's objects, by name.

required
geometry_name str

The geometry everything not linked to one is judged against; the first geometry found when omitted.

None
object_groups sequence of Mapping

The project's object groups, which say which geometry each object answers to.

None

Returns:

Type Description
Report

What is consistent and what is not, object by object.

Source code in src/visualdynamics/compatibility.py
def check_compatibility(objects: Mapping[str, Any],
                        geometry_name: str | None = None,
                        object_groups: 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.

    Parameters
    ----------
    objects : Mapping[str, Any]
        The test's objects, by name.
    geometry_name : str, optional
        The geometry everything not linked to one is judged against; the
        first geometry found when omitted.
    object_groups : sequence of Mapping, optional
        The project's object groups, which say which geometry each object
        answers to.

    Returns
    -------
    Report
        What is consistent and what is not, object by object.
    """
    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 object_groups 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 object_groups 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.

Parameters:

Name Type Description Default
obj object

What to write: a geometry, data, shapes, a project, and so on.

required
path str or PathLike

Where; the suffix picks the format when format is not given.

required
format str

An exporter by name (visualdynamics.io.exporters), whatever the suffix says.

None
unit_system UnitSystem

The units to write in; the stored values as they are when omitted.

None
**kwargs Any

Passed to the format's writer, e.g. an ESCDF file's created_by.

{}

Returns:

Type Description
None
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.

    Parameters
    ----------
    obj : object
        What to write: a geometry, data, shapes, a project, and so on.
    path : str or os.PathLike
        Where; the suffix picks the format when `format` is not given.
    format : str, optional
        An exporter by name (`visualdynamics.io.exporters`), whatever the
        suffix says.
    unit_system : UnitSystem, optional
        The units to write in; the stored values as they are when omitted.
    **kwargs
        Passed to the format's writer, e.g. an ESCDF file's `created_by`.

    Returns
    -------
    None
    """
    # `~` is the writer's to expand too, or a file lands in a folder
    # named for a tilde (the import's own courtesy, 2026-09-20)
    path = os.path.expanduser(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.

Parameters:

Name Type Description Default
timeseries dict or list of dict

One SEP 005 series, or a list of them.

required

Returns:

Type Description
TimeHistory or dict of str to TimeHistory

One for one dict; for a list, every series by name.

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.

    Parameters
    ----------
    timeseries : dict or list of dict
        One SEP 005 series, or a list of them.

    Returns
    -------
    TimeHistory or dict of str to TimeHistory
        One for one dict; for a list, every series by name.
    """
    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, honored where the reader can count — a project file's objects, a Rattlesnake run's samples (2026-10-02) — and quietly unused where it cannot: a UFF file is one parse, and nothing inside it reports fractions worth relaying.

Parameters:

Name Type Description Default
path str or PathLike

The file to read.

required
format str

An importer by name, rather than the one the file is recognized as.

None
progress callable

Called as progress(done, total) where the reader can count.

None
**kwargs Any

Passed to the importer: declared units (length_unit='m'), a window of a long run (start=, stop=), channels, and so on.

{}

Returns:

Type Description
object

The object the file holds; a dict of them by name for a file that holds several, and a Project for a project file.

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, honored where the reader can count — a
    project file's objects, a Rattlesnake run's samples (2026-10-02) —
    and quietly unused where it cannot: a UFF file is one parse, and
    nothing inside it reports fractions worth relaying.

    Parameters
    ----------
    path : str or os.PathLike
        The file to read.
    format : str, optional
        An importer by name, rather than the one the file is recognized as.
    progress : callable, optional
        Called as ``progress(done, total)`` where the reader can count.
    **kwargs
        Passed to the importer: declared units (``length_unit='m'``), a
        window of a long run (``start=``, ``stop=``), channels, and so on.

    Returns
    -------
    object
        The object the file holds; a dict of them by name for a file that
        holds several, and a `Project` for a project file.
    """
    import inspect

    def load_with(imp):
        kwargs_for = dict(kwargs)
        if progress is not None and 'progress' in inspect.signature(
                imp.load).parameters:
            kwargs_for['progress'] = progress
        return imp.load(path, **kwargs_for)

    # a person types a path where the window hands one over: `~` is
    # theirs to write and Python's to expand, and a path that is not
    # there has to say so. Both came back as "No importer recognizes",
    # which reads as "your file is the wrong kind" and is how a typo
    # looked like an unsupported format (Brandon, 2026-09-20).
    path = os.path.expanduser(str(path))
    if not os.path.exists(path):
        raise FileNotFoundError(f'no file at {path}')
    if os.path.isdir(path):
        raise IsADirectoryError(f'{path} is a folder, not a file')
    if path.endswith('.vdyn'):
        return load(path, progress=progress)
    if format is not None:
        for imp in _IMPORTERS:
            if imp.name == format:
                return load_with(imp)
        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 load_with(imp)
    # a refusal that says what the file *is* saves a round trip: a
    # container whose contents decide the reader, and whose name does
    # not, is the case that brought this up (Brandon, 2026-09-20)
    from .sniffing import describe

    said = describe(path)
    raise ValueError(f"No importer recognizes {path}"
                     + (f" — {said}" if said else ''))

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.

Parameters:

Name Type Description Default
path str or PathLike

The .vdyn file.

required
progress callable

Called as progress(loaded, total), once up front and once per object.

None

Returns:

Type Description
object

The object the file holds, or a Project for a whole test.

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.

    Parameters
    ----------
    path : str or os.PathLike
        The `.vdyn` file.
    progress : callable, optional
        Called as ``progress(loaded, total)``, once up front and once per
        object.

    Returns
    -------
    object
        The object the file holds, or a `Project` for a whole test.
    """
    import h5py

    with h5py.File(path, 'r') as f:
        return load_from(f, progress, str(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.

Parameters:

Name Type Description Default
name str

The importer's name, which import_file(format=) takes.

required
description str

What the format is, as a file dialog lists it.

required
sniff callable

sniff(path) -> bool: whether a file is this format.

required
load callable

load(path, **kwargs): the file read into objects.

required
project_type callable

project_type(path) -> str or None: the project type the file declares, for a format that carries one.

None

Returns:

Type Description
None
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.

    Parameters
    ----------
    name : str
        The importer's name, which `import_file(format=)` takes.
    description : str
        What the format is, as a file dialog lists it.
    sniff : callable
        ``sniff(path) -> bool``: whether a file is this format.
    load : callable
        ``load(path, **kwargs)``: the file read into objects.
    project_type : callable, optional
        ``project_type(path) -> str or None``: the project type the file
        declares, for a format that carries one.

    Returns
    -------
    None
    """
    _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.

Parameters:

Name Type Description Default
obj object

The object to save.

required
path str or PathLike

Where; .vdyn is added when the suffix is not there.

required

Returns:

Type Description
None
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.

    Parameters
    ----------
    obj : object
        The object to save.
    path : str or os.PathLike
        Where; `.vdyn` is added when the suffix is not there.

    Returns
    -------
    None
    """
    import h5py

    with h5py.File(_visualdynamics_path(path), 'w') as f:
        save_into(obj, f)

mixed_run

mixed_run(run: str | PathLike, per_octave: int | None = None, *, last: float | None = None, geometry: str | PathLike | None = None, length_unit: str | None = None, photos: Any = None) -> Project

A Rattlesnake random run with a sine sweep under it, worked up into a project: both halves.

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

Everything random_vibration_run does — the run imported, the PSDs averaged and banded, the specification banded, the coherence measured, the geometry and photographs brought in — and then the sine half: each tone's level extracted from the same recording against the sweep specification the file carried. The run says it is both, so the project comes back declared Random and Sine. A run with no sine specification is refused by name rather than worked up as half of what was asked for; random_vibration_run is the call for it. The keywords are random_vibration_run's.

Parameters:

Name Type Description Default
run str or PathLike

The controller's .nc4.

required
per_octave int

Bands per octave for the banded PSD and specification; the project's default (a sixth) when omitted.

None
last float

Import only the last last seconds of the run's streams; a shorter run is taken whole.

None
geometry str or PathLike

A geometry file to import and link to the run.

None
length_unit str

The geometry's length unit, for a file that does not say.

None
photos str, os.PathLike or sequence of them

A folder of photographs, one photograph, or several in order.

None

Returns:

Type Description
Project

The worked-up project, declared Random and Sine.

Source code in src/visualdynamics/project.py
def mixed_run(run: str | os.PathLike, per_octave: int | None = None, *,
              last: float | None = None,
              geometry: str | os.PathLike | None = None,
              length_unit: str | None = None,
              photos: Any = None) -> Project:
    """A Rattlesnake random run with a sine sweep under it, worked up
    into a project: both halves.

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

    Everything `random_vibration_run` does — the run imported, the PSDs
    averaged and banded, the specification banded, the coherence
    measured, the geometry and photographs brought in — and then the
    sine half: each tone's level extracted from the same recording
    against the sweep specification the file carried. The run says it
    is both, so the project comes back declared Random and Sine. A run
    with no sine specification is refused by name rather than worked
    up as half of what was asked for; `random_vibration_run` is the
    call for it. The keywords are `random_vibration_run`'s.

    Parameters
    ----------
    run : str or os.PathLike
        The controller's `.nc4`.
    per_octave : int, optional
        Bands per octave for the banded PSD and specification; the
        project's default (a sixth) when omitted.
    last : float, optional
        Import only the last `last` seconds of the run's streams; a
        shorter run is taken whole.
    geometry : str or os.PathLike, optional
        A geometry file to import and link to the run.
    length_unit : str, optional
        The geometry's length unit, for a file that does not say.
    photos : str, os.PathLike or sequence of them, optional
        A folder of photographs, one photograph, or several in order.

    Returns
    -------
    Project
        The worked-up project, declared Random and Sine.
    """
    project = random_vibration_run(run, per_octave, last=last,
                                   geometry=geometry,
                                   length_unit=length_unit, photos=photos)
    from .core.sine import SineSweepSpecification

    if not any(isinstance(obj, SineSweepSpecification)
               for _name, obj in project.items()):
        raise ValueError(
            f'{run} holds no sine sweep specification: it is not a random '
            'and sine run — random_vibration_run is the call for it')
    project.extract_sine(project.time_history)
    return project

random_vibration_report

random_vibration_report(run: Any = ASK, path: str | PathLike | None = None, *, last: float | None = None, geometry: Any = None, photos: Any = None, per_octave: int | None = None, unit_system: Any = None, marking: str | None = None) -> Any

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

visualdynamics.random_vibration_report('run.nc4', 'report.html')
visualdynamics.random_vibration_report(
    'run.nc4', 'report.html', last=100.0,
    geometry='article.stp', photos='setup_photos/')
visualdynamics.random_vibration_report()          # ask for both
visualdynamics.random_vibration_report('run.nc4', 'reports/')

Left out, it asks. Called with no run, it opens a file dialog and takes as many runs as are chosen, writing one report each and returning the list; a run chosen that way is asked about its geometry too, where Cancel means none. visualdynamics.ASK in either place forces the question, so a script with a run in hand can still be asked for the geometry. A run given without a geometry keyword means no geometry, as it always has, and nothing opens.

A folder is a folder. path may be the file to write, or a folder the report lands in under the run's own name with an .html extension. Without a path it lands beside the run. Several runs need a folder or no path, never one file name.

The whole workflow in one call, with nothing to click (Brandon, 2026-09-19): import the run — or only its last last seconds, a shorter run taken whole — detect the averaging, compute the PSDs, band them and the specification onto octave bands, compute the multiple coherence, bring in the geometry and the photographs when they are given, generate the Random Vibration report and write it as one self-contained HTML file. Returns the path written, or the list of them when several runs were chosen. The keywords are random_vibration_run's, plus unit_system for the units the report is written in.

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 write the test summary, or to save it as .vdyn.

Parameters:

Name Type Description Default
run str, os.PathLike, sequence of them, or `visualdynamics.ASK`

The controller's .nc4, or a list of them for a batch; asked for in a file dialog, as many as wanted, when omitted.

ASK
path (str, PathLike or None)

The file to write, or a folder (one that exists, or a name ending in a separator) the report lands in under the run's own name. Beside the run when omitted. Several runs need a folder or None.

None
last float

Import only the last last seconds of the run; a shorter run is taken whole.

None
geometry str, os.PathLike, `visualdynamics.ASK` or None

A geometry file to import and link to the run. Asked for once for the whole batch when the run was asked for, Cancel meaning none; ASK asks even when the run was given.

None
photos str, os.PathLike or sequence of them

A folder of photographs, one photograph, or several in order.

None
per_octave int

Bands per octave for the banded PSD and specification; the project's default (a sixth) when omitted.

None
unit_system UnitSystem

The units the report is written in; the package default, 'in-slinch-lbf-s (g)', when omitted.

None
marking str

The banner across the top and bottom of every page; 'UNCLASSIFIED' when omitted.

None

Returns:

Type Description
str or list of str

The path written; a list of them when several runs were chosen or a list of runs was given.

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

        visualdynamics.random_vibration_report('run.nc4', 'report.html')
        visualdynamics.random_vibration_report(
            'run.nc4', 'report.html', last=100.0,
            geometry='article.stp', photos='setup_photos/')
        visualdynamics.random_vibration_report()          # ask for both
        visualdynamics.random_vibration_report('run.nc4', 'reports/')

    **Left out, it asks.** Called with no run, it opens a file dialog
    and takes as many runs as are chosen, writing one report each and
    returning the list; a run chosen that way is asked about its
    geometry too, where Cancel means none. `visualdynamics.ASK` in
    either place forces the question, so a script with a run in hand
    can still be asked for the geometry. A run given without a
    geometry keyword means *no geometry*, as it always has, and
    nothing opens.

    **A folder is a folder.** `path` may be the file to write, or a
    folder the report lands in under the run's own name with an
    `.html` extension. Without a path it lands beside the run. Several
    runs need a folder or no path, never one file name.

    The whole workflow in one call, with nothing to click (Brandon,
    2026-09-19): import the run — or only its last `last` seconds, a
    shorter run taken whole — detect the averaging, compute the PSDs,
    band them and the specification onto octave bands, compute the
    multiple coherence, bring in the geometry and the photographs when
    they are given, generate the Random Vibration report and write it
    as one self-contained HTML file. Returns the path written, or the
    list of them when several runs were chosen. The keywords are
    `random_vibration_run`'s, plus `unit_system` for the units the
    report is written in.

    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 write the test summary, or to
    save it as `.vdyn`.

    Parameters
    ----------
    run : str, os.PathLike, sequence of them, or `visualdynamics.ASK`
        The controller's `.nc4`, or a list of them for a batch; asked for
        in a file dialog, as many as wanted, when omitted.
    path : str, os.PathLike or None, optional
        The file to write, or a folder (one that exists, or a name ending
        in a separator) the report lands in under the run's own name.
        Beside the run when omitted. Several runs need a folder or None.
    last : float, optional
        Import only the last `last` seconds of the run; a shorter run is
        taken whole.
    geometry : str, os.PathLike, `visualdynamics.ASK` or None, optional
        A geometry file to import and link to the run. Asked for once for
        the whole batch when the run was asked for, Cancel meaning none;
        `ASK` asks even when the run was given.
    photos : str, os.PathLike or sequence of them, optional
        A folder of photographs, one photograph, or several in order.
    per_octave : int, optional
        Bands per octave for the banded PSD and specification; the
        project's default (a sixth) when omitted.
    unit_system : UnitSystem, optional
        The units the report is written in; the package default,
        'in-slinch-lbf-s (g)', when omitted.
    marking : str, optional
        The banner across the top and bottom of every page;
        'UNCLASSIFIED' when omitted.

    Returns
    -------
    str or list of str
        The path written; a list of them when several runs were chosen or
        a list of runs was given.
    """
    # no length unit reaches the workup: the report draws the geometry
    # as a shape and never states a coordinate or a scale, so declaring
    # what the file's numbers meant changed one label and nothing else
    # (Brandon, 2026-09-22). The `_run` calls still take it, for a
    # project that lives on.
    return _one_call_reports(
        run, path, geometry, unit_system, 'random',
        lambda one, geo: random_vibration_run(one, per_octave, last=last,
                                              geometry=geo, photos=photos),
        marking=marking)

random_vibration_run

random_vibration_run(run: str | PathLike, per_octave: int | None = None, *, last: float | None = None, geometry: str | PathLike | None = None, length_unit: str | None = None, photos: Any = None) -> Project

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

project = visualdynamics.random_vibration_run('run.nc4')
project = visualdynamics.random_vibration_run(
    'run.nc4', last=100.0, geometry='article.stp', length_unit='mm',
    photos='setup_photos/')

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 the specification onto the same bands, limits and all, since the report reads the banded measurement against the banded requirement (Brandon, 2026-09-18) — 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 averaging is the Detect answer: the controller's own frame length, window and overlap from the file, with the start and the count worked out from the record — where the run is at level and how many frames that stretch holds — so a script, an import and a click on Detect reach the same numbers (Brandon, 2026-09-19).

The rest is what the window's tree asks for after the run is in, given here so a script never has to open it (Brandon, 2026-09-19): the article's geometry, with its length unit declared when the file does not carry one; the setup photographs, as a folder of png or jpeg files, one file, or a list of files in the order they should appear; and, for a run too long to hold, last — the seconds before the end to import, the same window the import dialog's Last field sets, and a run shorter than that is taken whole. The geometry and the photographs are linked into the run's own group, which is what lets the report read them against the channel table.

Parameters:

Name Type Description Default
run str or PathLike

The controller's .nc4.

required
per_octave int

Bands per octave for the banded PSD and specification; the project's default (a sixth) when omitted.

None
last float

Import only the last last seconds of the run's streams.

None
geometry str or PathLike

A geometry file to import and link to the run.

None
length_unit str

The geometry's length unit, for a file that does not say.

None
photos str, os.PathLike or sequence of them

A folder of photographs, one photograph, or several.

None

Returns:

Type Description
Project

The worked-up project, declared Random Vibration.

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

        project = visualdynamics.random_vibration_run('run.nc4')
        project = visualdynamics.random_vibration_run(
            'run.nc4', last=100.0, geometry='article.stp', length_unit='mm',
            photos='setup_photos/')

    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 the specification onto the same bands,
    limits and all, since the report reads the banded measurement
    against the banded requirement (Brandon, 2026-09-18) — 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 averaging is the Detect answer: the controller's own frame
    length, window and overlap from the file, with the start and the
    count worked out from the record — where the run is at level and
    how many frames that stretch holds — so a script, an import and a
    click on Detect reach the same numbers (Brandon, 2026-09-19).

    The rest is what the window's tree asks for after the run is in,
    given here so a script never has to open it (Brandon,
    2026-09-19): the article's geometry, with its length unit
    declared when the file does not carry one; the setup photographs,
    as a folder of png or jpeg files, one file, or a list of files in
    the order they should appear; and, for a run too long to hold,
    `last` — the seconds before the end to import, the same window the
    import dialog's *Last* field sets, and a run shorter than that is
    taken whole. The geometry and the photographs are linked into the
    run's own group, which is what lets the report read them against
    the channel table.

    Parameters
    ----------
    run : str or os.PathLike
        The controller's `.nc4`.
    per_octave : int, optional
        Bands per octave for the banded PSD and specification; the
        project's default (a sixth) when omitted.
    last : float, optional
        Import only the last `last` seconds of the run's streams.
    geometry : str or os.PathLike, optional
        A geometry file to import and link to the run.
    length_unit : str, optional
        The geometry's length unit, for a file that does not say.
    photos : str, os.PathLike or sequence of them, optional
        A folder of photographs, one photograph, or several.

    Returns
    -------
    Project
        The worked-up project, declared Random Vibration.
    """
    project = Project()
    project.import_file(run, **(_last_window(run, last) if last is not None
                                else {}))
    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)
    from .core.data import Specification
    specification = next((name for name, obj in project.items()
                          if isinstance(obj, Specification)), None)
    if specification is not None:
        project.compute_octave(specification, per_octave)
    project.compute_multiple_coherence(history)
    extras: list[str] = []
    if geometry is not None:
        options = {} if length_unit is None else {'length_unit': length_unit}
        extras += project.import_file(geometry, **options)
    if photos is not None:
        extras.append(project.add('Photos', _photos_from(photos)))
    if extras:
        project.link(history, *extras)
    return project

report_kind

report_kind(run: str | PathLike) -> str

Which one-call reports a Rattlesnake run gets: 'random', 'mixed', 'sine' or 'sysid', from the project type the file declares — 'mixed' being a random run with a sweep under it, which gets a random report and a sine report (run_report) — and 'sysid' for a streamed save with a system ID's shape, two streams, a quiet one then a loud one, where the window asks and this decides (Brandon, 2026-10-02: a batch defaults to the likeliest reading). A run of a type with no one-call report, a modal or a transient run, is refused by name.

Parameters:

Name Type Description Default
run str or PathLike

The controller's .nc4.

required

Returns:

Type Description
str

'random', 'mixed', 'sine' or 'sysid'.

Source code in src/visualdynamics/project.py
def report_kind(run: str | os.PathLike) -> str:
    """Which one-call reports a Rattlesnake run gets: 'random', 'mixed',
    'sine' or 'sysid', from the project type the file declares — 'mixed'
    being a random run with a sweep under it, which gets a random report
    and a sine report (`run_report`) — and
    'sysid' for a streamed save with a system ID's shape, two streams,
    a quiet one then a loud one, where the window asks and this
    decides (Brandon, 2026-10-02: a batch defaults to the likeliest
    reading). A run of a type with no one-call report, a modal or a
    transient run, is refused by name.

    Parameters
    ----------
    run : str or os.PathLike
        The controller's `.nc4`.

    Returns
    -------
    str
        'random', 'mixed', 'sine' or 'sysid'.
    """
    from .io.rattlesnake import project_type, streamed_sysid_candidate

    declared = project_type(run)
    if declared == 'System ID' or streamed_sysid_candidate(run):
        return 'sysid'
    kind = _REPORTS_BY_TYPE.get(declared or '')
    if kind is None:
        raise ValueError(
            f'{run} is {declared or "of no type visualdynamics reports on"}: '
            'no one-call report exists for it — a project worked up in the '
            'window can still generate its report')
    return kind

run_report

run_report(run: Any = ASK, path: str | PathLike | None = None, *, last: Any = None, kinds: Any = None, geometry: Any = None, photos: Any = None, per_octave: int | None = None, unit_system: Any = None, marking: str | None = None, progress: Any = None) -> Any

A Rattlesnake run in, the report its type calls for out.

visualdynamics.run_report('run.nc4', 'report.html')
visualdynamics.run_report()               # ask for the runs and the geometry
visualdynamics.run_report('run.nc4', 'reports/')
visualdynamics.run_report(['a.nc4', 'b.nc4'], 'reports/',
                          geometry='article.stp')

The one-call report that reads the run's own type (report_kind) and writes that report: a random run gets random_vibration_report's, a sweep sine_report's, a system identification system_id_report's, and a random-and-sine run both a random and a sine report, as <name>_random.html and <name>_sine.html — the random reading only the last last seconds when they are given, the sine always the whole run, since a sweep cut short loses tones (the combined report retired 2026-10-05). A streamed save that looks like a system ID, two streams quiet then loud, is taken as one rather than asked about. Named for what it takes, since visualdynamics.report is the rendering package. The asking, the batch and the folder are the same rule the typed functions share, and a batch may mix kinds. The keywords are the union of theirs; per_octave reaches the random halves alone.

Every run's kind is read before any report is written, so a batch holding a run with no one-call report is refused whole rather than stopping partway through. kinds overrides the reading run by run — a restarted random run the system-ID guess took for one, a file that declares no type (Reports from Runs' per-run choice, 2026-10-06) — and last may be given run by run too. progress(done, total) is told after each run, the hook the window's bar and Cancel ride on (Reports from Runs, 2026-10-04); a run is the smallest step, so a cancel lands between runs.

Parameters:

Name Type Description Default
run str, os.PathLike, sequence of them, or `visualdynamics.ASK`

The controller's .nc4, or a list of them for a batch; asked for in a file dialog, as many as wanted, when omitted.

ASK
path (str, PathLike or None)

The file to write, or a folder (one that exists, or a name ending in a separator) the report lands in under the run's own name. Beside the run when omitted. Several runs need a folder or None.

None
last float or Mapping

Import only the last last seconds of the run; a shorter run is taken whole. For a random-and-sine run the random report alone: its sine report reads the whole run. A mapping gives each run its own, by path; a run it leaves out, or maps to None, is read whole.

None
kinds Mapping

The report each run gets, by path: 'random', 'mixed' (a random and a sine report), 'sine' or 'sysid'. A run it leaves out gets the report its file declares (report_kind).

None
geometry str, os.PathLike, `visualdynamics.ASK` or None

A geometry file to import and link to the run. Asked for once for the whole batch when the run was asked for, Cancel meaning none; ASK asks even when the run was given.

None
photos str, os.PathLike or sequence of them

A folder of photographs, one photograph, or several in order.

None
per_octave int

Bands per octave for the banded PSD and specification; the project's default (a sixth) when omitted. The random halves alone read it.

None
unit_system UnitSystem

The units the report is written in; the package default, 'in-slinch-lbf-s (g)', when omitted.

None
marking str

The banner across the top and bottom of every page; 'UNCLASSIFIED' when omitted.

None
progress callable

Called as progress(done, total) before the first run and after each; it may raise to stop the batch between runs.

None

Returns:

Type Description
str or list of str

The path written; a list of them when several runs were chosen, a list of runs was given, or a run was random and sine (two reports).

Source code in src/visualdynamics/project.py
def run_report(run: Any = ASK, path: str | os.PathLike | None = None, *,
           last: Any = None,
           kinds: Any = None,
           geometry: Any = None,
           photos: Any = None,
           per_octave: int | None = None,
           unit_system: Any = None,
           marking: str | None = None,
           progress: Any = None) -> Any:
    """A Rattlesnake run in, the report its type calls for out.

        visualdynamics.run_report('run.nc4', 'report.html')
        visualdynamics.run_report()               # ask for the runs and the geometry
        visualdynamics.run_report('run.nc4', 'reports/')
        visualdynamics.run_report(['a.nc4', 'b.nc4'], 'reports/',
                                  geometry='article.stp')

    The one-call report that reads the run's own type (`report_kind`)
    and writes that report: a random run gets `random_vibration_report`'s,
    a sweep `sine_report`'s, a system identification
    `system_id_report`'s, and a random-and-sine run both a random and a
    sine report, as `<name>_random.html` and `<name>_sine.html` — the
    random reading only the last `last` seconds when they are given, the
    sine always the whole run, since a sweep cut short loses tones (the
    combined report retired 2026-10-05). A streamed save
    that looks like a system ID, two streams quiet then loud, is taken
    as one rather than asked about. Named for what it takes, since
    `visualdynamics.report` is the rendering package. The asking, the batch and the
    folder are the same rule the typed functions share, and a batch
    may mix kinds. The keywords are the union of theirs; `per_octave`
    reaches the random halves alone.

    Every run's kind is read before any report is written, so a batch
    holding a run with no one-call report is refused whole rather than
    stopping partway through. `kinds` overrides the reading run by run —
    a restarted random run the system-ID guess took for one, a file
    that declares no type (Reports from Runs' per-run choice,
    2026-10-06) — and `last` may be given run by run too. `progress(done, total)` is told after
    each run, the hook the window's bar and Cancel ride on (Reports
    from Runs, 2026-10-04); a run is the smallest step, so a cancel
    lands between runs.

    Parameters
    ----------
    run : str, os.PathLike, sequence of them, or `visualdynamics.ASK`
        The controller's `.nc4`, or a list of them for a batch; asked for
        in a file dialog, as many as wanted, when omitted.
    path : str, os.PathLike or None, optional
        The file to write, or a folder (one that exists, or a name ending
        in a separator) the report lands in under the run's own name.
        Beside the run when omitted. Several runs need a folder or None.
    last : float or Mapping, optional
        Import only the last `last` seconds of the run; a shorter run is
        taken whole. For a random-and-sine run the random report alone:
        its sine report reads the whole run. A mapping gives each run its
        own, by path; a run it leaves out, or maps to None, is read
        whole.
    kinds : Mapping, optional
        The report each run gets, by path: 'random', 'mixed' (a random
        and a sine report), 'sine' or 'sysid'. A run it leaves out gets
        the report its file declares (`report_kind`).
    geometry : str, os.PathLike, `visualdynamics.ASK` or None, optional
        A geometry file to import and link to the run. Asked for once for
        the whole batch when the run was asked for, Cancel meaning none;
        `ASK` asks even when the run was given.
    photos : str, os.PathLike or sequence of them, optional
        A folder of photographs, one photograph, or several in order.
    per_octave : int, optional
        Bands per octave for the banded PSD and specification; the
        project's default (a sixth) when omitted. The random
        halves alone read it.
    unit_system : UnitSystem, optional
        The units the report is written in; the package default,
        'in-slinch-lbf-s (g)', when omitted.
    marking : str, optional
        The banner across the top and bottom of every page;
        'UNCLASSIFIED' when omitted.
    progress : callable, optional
        Called as ``progress(done, total)`` before the first run and after
        each; it may raise to stop the batch between runs.

    Returns
    -------
    str or list of str
        The path written; a list of them when several runs were chosen, a
        list of runs was given, or a run was random and sine (two
        reports).
    """
    runs, asked, many = _runs_of(run)
    chosen = {str(key): value for key, value in (kinds or {}).items()}
    wrong = sorted({str(value) for value in chosen.values()
                    if value not in REPORT_KINDS})
    if wrong:
        raise ValueError(f'no report is called {", ".join(wrong)}: '
                         f'the kinds are {", ".join(REPORT_KINDS)}')
    kinds = [chosen.get(str(one)) or report_kind(one) for one in runs]

    def seconds(one):
        # one number for the batch, or each run's own; read whole when
        # a mapping says nothing about it
        if isinstance(last, Mapping):
            return last.get(str(one))
        return last

    if geometry is ASK or (asked and geometry is None):
        from .gui.ask import for_geometry
        geometry = for_geometry()
    if len(runs) > 1 and path is not None and not (
            os.path.isdir(os.path.expanduser(str(path)))
            or str(path).endswith((os.sep, '/'))):
        raise ValueError(
            f'{len(runs)} runs cannot be written to one file '
            f'{str(path)!r} — give a folder, or no path at all')
    workups = {
        'random': lambda one, geo: random_vibration_run(
            one, per_octave, last=seconds(one), geometry=geo, photos=photos),
        'sine': lambda one, geo: sine_run(one, last=seconds(one),
                                          geometry=geo, photos=photos),
        'sysid': lambda one, geo: system_id_run(one, last=seconds(one),
                                                geometry=geo, photos=photos),
    }
    written = []
    if progress is not None:
        progress(0, len(runs))
    for done, (one, kind) in enumerate(zip(runs, kinds), start=1):
        if kind == 'mixed':
            # two reports, each read the way its own test is: the
            # random from the last `last` seconds when given, the sweep
            # from the whole run, since a sweep cut short loses tones
            # (Brandon, 2026-10-05, when the combined report retired)
            stem, suffix = os.path.splitext(_report_path(one, path))
            suffix = suffix or '.html'
            written.append(_one_call_reports(
                one, f'{stem}_random{suffix}', geometry, unit_system,
                'random', workups['random'], marking=marking))
            written.append(_one_call_reports(
                one, f'{stem}_sine{suffix}', geometry, unit_system, 'sine',
                lambda whole, geo: sine_run(whole, geometry=geo,
                                            photos=photos),
                marking=marking))
        else:
            written.append(_one_call_reports(one, path, geometry,
                                             unit_system, kind,
                                             workups[kind], marking=marking))
        if progress is not None:
            progress(done, len(runs))
    return written if many or len(written) > 1 else written[0]

sine_report

sine_report(run: Any = ASK, path: str | PathLike | None = None, *, last: float | None = None, geometry: Any = None, photos: Any = None, unit_system: Any = None, marking: str | None = None) -> Any

A Rattlesnake sine sweep run in, an HTML report out.

visualdynamics.sine_report('sweep.nc4', 'report.html')
visualdynamics.sine_report()                   # ask for both
visualdynamics.sine_report('sweep.nc4', 'reports/')

random_vibration_report's twin for a sweep: the same asking when the run is left out, the same batch and folder rules, the workup of sine_run, and the Sine Sweep report written as one self-contained HTML file. Returns the path written, or the list of them when several runs were chosen (Brandon, 2026-10-02: the batch tool stopped at a run that was sine alone). marking is the banner across every page of the report, 'UNCLASSIFIED' unless said — on every one-call report since 2026-10-02, when a batch wanted its own heading.

Parameters:

Name Type Description Default
run str, os.PathLike, sequence of them, or `visualdynamics.ASK`

The controller's .nc4, or a list of them for a batch; asked for in a file dialog, as many as wanted, when omitted.

ASK
path (str, PathLike or None)

The file to write, or a folder (one that exists, or a name ending in a separator) the report lands in under the run's own name. Beside the run when omitted. Several runs need a folder or None.

None
last float

Import only the last last seconds of the run; a shorter run is taken whole.

None
geometry str, os.PathLike, `visualdynamics.ASK` or None

A geometry file to import and link to the run. Asked for once for the whole batch when the run was asked for, Cancel meaning none; ASK asks even when the run was given.

None
photos str, os.PathLike or sequence of them

A folder of photographs, one photograph, or several in order.

None
unit_system UnitSystem

The units the report is written in; the package default, 'in-slinch-lbf-s (g)', when omitted.

None
marking str

The banner across the top and bottom of every page; 'UNCLASSIFIED' when omitted.

None

Returns:

Type Description
str or list of str

The path written; a list of them when several runs were chosen or a list of runs was given.

Source code in src/visualdynamics/project.py
def sine_report(run: Any = ASK, path: str | os.PathLike | None = None, *,
                last: float | None = None,
                geometry: Any = None,
                photos: Any = None,
                unit_system: Any = None,
                marking: str | None = None) -> Any:
    """A Rattlesnake sine sweep run in, an HTML report out.

        visualdynamics.sine_report('sweep.nc4', 'report.html')
        visualdynamics.sine_report()                   # ask for both
        visualdynamics.sine_report('sweep.nc4', 'reports/')

    `random_vibration_report`'s twin for a sweep: the same asking when
    the run is left out, the same batch and folder rules, the workup
    of `sine_run`, and the Sine Sweep report written as one
    self-contained HTML file. Returns the path written, or the list of
    them when several runs were chosen (Brandon, 2026-10-02: the batch
    tool stopped at a run that was sine alone). `marking` is the banner
    across every page of the report, 'UNCLASSIFIED' unless said — on
    every one-call report since 2026-10-02, when a batch wanted its own
    heading.

    Parameters
    ----------
    run : str, os.PathLike, sequence of them, or `visualdynamics.ASK`
        The controller's `.nc4`, or a list of them for a batch; asked for
        in a file dialog, as many as wanted, when omitted.
    path : str, os.PathLike or None, optional
        The file to write, or a folder (one that exists, or a name ending
        in a separator) the report lands in under the run's own name.
        Beside the run when omitted. Several runs need a folder or None.
    last : float, optional
        Import only the last `last` seconds of the run; a shorter run is
        taken whole.
    geometry : str, os.PathLike, `visualdynamics.ASK` or None, optional
        A geometry file to import and link to the run. Asked for once for
        the whole batch when the run was asked for, Cancel meaning none;
        `ASK` asks even when the run was given.
    photos : str, os.PathLike or sequence of them, optional
        A folder of photographs, one photograph, or several in order.
    unit_system : UnitSystem, optional
        The units the report is written in; the package default,
        'in-slinch-lbf-s (g)', when omitted.
    marking : str, optional
        The banner across the top and bottom of every page;
        'UNCLASSIFIED' when omitted.

    Returns
    -------
    str or list of str
        The path written; a list of them when several runs were chosen or
        a list of runs was given.
    """
    return _one_call_reports(
        run, path, geometry, unit_system, 'sine',
        lambda one, geo: sine_run(one, last=last, geometry=geo, photos=photos),
        marking=marking)

sine_run

sine_run(run: str | PathLike, *, last: float | None = None, geometry: str | PathLike | None = None, length_unit: str | None = None, photos: Any = None) -> Project

A Rattlesnake sine sweep run, worked up into a project.

project = visualdynamics.sine_run('sweep.nc4')

The run imported, each tone's level extracted from the control channels against the sweep specification the file carried, and the geometry and photographs brought in and linked, as random_vibration_run brings them. The run says it is a sine sweep, so the project comes back declared as one. A run with no sine sweep specification is refused by name; random_vibration_run or mixed_run is the call for it. The keywords are random_vibration_run's, less per_octave, which a sweep has no use for.

Parameters:

Name Type Description Default
run str or PathLike

The controller's .nc4.

required
last float

Import only the last last seconds of the run's streams; a shorter run is taken whole.

None
geometry str or PathLike

A geometry file to import and link to the run.

None
length_unit str

The geometry's length unit, for a file that does not say.

None
photos str, os.PathLike or sequence of them

A folder of photographs, one photograph, or several in order.

None

Returns:

Type Description
Project

The worked-up project, declared Sine Sweep.

Source code in src/visualdynamics/project.py
def sine_run(run: str | os.PathLike, *,
             last: float | None = None,
             geometry: str | os.PathLike | None = None,
             length_unit: str | None = None,
             photos: Any = None) -> Project:
    """A Rattlesnake sine sweep run, worked up into a project.

        project = visualdynamics.sine_run('sweep.nc4')

    The run imported, each tone's level extracted from the control
    channels against the sweep specification the file carried, and the
    geometry and photographs brought in and linked, as
    `random_vibration_run` brings them. The run says it is a sine
    sweep, so the project comes back declared as one. A run with no
    sine sweep specification is refused by name; `random_vibration_run`
    or `mixed_run` is the call for it. The keywords are
    `random_vibration_run`'s, less `per_octave`, which a sweep has no
    use for.

    Parameters
    ----------
    run : str or os.PathLike
        The controller's `.nc4`.
    last : float, optional
        Import only the last `last` seconds of the run's streams; a
        shorter run is taken whole.
    geometry : str or os.PathLike, optional
        A geometry file to import and link to the run.
    length_unit : str, optional
        The geometry's length unit, for a file that does not say.
    photos : str, os.PathLike or sequence of them, optional
        A folder of photographs, one photograph, or several in order.

    Returns
    -------
    Project
        The worked-up project, declared Sine Sweep.
    """
    project = Project()
    project.import_file(run, **(_last_window(run, last) if last is not None
                                else {}))
    from .core.sine import SineSweepSpecification

    if not any(isinstance(obj, SineSweepSpecification)
               for _name, obj in project.items()):
        raise ValueError(
            f'{run} holds no sine sweep specification: it is not a sine '
            'sweep run — random_vibration_run or mixed_run is the call for it')
    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')
    project.extract_sine(history)
    extras: list[str] = []
    if geometry is not None:
        options = {} if length_unit is None else {'length_unit': length_unit}
        extras += project.import_file(geometry, **options)
    if photos is not None:
        extras.append(project.add('Photos', _photos_from(photos)))
    if extras:
        project.link(history, *extras)
    return project

system_id_report

system_id_report(run: Any = ASK, path: str | PathLike | None = None, *, last: float | None = None, geometry: Any = None, photos: Any = None, unit_system: Any = None, marking: str | None = None) -> Any

A Rattlesnake system identification in, an HTML report out.

visualdynamics.system_id_report('sysid.nc4', 'sysid.html')
visualdynamics.system_id_report()            # ask for both

system_id_run followed by the System ID report: the measured plant's FRFs, the coherence map, and the signal-to-noise of the measurement, line by line and per channel — where it falls to zero decibels, the plant is the room.

Everything about being asked, writing a batch and taking a folder is random_vibration_report's, and means the same things: left out, the run is asked for and as many may be chosen as there are reports wanted; the geometry is asked for once for the whole batch when the run was asked for, Cancel meaning none; visualdynamics.ASK forces either question; and path may be the file to write or the folder to write into.

Parameters:

Name Type Description Default
run str, os.PathLike, sequence of them, or `visualdynamics.ASK`

The controller's .nc4, or a list of them for a batch; asked for in a file dialog, as many as wanted, when omitted.

ASK
path (str, PathLike or None)

The file to write, or a folder (one that exists, or a name ending in a separator) the report lands in under the run's own name. Beside the run when omitted. Several runs need a folder or None.

None
last float

Import only the last last seconds of the run; a shorter run is taken whole.

None
geometry str, os.PathLike, `visualdynamics.ASK` or None

A geometry file to import and link to the run. Asked for once for the whole batch when the run was asked for, Cancel meaning none; ASK asks even when the run was given.

None
photos str, os.PathLike or sequence of them

A folder of photographs, one photograph, or several in order.

None
unit_system UnitSystem

The units the report is written in; the package default, 'in-slinch-lbf-s (g)', when omitted.

None
marking str

The banner across the top and bottom of every page; 'UNCLASSIFIED' when omitted.

None

Returns:

Type Description
str or list of str

The path written; a list of them when several runs were chosen or a list of runs was given.

Source code in src/visualdynamics/project.py
def system_id_report(run: Any = ASK,
                     path: str | os.PathLike | None = None, *,
                     last: float | None = None,
                     geometry: Any = None,
                     photos: Any = None,
                     unit_system: Any = None,
                     marking: str | None = None) -> Any:
    """A Rattlesnake system identification in, an HTML report out.

        visualdynamics.system_id_report('sysid.nc4', 'sysid.html')
        visualdynamics.system_id_report()            # ask for both

    `system_id_run` followed by the System ID report: the measured
    plant's FRFs, the coherence map, and the signal-to-noise of the
    measurement, line by line and per channel — where it falls to
    zero decibels, the plant is the room.

    Everything about being asked, writing a batch and taking a folder
    is `random_vibration_report`'s, and means the same things: left
    out, the run is asked for and as many may be chosen as there are
    reports wanted; the geometry is asked for once for the whole
    batch when the run was asked for, Cancel meaning none;
    `visualdynamics.ASK` forces either question; and `path` may be the
    file to write or the folder to write into.

    Parameters
    ----------
    run : str, os.PathLike, sequence of them, or `visualdynamics.ASK`
        The controller's `.nc4`, or a list of them for a batch; asked for
        in a file dialog, as many as wanted, when omitted.
    path : str, os.PathLike or None, optional
        The file to write, or a folder (one that exists, or a name ending
        in a separator) the report lands in under the run's own name.
        Beside the run when omitted. Several runs need a folder or None.
    last : float, optional
        Import only the last `last` seconds of the run; a shorter run is
        taken whole.
    geometry : str, os.PathLike, `visualdynamics.ASK` or None, optional
        A geometry file to import and link to the run. Asked for once for
        the whole batch when the run was asked for, Cancel meaning none;
        `ASK` asks even when the run was given.
    photos : str, os.PathLike or sequence of them, optional
        A folder of photographs, one photograph, or several in order.
    unit_system : UnitSystem, optional
        The units the report is written in; the package default,
        'in-slinch-lbf-s (g)', when omitted.
    marking : str, optional
        The banner across the top and bottom of every page;
        'UNCLASSIFIED' when omitted.

    Returns
    -------
    str or list of str
        The path written; a list of them when several runs were chosen or
        a list of runs was given.
    """
    return _one_call_reports(
        run, path, geometry, unit_system, 'sysid',
        lambda one, geo: system_id_run(one, last=last, geometry=geo,
                                       photos=photos),
        marking=marking)

system_id_run

system_id_run(run: str | PathLike, *, last: float | None = None, geometry: str | PathLike | None = None, length_unit: str | None = None, photos: Any = None) -> Project

A Rattlesnake system identification, worked up into a project.

project = visualdynamics.system_id_run('sysid.nc4')

The steps the window would take, in its order: import the recording, name the two streams for what they are, measure the plant from the driven one by H1 — the controller's own estimator, the excitation being known and the noise on the response — take the multiple coherence, and average a density from each stream on the same frames, since the signal-to-noise is a ratio of densities and a ratio only exists on shared lines. The project comes back declared a System ID.

last, geometry, length_unit and photos are random_vibration_run's, and mean the same things.

Parameters:

Name Type Description Default
run str or PathLike

The controller's .nc4.

required
last float

Import only the last last seconds of the run's streams; a shorter run is taken whole.

None
geometry str or PathLike

A geometry file to import and link to the run.

None
length_unit str

The geometry's length unit, for a file that does not say.

None
photos str, os.PathLike or sequence of them

A folder of photographs, one photograph, or several in order.

None

Returns:

Type Description
Project

The worked-up project, declared System ID.

Source code in src/visualdynamics/project.py
def system_id_run(run: str | os.PathLike, *,
                  last: float | None = None,
                  geometry: str | os.PathLike | None = None,
                  length_unit: str | None = None,
                  photos: Any = None) -> Project:
    """A Rattlesnake system identification, worked up into a project.

        project = visualdynamics.system_id_run('sysid.nc4')

    The steps the window would take, in its order: import the
    recording, name the two streams for what they are, measure the
    plant from the driven one by H1 — the controller's own estimator,
    the excitation being known and the noise on the response — take
    the multiple coherence, and average a density from each stream on
    **the same frames**, since the signal-to-noise is a ratio of
    densities and a ratio only exists on shared lines. The project
    comes back declared a System ID.

    `last`, `geometry`, `length_unit` and `photos` are
    `random_vibration_run`'s, and mean the same things.

    Parameters
    ----------
    run : str or os.PathLike
        The controller's `.nc4`.
    last : float, optional
        Import only the last `last` seconds of the run's streams; a
        shorter run is taken whole.
    geometry : str or os.PathLike, optional
        A geometry file to import and link to the run.
    length_unit : str, optional
        The geometry's length unit, for a file that does not say.
    photos : str, os.PathLike or sequence of them, optional
        A folder of photographs, one photograph, or several in order.

    Returns
    -------
    Project
        The worked-up project, declared System ID.
    """
    project = Project()
    project.import_file(run, **(_last_window(run, last) if last is not None
                                else {}))
    try:
        noise, driven = work_up_system_id(project)
    except ValueError as exc:
        raise ValueError(f'{run} holds no time data to work up') from exc
    extras: list[str] = []
    if geometry is not None:
        options = {} if length_unit is None else {'length_unit': length_unit}
        extras += project.import_file(geometry, **options)
    if photos is not None:
        extras.append(project.add('Photos', _photos_from(photos)))
    linked = [name for name in (noise, *extras) if name]
    if linked:
        project.link(driven, *linked)
    return project

export_html

export_html(path: str | PathLike, data: Any = None, *, specification: Any = None, channel: str | None = None, mode: str = 'curves', geometry: Any = None, shapes: Any = None, dofs: tuple[str, Any] | None = None, name: str | None = None, caption: str = '', theme: str | None = None, unit_system: UnitSystem | None = None, fill: bool = True) -> str

One figure, interactive, as a single self-contained HTML file.

visualdynamics.export_html('spec.html', psd, specification=spec,
                           channel='101Z+', theme='light')
visualdynamics.export_html('modes.html', geometry=g, shapes=modes)

The figure the report draws — zoom, pan and the readout on a plot, turning and animating on a scene — with nothing else: no title and no marking, and with fill the figure takes whatever frame holds it, an iframe or a box on a slide, redrawing when the frame changes size (Brandon, 2026-09-27, for an interactive slide deck from the scripts that draw a paper's printed figures). Everything the page needs is in the file, so it opens offline.

A plot of data — against specification and its zones when one is given, one channel of it when named, in mode ('curves', 'stage', 'cmif', 'mac' or 'map'); or, given a geometry, a scene of it, its shapes animating or the dofs one object measures as a quantity, ('acceleration', frf), drawn as labeled arrows.

Parameters:

Name Type Description Default
path str or path - like

Where the .html goes.

required
data DataArray or ShapeSet

What a plot draws.

None
specification Specification

What data is drawn against.

None
channel str

The one control channel of a comparison to draw.

None
mode str

The plot's reading.

'curves'
geometry Geometry

Makes the figure a scene.

None
shapes ShapeSet

What the scene animates.

None
dofs (str, object)

A quantity and an object: its DOFs of that quantity, as arrows.

None
name str

What the legend calls data.

None
caption str

A line under the figure.

''
theme ('light', 'dark')

Fixes the figure's theme; unset, it follows the reader's.

'light'
unit_system UnitSystem

The units it is drawn in.

None
fill bool

Fill the frame; False lays it out as the report page does.

True

Returns:

Type Description
str

The path written.

Source code in src/visualdynamics/report/__init__.py
def export_html(path: str | os.PathLike, data: Any = None, *,
                specification: Any = None, channel: str | None = None,
                mode: str = 'curves', geometry: Any = None,
                shapes: Any = None, dofs: tuple[str, Any] | None = None,
                name: str | None = None, caption: str = '',
                theme: str | None = None,
                unit_system: UnitSystem | None = None,
                fill: bool = True) -> str:
    """One figure, interactive, as a single self-contained HTML file.

        visualdynamics.export_html('spec.html', psd, specification=spec,
                                   channel='101Z+', theme='light')
        visualdynamics.export_html('modes.html', geometry=g, shapes=modes)

    The figure the report draws — zoom, pan and the readout on a plot,
    turning and animating on a scene — with nothing else: no title and
    no marking, and with `fill` the figure takes whatever frame holds
    it, an iframe or a box on a slide, redrawing when the frame changes
    size (Brandon, 2026-09-27, for an interactive slide deck from the
    scripts that draw a paper's printed figures). Everything the page
    needs is in the file, so it opens offline.

    A plot of `data` — against `specification` and its zones when one
    is given, one `channel` of it when named, in `mode` ('curves',
    'stage', 'cmif', 'mac' or 'map'); or, given a `geometry`, a scene of
    it, its `shapes` animating or the `dofs` one object measures as a
    quantity, ``('acceleration', frf)``, drawn as labeled arrows.

    Parameters
    ----------
    path : str or path-like
        Where the .html goes.
    data : DataArray or ShapeSet, optional
        What a plot draws.
    specification : Specification, optional
        What `data` is drawn against.
    channel : str, optional
        The one control channel of a comparison to draw.
    mode : str, default 'curves'
        The plot's reading.
    geometry : Geometry, optional
        Makes the figure a scene.
    shapes : ShapeSet, optional
        What the scene animates.
    dofs : (str, object), optional
        A quantity and an object: its DOFs of that quantity, as arrows.
    name : str, optional
        What the legend calls `data`.
    caption : str, optional
        A line under the figure.
    theme : {'light', 'dark'}, optional
        Fixes the figure's theme; unset, it follows the reader's.
    unit_system : UnitSystem, optional
        The units it is drawn in.
    fill : bool, default True
        Fill the frame; False lays it out as the report page does.

    Returns
    -------
    str
        The path written.
    """
    import pathlib

    from ..core.report import Report
    from ..project import Project

    holder = Project('Figure')
    if geometry is not None:
        holder.add('Geometry', geometry)
        block = {'kind': 'scene', 'geometry': 'Geometry', 'shapes': '',
                 'caption': caption}
        if shapes is not None:
            holder.add('Shapes', shapes)
            holder.link('Geometry', 'Shapes')
            block['shapes'] = 'Shapes'
        if dofs is not None:
            quantity, source = dofs
            holder.add('Measured', source)
            block.update(dofs=quantity, dofs_source='Measured')
    elif data is not None:
        label = name or ('Response' if specification is not None else 'Data')
        holder.add(label, data)
        block = {'kind': 'plot', 'source': label, 'mode': mode,
                 'caption': caption}
        if specification is not None:
            holder.add('Specification', specification)
            block['specification'] = 'Specification'
        if channel is not None:
            block['channel'] = channel
    else:
        raise ValueError('export_html needs data to plot or a geometry '
                         'to show')
    report = Report('', [block], marking='')
    html = render_html(report, dict(holder.items()), unit_system,
                       object_groups=holder.object_groups, fill=fill, theme=theme)
    path = pathlib.Path(path)
    path.write_text(html, encoding='utf-8')
    return str(path)

convert

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

values from one unit to another, through SI.

Parameters:

Name Type Description Default
values array - like

The values, in from_unit.

required
from_unit str

The unit they are in, e.g. 'in/s**2'.

required
to_unit str

The unit wanted, of the same dimension.

required

Returns:

Type Description
ndarray or float

The values in to_unit, the shape of values.

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.

    Parameters
    ----------
    values : array-like
        The values, in `from_unit`.
    from_unit : str
        The unit they are in, e.g. 'in/s**2'.
    to_unit : str
        The unit wanted, of the same dimension.

    Returns
    -------
    numpy.ndarray or float
        The values in `to_unit`, the shape of `values`.
    """
    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.

Parameters:

Name Type Description Default
unit str

The unit, e.g. 'mm' or 'lbf'.

required
dimension str

The dimension the unit is meant as, where a spelling is ambiguous.

None

Returns:

Type Description
float

The factor: a value in unit times this is the value in 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`.

    Parameters
    ----------
    unit : str
        The unit, e.g. 'mm' or 'lbf'.
    dimension : str, optional
        The dimension the unit is meant as, where a spelling is ambiguous.

    Returns
    -------
    float
        The factor: a value in `unit` times this is the value in 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.

Parameters:

Name Type Description Default
*paths str

Files to import on the way in.

()
in_process bool

True to run the window in this interpreter, False to start a fresh process; decided from the Qt already bound when omitted.

None

Returns:

Type Description
Popen or None

The process handle when the window was started apart; None when it ran here and has closed.

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.

    Parameters
    ----------
    *paths : str
        Files to import on the way in.
    in_process : bool, optional
        True to run the window in this interpreter, False to start a fresh
        process; decided from the Qt already bound when omitted.

    Returns
    -------
    subprocess.Popen or None
        The process handle when the window was started apart; None when it
        ran here and has closed.
    """
    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 the `visualdynamics` command 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)