Skip to content

visualdynamics.core.transform

transform

Transform physical responses through a shape set to modal responses, and back.

The relationship is u = Φq. Physical to modal is q = Φ⁺u, the least-squares fit over the DOFs the data and the set share; modal to physical is u = Φq, exact by definition. Any shape set can be used: a fitted set, an eigensolution, or the six rigid-body shapes of a geometry, which gives the virtual point transformation of the substructuring literature with no special case in the code.

Each kind of data goes through the same two rules, in the form its kind needs. A time history or a spectrum is rows, one per channel, and is transformed as q = Φ⁺u line by line, complex where the rows are. A cross-spectral matrix is a quadratic form, S_qq = Φ⁺ S_uu Φ⁺ᴴ, so it needs every cross term between the shared channels: the phase between two channels is what tells a translation from a rotation. A set of autospectra alone is refused, not completed with assumed cross terms; the user computes or defines the cross terms when they are needed. An FRF is a matrix from references to responses. Its response rows transform through Φ⁺ like any response, and its reference columns through the force rule, f = (Φᵀ)⁺ f_m, when the set covers the drives, which gives the virtual point's FRF in both halves. References the set does not cover stay physical, and the report says so. A shock response spectrum (a maximum), a coherence (a ratio) and a sine level (read tone by tone beside a noise floor) do not transform at all, and the refusal says to transform the time history and compute them again.

Records are matched to shape columns by DOF, within each quantity, with the sign honored: a 101Z- channel against a 101Z+ column is negated. Every shape column is a displacement today.

Each record's quantity sets how it transforms. Motions (acceleration, velocity, displacement) transform as responses, q = Φ⁺u, each quantity separately with its own unit. Forces transform the other way: a force is work-conjugate to a motion, so the modal force is Φᵀf, with no pseudo-inverse and no rank condition. Every other quantity (temperature, voltage, pressure, strain) is left out and reported, since a displacement shape set says nothing about it. A record whose units are not defined is left out and reported too, since its values are raw numbers.

The unit rule is [q] = [u]/[Φ]. Unit rigid-body shapes have dimensionless translations and length-per-radian rotations, so the translational modal responses keep the data's quantity and the rotational ones come out per radian (rad/s², or a moment for a force). A mass-normalized set is in 1/√kg, so its modal responses carry a half power of mass, the modal_* dimensions. Any other set with no declared mass unit gives modal responses with no unit, hinted with the modal_* dimension for Define Units to declare. The expansion applies the same rule the other way, so a round trip returns the original quantity and unit.

A specification's bands carry through when every channel has the same band. The transform is linear, so a band that is the same number of decibels on every channel at a line is the same on every modal channel there. Bands that differ between channels have no single answer, and the transform refuses them.

A modal coordinate is named M and the mode's 1-based index (M1 … M6 for a rigid set, M1 … M22 for a fitted one), one rule for every set (validate.modal_coordinate). The names are visibly not nodes and do not change when a set is renamed; which set a coordinate belongs to is in the object's provenance and in each record's comment, which names the mode as the set describes it.

Classes:

Name Description
TransformReport

What a transform used, left out, and left unexplained.

MatrixAt

A shape set's matrix at a choice of DOFs, in one of FORMS.

Functions:

Name Description
modal_dofs

The DOF names a set's modal coordinates take: M1 … Mn.

reads_as

Which way a transform of data through shapes would go.

modal_records

The records of data at the set's modal DOFs.

expands

Whether to_physical can expand data through shapes: it

to_modal

Transform physical responses through the set into modal

to_physical

Expand modal responses back through the set, u = Φq, at every

matrix_at

A shape set's matrix at dofs, as the transform uses it.

carried_modes

The modal DOFs an expansion carried, in mode order, or an empty

Classes

TransformReport dataclass

TransformReport(shared: dict[str, list[str]] = dict(), dropped: list[str] = list(), skipped: dict[str, int] = dict(), rank: dict[str, tuple[int, int]] = dict(), residual: dict[str, float] = dict(), notes: list[str] = list())

What a transform used, left out, and left unexplained.

Attributes:

Name Type Description
shared dict of str to list of str

The DOFs matched to shape columns, per quantity.

dropped list of str

The DOFs of transformable quantities the set has no column for, such as a shaker's synthetic drive DOF or a node the set never covered.

skipped dict of str to int

How many records of each quantity that does not transform were left out ({'temperature': 2}), including records whose units are not defined.

rank dict of str to (int, int)

Per motion quantity, the rank of the shared shape matrix and the number of modes.

residual dict of str to float

Per shared motion DOF, the fraction of its RMS (or of its power, for a density) the modes do not explain; 0 when the fit is exact.

notes list of str

Anything else worth a sentence, such as references kept physical or cross-quantity terms left out.

Methods:

Name Description
describe

One line saying what was shared, dropped, skipped and left

Attributes
worst_residual property
worst_residual: float

The largest per-DOF residual fraction, or 0 with none.

Methods:
describe
describe() -> str

One line saying what was shared, dropped, skipped and left unexplained, worded as the status line shows it.

Source code in src/visualdynamics/core/transform.py
def describe(self) -> str:
    """One line saying what was shared, dropped, skipped and left
    unexplained, worded as the status line shows it."""
    shared = len({dof for dofs in self.shared.values() for dof in dofs})
    parts = [f'{shared} DOF{"s" * (shared != 1)} shared']
    if self.dropped:
        parts.append(f'{len(self.dropped)} not in the shapes')
    if self.skipped:
        parts.append('not transformed: ' + ', '.join(
            f'{count} {quantity}' for quantity, count in
            self.skipped.items()))
    if self.residual:
        parts.append(f'residual {self.worst_residual:.1%} at worst')
    parts.extend(self.notes)
    return ', '.join(parts)

MatrixAt dataclass

MatrixAt(form: str, rows: list[str], columns: list[str], values: ndarray, missing: list[str] = list())

A shape set's matrix at a choice of DOFs, in one of FORMS.

Attributes:

Name Type Description
form str

'shapes', 'response' or 'reference'.

rows, columns list of str

The labels: DOFs down and modes across for the shapes, modes down and DOFs across for the two transformations.

values ndarray

(rows, columns), NaN where a DOF is not in the set.

missing list of str

The DOFs asked for that the set has no column for.

Functions:

modal_dofs

modal_dofs(shapes: ShapeSet) -> list[str]

The DOF names a set's modal coordinates take: M1 … Mn.

Parameters:

Name Type Description Default
shapes ShapeSet

The set.

required

Returns:

Type Description
list of str

One DOF string per mode, in mode order.

Source code in src/visualdynamics/core/transform.py
def modal_dofs(shapes: ShapeSet) -> list[str]:
    """The DOF names a set's modal coordinates take: `M1` … `Mn`.

    Parameters
    ----------
    shapes : ShapeSet
        The set.

    Returns
    -------
    list of str
        One DOF string per mode, in mode order.
    """
    return [f'{MODAL_PREFIX}{k + 1}' for k in range(shapes.num_shapes)]

reads_as

reads_as(data: DataArray, shapes: ShapeSet, records: Sequence[int] | None = None) -> str | None

Which way a transform of data through shapes would go.

'physical' when any of the data's DOFs is on the set's coordinates, 'modal' when any is one of the set's modal names, and None when neither, or when the kind of data does not transform at all.

Parameters:

Name Type Description Default
data DataArray

The object to read.

required
shapes ShapeSet

The set to read it against.

required
records sequence of int

Read only these records' DOFs; every record's when omitted.

None

Returns:

Type Description
str or None

Which way a transform would go.

Source code in src/visualdynamics/core/transform.py
def reads_as(data: DataArray, shapes: ShapeSet,
             records: Sequence[int] | None = None) -> str | None:
    """Which way a transform of `data` through `shapes` would go.

    `'physical'` when any of the data's DOFs is on the set's
    coordinates, `'modal'` when any is one of the set's modal names, and
    None when neither, or when the kind of data does not transform at
    all.

    Parameters
    ----------
    data : DataArray
        The object to read.
    shapes : ShapeSet
        The set to read it against.
    records : sequence of int, optional
        Read only these records' DOFs; every record's when omitted.

    Returns
    -------
    str or None
        Which way a transform would go.
    """
    if _cannot(data) is not None:
        return None
    lookup = shapes._dof_lookup()
    if records is None:
        dofs = list(data.response_dof) + list(data.reference_dof or [])
    else:
        picked = _picked(data, records)
        dofs = [data.response_dof[i] for i in picked] + (
            [] if data.reference_dof is None
            else [data.reference_dof[i] for i in picked])
    if any(_split_sign(dof)[0] in lookup for dof in dofs):
        return 'physical'
    if any(_is_modal(dof, shapes) for dof in dofs):
        return 'modal'
    return None

modal_records

modal_records(data: DataArray, shapes: ShapeSet) -> list[int]

The records of data at the set's modal DOFs.

A time history can hold physical channels and modal coordinates side by side: the forces of a test merged with the modal responses computed from them (Project.compute_modal_responses, then Project.merge). Such a record reads as physical as a whole; these are the records an expansion of it reads. Only records that stand alone (a time history, a spectrum) are counted: a density's or an FRF's records are terms of a matrix and are expanded together or not at all.

Parameters:

Name Type Description Default
data DataArray

The object to read.

required
shapes ShapeSet

The set to read it against.

required

Returns:

Type Description
list of int

The record indices, in order; empty when there are none, or when the kind of data does not transform this way.

Source code in src/visualdynamics/core/transform.py
def modal_records(data: DataArray, shapes: ShapeSet) -> list[int]:
    """The records of `data` at the set's modal DOFs.

    A time history can hold physical channels and modal coordinates
    side by side: the forces of a test merged with the modal responses
    computed from them (`Project.compute_modal_responses`, then
    `Project.merge`).
    Such a record reads as physical as a whole; these are the records
    an expansion of it reads. Only records that stand alone (a time
    history, a spectrum) are counted: a density's or an FRF's records
    are terms of a matrix and are expanded together or not at all.

    Parameters
    ----------
    data : DataArray
        The object to read.
    shapes : ShapeSet
        The set to read it against.

    Returns
    -------
    list of int
        The record indices, in order; empty when there are none, or
        when the kind of data does not transform this way.
    """
    from .data import Frf, Psd

    if _cannot(data) is not None or isinstance(data, (Psd, Frf)):
        return []
    return [i for i, dof in enumerate(data.response_dof)
            if _is_modal(dof, shapes)]

expands

expands(data: DataArray, shapes: ShapeSet) -> bool

Whether to_physical can expand data through shapes: it reads as modal, or it holds modal records beside physical ones (modal_records).

Modal forces do not expand (many force distributions give one modal force), so a record holding only those is not expandable.

Parameters:

Name Type Description Default
data DataArray

The object to read.

required
shapes ShapeSet

The set to read it against.

required

Returns:

Type Description
bool

True when there is something modal to expand.

Source code in src/visualdynamics/core/transform.py
def expands(data: DataArray, shapes: ShapeSet) -> bool:
    """Whether `to_physical` can expand `data` through `shapes`: it
    reads as modal, or it holds modal records beside physical ones
    (`modal_records`).

    Modal forces do not expand (many force distributions give one
    modal force), so a record holding only those is not expandable.

    Parameters
    ----------
    data : DataArray
        The object to read.
    shapes : ShapeSet
        The set to read it against.

    Returns
    -------
    bool
        True when there is something modal to expand.
    """
    # a modal force CPSD from Transform was offered Expand and refused
    # it when pressed (Brandon, 2026-10-30): principle 1
    records = (range(data.num_records) if reads_as(data, shapes) == 'modal'
               else modal_records(data, shapes))
    return any(_expands(data, i) for i in records)

to_modal

to_modal(data: DataArray, shapes: ShapeSet, records: Sequence[int] | None = None) -> tuple[DataArray, TransformReport]

Transform physical responses through the set into modal responses at the set's modal DOFs M1 … Mn.

The result takes the form the data's kind needs: rows for a time history or spectrum, the matrix for a density or specification, and both halves for an FRF. A time history's averaging frames and shock windows are carried onto the result.

Parameters:

Name Type Description Default
data DataArray

The physical record. Motions are fitted, forces projected, anything else left out and reported. A density needs every cross term between the shared channels; an SRS, a coherence or a sine level does not transform at all.

required
shapes ShapeSet

The set to transform through. Complex sets are refused: real data cannot carry a complex modal coordinate.

required
records sequence of int

Which records to carry through, the channels picked in the tree. All of them when omitted.

None

Returns:

Type Description
tuple of (DataArray, TransformReport)

The modal responses, and what was shared, dropped and left unexplained. A density or FRF comes back as the source's own class; a time history or spectrum as a TimeHistory or Spectrum.

Raises:

Type Description
ValueError

When the kind of data does not transform, the set is complex or has no modes, the shared DOFs of a motion quantity resolve fewer modes than the set has, cross terms or FRF pairs are missing, the bands differ between channels, or no motion or force record is on a DOF the shapes cover.

Source code in src/visualdynamics/core/transform.py
def to_modal(data: DataArray, shapes: ShapeSet,
             records: Sequence[int] | None = None
             ) -> tuple[DataArray, TransformReport]:
    """Transform physical responses through the set into modal
    responses at the set's modal DOFs `M1` … `Mn`.

    The result takes the form the data's kind needs: rows for a time
    history or spectrum, the matrix for a density or specification, and
    both halves for an FRF. A time history's averaging frames and shock
    windows are carried onto the result.

    Parameters
    ----------
    data : DataArray
        The physical record. Motions are fitted, forces projected,
        anything else left out and reported. A density needs every
        cross term between the shared channels; an SRS, a coherence or
        a sine level does not transform at all.
    shapes : ShapeSet
        The set to transform through. Complex sets are refused: real
        data cannot carry a complex modal coordinate.
    records : sequence of int, optional
        Which records to carry through, the channels picked in the
        tree. All of them when omitted.

    Returns
    -------
    tuple of (DataArray, TransformReport)
        The modal responses, and what was shared, dropped and left
        unexplained. A density or FRF comes back as the source's own
        class; a time history or spectrum as a `TimeHistory` or
        `Spectrum`.

    Raises
    ------
    ValueError
        When the kind of data does not transform, the set is complex or
        has no modes, the shared DOFs of a motion quantity resolve fewer
        modes than the set has, cross terms or FRF pairs are missing,
        the bands differ between channels, or no motion or force record
        is on a DOF the shapes cover.
    """
    from .data import Frf, Psd, TimeHistory

    refusal = _cannot(data)
    if refusal is not None:
        raise ValueError(refusal)
    _check_set(shapes)
    report = TransformReport()
    if isinstance(data, Psd):
        result = _matrix_to_modal(data, shapes, records, report)
    elif isinstance(data, Frf):
        result = _frf_to_modal(data, shapes, records, report)
    else:
        result = _rows_to_modal(data, shapes, records, report)
    if isinstance(data, TimeHistory):
        carrying_marks(data, result)
    return result, report

to_physical

to_physical(data: DataArray, shapes: ShapeSet, records: Sequence[int] | None = None) -> tuple[DataArray, TransformReport]

Expand modal responses back through the set, u = Φq, at every DOF the set covers.

The result is summed over the modes present, in the form the data's kind takes. With every mode it is the motion of the structure; with a few (the records picked in the tree) it is those modes' contribution to it, as in a modal contribution plot. The result's comments say which modes it carries. Modal forces are left out, because many force distributions give the same modal force.

Parameters:

Name Type Description Default
data DataArray

The modal record, its DOFs the set's modal names.

required
shapes ShapeSet

The set the record was transformed through.

required
records sequence of int

Which modal records to expand, the modes picked in the tree. All of them when omitted; in a record that holds physical channels too (modal_records), all of its modal records.

None

Returns:

Type Description
tuple of (DataArray, TransformReport)

The physical responses, and which modal DOFs were used.

Raises:

Type Description
ValueError

When the kind of data does not transform, the set is complex or has no modes, the record's DOFs are not this set's modal coordinates, cross terms or FRF pairs are missing, the bands differ between modal channels, or no motion record is at one of the set's modal DOFs.

Source code in src/visualdynamics/core/transform.py
def to_physical(data: DataArray, shapes: ShapeSet,
                records: Sequence[int] | None = None
                ) -> tuple[DataArray, TransformReport]:
    """Expand modal responses back through the set, `u = Φq`, at every
    DOF the set covers.

    The result is summed over the modes present, in the form the
    data's kind takes. With every mode it is the motion of the
    structure; with a few (the records picked in the tree) it is those
    modes' contribution to it, as in a modal contribution plot. The
    result's comments say which modes it carries. Modal forces are left
    out, because many force distributions give the same modal force.

    Parameters
    ----------
    data : DataArray
        The modal record, its DOFs the set's modal names.
    shapes : ShapeSet
        The set the record was transformed through.
    records : sequence of int, optional
        Which modal records to expand, the modes picked in the tree.
        All of them when omitted; in a record that holds physical
        channels too (`modal_records`), all of its modal records.

    Returns
    -------
    tuple of (DataArray, TransformReport)
        The physical responses, and which modal DOFs were used.

    Raises
    ------
    ValueError
        When the kind of data does not transform, the set is complex or
        has no modes, the record's DOFs are not this set's modal
        coordinates, cross terms or FRF pairs are missing, the bands
        differ between modal channels, or no motion record is at one of
        the set's modal DOFs.
    """
    from .data import Frf, Psd, TimeHistory

    refusal = _cannot(data)
    if refusal is not None:
        raise ValueError(refusal)
    _check_set(shapes)
    names = modal_dofs(shapes)
    if records is None and reads_as(data, shapes) == 'physical':
        # forces with the modal responses they drive appended (Brandon,
        # 2026-10-25): the whole record reads physical, and what there
        # is to expand is its modal records
        records = modal_records(data, shapes) or None
    if reads_as(data, shapes, records) != 'modal':
        which = ('the record\'s DOFs are not' if records is None
                 else 'the records picked are not all')
        raise ValueError(f'{which} modal coordinates of this set '
                         f'({names[0]} … {names[-1]})')
    report = TransformReport()
    if isinstance(data, Psd):
        result = _matrix_to_physical(data, shapes, records, report)
    elif isinstance(data, Frf):
        result = _frf_to_physical(data, shapes, records, report)
    else:
        result = _rows_to_physical(data, shapes, records, report)
    if isinstance(data, TimeHistory):
        carrying_marks(data, result)
    return result, report

matrix_at

matrix_at(shapes: ShapeSet, dofs: Sequence[str] | None = None, form: str = 'shapes') -> MatrixAt

A shape set's matrix at dofs, as the transform uses it.

'shapes' is Φ at the DOFs, a row per DOF and a column per mode: how each modal coordinate moves each DOF, u = Φq, each value as stored, complex where the set is. 'response' is Φ⁺, a row per mode and a column per DOF, what to_modal applies to motions, q = Φ⁺u. 'reference' is Φᵀ, what it applies to forces, f_m = Φᵀf, and what a controller applies to its drive signals. The names are the roles a controller gives the two matrices; what the transform applies follows a channel's quantity, not its role, so a motion reference (a base-excitation accelerometer) transforms by the response transformation and a force response by the reference one. The two transformations are over the real shapes, as the transform takes them, and the pseudo-inverse is refused when the DOFs resolve fewer modes than the set has.

A DOF is matched to the set's column as the transform matches a channel, its sign honored: 2X- is the negative of the set's 2X+. A DOF the set has no column for is kept, its row or column NaN, and listed in missing. With dofs left out, every DOF of the set.

Parameters:

Name Type Description Default
shapes ShapeSet

The set.

required
dofs sequence of str

The DOFs, in the order they are wanted.

None
form str

One of FORMS.

'shapes'

Returns:

Type Description
MatrixAt

The labels, the values and the DOFs not in the set.

Raises:

Type Description
ValueError

For a form not in FORMS, a transformation at DOFs none of which is in the set, or a response transformation the DOFs are too few to resolve.

Source code in src/visualdynamics/core/transform.py
def matrix_at(shapes: ShapeSet, dofs: Sequence[str] | None = None,
              form: str = 'shapes') -> MatrixAt:
    """A shape set's matrix at `dofs`, as the transform uses it.

    'shapes' is `Φ` at the DOFs, a row per DOF and a column per mode:
    how each modal coordinate moves each DOF, `u = Φq`, each value as
    stored, complex where the set is. 'response' is `Φ⁺`, a row per mode
    and a column per DOF, what `to_modal` applies to motions,
    `q = Φ⁺u`. 'reference' is `Φᵀ`, what it applies to forces,
    `f_m = Φᵀf`, and what a controller applies to its drive signals.
    The names are the roles a controller gives the two matrices; what
    the transform applies follows a channel's quantity, not its role, so
    a motion reference (a base-excitation accelerometer) transforms by
    the response transformation and a force response by the reference
    one. The two transformations are over the real shapes, as the
    transform takes them, and the pseudo-inverse is refused when the
    DOFs resolve fewer modes than the set has.

    A DOF is matched to the set's column as the transform matches a
    channel, its sign honored: `2X-` is the negative of the set's `2X+`.
    A DOF the set has no column for is kept, its row or column NaN, and
    listed in `missing`. With `dofs` left out, every DOF of the set.

    Parameters
    ----------
    shapes : ShapeSet
        The set.
    dofs : sequence of str, optional
        The DOFs, in the order they are wanted.
    form : str, default 'shapes'
        One of `FORMS`.

    Returns
    -------
    MatrixAt
        The labels, the values and the DOFs not in the set.

    Raises
    ------
    ValueError
        For a form not in `FORMS`, a transformation at DOFs none of
        which is in the set, or a response transformation the DOFs are
        too few to resolve.
    """
    # Brandon, 2026-10-23: the kinematic transformation at a channel
    # table's references, responses or control channels, beside the
    # shapes themselves, read the way the transform reads them.
    if form not in FORMS:
        raise ValueError(f'{form!r} is not a form; use one of {FORMS}')
    names = modal_dofs(shapes)
    if dofs is None:
        if form == 'shapes':
            return MatrixAt(form, list(shapes.coordinate), names,
                            np.asarray(shapes.shape_matrix).T)
        dofs = list(shapes.coordinate)
    dofs = [str(dof) for dof in dofs]
    report = TransformReport()
    if form == 'shapes':
        lookup = shapes._dof_lookup()
        matrix = np.asarray(shapes.shape_matrix)
        values = np.full((len(dofs), shapes.num_shapes), np.nan,
                         dtype=np.result_type(matrix.dtype, np.float64))
        missing = []
        for row, dof in enumerate(dofs):
            base, sign = _split_sign(dof)
            found = lookup.get(base)
            if found is None:
                missing.append(dof)
                continue
            column, column_sign = found
            values[row] = matrix[:, column] * sign * column_sign
        return MatrixAt(form, dofs, names, values, missing)
    kept, phi = _columns(shapes, dofs, report)
    if not kept:
        raise ValueError('none of these DOFs is in the set')
    block = (_fit(phi, 'motion', len(kept), shapes, report)
             if form == 'response' else phi.T)
    values = np.full((shapes.num_shapes, len(dofs)), np.nan)
    at = {dof: k for k, dof in enumerate(kept)}
    for column, dof in enumerate(dofs):
        if dof in at:
            values[:, column] = block[:, at[dof]]
    return MatrixAt(form, names, dofs, values, list(report.dropped))

carried_modes

carried_modes(report: TransformReport, shapes: ShapeSet) -> list[str]

The modal DOFs an expansion carried, in mode order, or an empty list when it carried every mode, so a name lists the modes only when that matters.

Source code in src/visualdynamics/core/transform.py
def carried_modes(report: TransformReport, shapes: ShapeSet) -> list[str]:
    """The modal DOFs an expansion carried, in mode order, or an empty
    list when it carried every mode, so a name lists the modes only
    when that matters."""
    used = sorted({dof for dofs in report.shared.values() for dof in dofs
                   if modal_coordinate(dof) is not None},
                  key=lambda dof: modal_coordinate(dof) or 0)
    return [] if len(used) == shapes.num_shapes else used