Skip to content

visualdynamics.core.transform

transform

Physical responses through a shape set to modal responses, and back.

u = Φq is the whole relationship (PLAN.md, "The virtual point arc", phase 2). 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 serves — a fitted set, an eigensolution, the six rigid-body shapes of a geometry, which is the virtual point transformation of the substructuring literature with no special case in the code.

Every 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: 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 exactly what tells a translation from a rotation — and a set of autospectra alone is refused rather than completed with a guess (Brandon, 2026-09-04: no assumptions about cross terms; the user defines or computes them 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 — 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 coherence and a sine level set do not transform at all — a maximum, a ratio, a magnitude without phase — and the refusal says to transform the time history and recompute.

Records match shape columns by (DOF, quantity), with the sign honoured: a 101Z- channel against a 101Z+ column negates. Every column is a displacement quantity today; matching by quantity as well is what lets a later set carry strain at gauge DOFs beside displacement at accelerometer DOFs and slot into the same transform.

The quantity rule, three classes. Motions (acceleration, velocity, displacement) transform as responses, q = Φ⁺u, each quantity group with its own rule and unit. Forces transform the other way — a force is work-conjugate to a motion, so the modal force is Φᵀf, no pseudo-inverse and no rank condition. Everything else (temperature, voltage, pressure, strain) is left out and said: a displacement shape set says nothing about it, and a temperature at a virtual point would be a fiction.

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

A specification's bands carry through exactly when they can. The transform is linear, so a band that is the same decibels on every channel at a line is the same decibels on every modal channel there; bands that differ between channels have no single answer, and the transform refuses rather than pick one.

Modal DOF names. A modal coordinate is spelled M and the mode's 1-based index — M1M6 for a rigid set, M1M22 for a fitted one — one rule for every set (validate.modal_coordinate). Visibly not a node, and stable across renames; which set it belongs to is the object's provenance and 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.

Functions:

Name Description
modal_dofs

The DOF names a set's modal coordinates take: M1Mn.

reads_as

'physical' when the data's DOFs are on the set's coordinates,

to_modal

Physical responses through the set: modal responses at the

to_physical

Modal responses back through the set: u = Φq at every DOF the

carried_modes

The modal DOFs an expansion carried, in mode order — empty when

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: shared: The physical DOFs matched to shape columns, per quantity. dropped: The DOFs of transformable quantities the set has no column for — a shaker's synthetic drive DOF, a node the set never covered. skipped: How many records of each untransformable quantity were left out ({'temperature': 2}). rank: The rank of the shared shape matrix per motion quantity, against the number of modes. residual: 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: Anything else worth a sentence — references kept physical, cross-quantity blocks left out.

Methods:

Name Description
describe

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

Attributes:

Name Type Description
worst_residual float

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

Attributes
worst_residual property
worst_residual: float

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

Methods:
describe
describe() -> str

One line: what was shared, dropped, skipped, and left unexplained — the status line's wording.

Source code in src/visualdynamics/core/transform.py
def describe(self) -> str:
    """One line: what was shared, dropped, skipped, and left
    unexplained — the status line's wording."""
    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)

Functions:

modal_dofs

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

The DOF names a set's modal coordinates take: M1Mn.

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) -> str | None

'physical' when the data's DOFs are on the set's coordinates, 'modal' when they are the set's modal names, 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

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) -> str | None:
    """`'physical'` when the data's DOFs are on the set's coordinates,
    `'modal'` when they are the set's modal names, 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.

    Returns
    -------
    str or None
        Which way a transform would go.
    """
    if _cannot(data) is not None:
        return None
    lookup = shapes._dof_lookup()
    dofs = list(data.response_dof) + list(data.reference_dof or [])
    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

to_modal

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

Physical responses through the set: modal responses at the set's modal DOFs M1Mn, in the form the data's kind takes — rows for a time history or spectrum, the matrix for a density or specification, both halves for an FRF.

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 or a coherence 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, of the source's own class, and what was shared, dropped and left unexplained.

Source code in src/visualdynamics/core/transform.py
def to_modal(data: DataArray, shapes: ShapeSet,
             records: Sequence[int] | None = None
             ) -> tuple[DataArray, TransformReport]:
    """Physical responses through the set: modal responses at the
    set's modal DOFs `M1` … `Mn`, in the form the data's kind takes —
    rows for a time history or spectrum, the matrix for a density or
    specification, both halves for an FRF.

    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 or a coherence
        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, of the source's own class, and what was
        shared, dropped and left unexplained.
    """
    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]

Modal responses back through the set: u = Φq at every DOF the set covers, summed over the modes present, in the form the data's kind takes.

Every mode gives the motion of the structure; a few — one record picked in the tree — give those modes' contribution to it, which is what a modal contribution plot is. The result's comments say which modes it carries. Modal forces are left out: many force distributions give one 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.

None

Returns:

Type Description
tuple of (DataArray, TransformReport)

The physical responses, and which modal DOFs were used.

Source code in src/visualdynamics/core/transform.py
def to_physical(data: DataArray, shapes: ShapeSet,
                records: Sequence[int] | None = None
                ) -> tuple[DataArray, TransformReport]:
    """Modal responses back through the set: `u = Φq` at every DOF the
    set covers, summed over the modes present, in the form the data's
    kind takes.

    Every mode gives the motion of the structure; a few — one record
    picked in the tree — give those modes' contribution to it, which
    is what a modal contribution plot is. The result's comments say
    which modes it carries. Modal forces are left out: many force
    distributions give one 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.

    Returns
    -------
    tuple of (DataArray, TransformReport)
        The physical responses, and which modal DOFs were used.
    """
    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 reads_as(data, shapes) != 'modal':
        raise ValueError('the record\'s DOFs are not modal coordinates '
                         f'of this set ({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

carried_modes

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

The modal DOFs an expansion carried, in mode order — empty when it carried every mode, so a name need only say so when it 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 — empty when
    it carried every mode, so a name need only say so when it 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