Skip to content

visualdynamics.core.transformation

transformation

A transformation between control channels and degrees of freedom, as an object of its own: a matrix with named inputs and outputs and the units its entries carry.

A six-DOF test controls the motion of a point, and measures it with accelerometers spread over the article. The matrix that turns the measured channels into the point's three translations and three rotations is the most consequential setting in such a test, and it is usually typed into a controller once and never read again. A Transformation holds it so it can be read: which channel goes in where, what comes out, in what unit, how well the inputs resolve the outputs, and how much of the measured motion the outputs leave unexplained.

The matrix T maps inputs to outputs, y = T u. Built from geometry it is the pseudo-inverse of the six rigid-body shapes at the input DOFs, T = Φ⁺, the same matrix transform.to_modal applies through a rigid set (transform.matrix_at's response form); typed, it is whatever the user states, with its inputs and outputs named. A controller run's matrices arrive on the channel table, one per environment (channel_table.ChannelTable.transformation), with a column per control channel headed by its DOF and the rows numbered; named, the rows make one a typed transformation. Either way it is applied the same way, to a time history or spectrum row by row, y = T u, and to a cross-spectral density or a specification as the quadratic form G_yy = T G_uu Tᵀ, which needs every cross term between the inputs: the phase between two channels is what tells a translation from a rotation. The package stores a cross term as conj(X_a)·X_b (compute_cpsds), which makes the form conj(T) G_uu Tᵀ; a transformation is real, so the two are one.

Every input and output is a translation or a rotation. An entry's unit follows from the two it joins: [T_ij] = length^(r_j − r_i), with r one for a rotation and zero for a translation, so a translation from translations is a plain number, a rotation from translations is per meter, and a translation from a rotation is in meters. Stored values are SI like every other value in the package. An output rotation comes out in the input's angular quantity: acceleration in, angular acceleration out, in rad/s².

The residual is what the outputs cannot carry back. Q = T⁺T is the orthogonal projector onto the motions the outputs describe, and P = I − Q onto the rest. For a rigid-body transformation, P u is the part of the measured motion that is not rigid: a channel's local flexing, a mode of the article, a loose mount or a channel wired to the wrong DOF. For a density the measured power splits exactly in two, trace G = trace(Q G Q) + trace(P G P), because P Q = 0, so the residual is a fraction of the measured power at every line, and read_residual says up to what frequency that fraction stays under a stated threshold: where the rigid-body assumption holds.

The conditioning is the ratio of the largest to the smallest singular value. Entries in mixed units make that ratio depend on the length unit, so it is computed with every rotation scaled by a characteristic length, length_scale: the RMS lever arm of the inputs about the point for a transformation built from geometry, one of the stated length unit for a typed one. A pseudo-inverse whose conditioning is past a stated limit is refused, since an error of one part in a hundred at the inputs can then reach the outputs a hundred times larger.

A force does not transform by this matrix. A force is work-conjugate to a motion, so a controller's drives transform by the transpose of the shapes (transform.matrix_at(form='reference')), and the transform verbs apply that rule by quantity.

Classes:

Name Description
Transformation

A matrix from named inputs to named outputs, y = T u, with the

TransformationResidual

The measured power at a transformation's inputs split in two at

Functions:

Name Description
read_residual

Split the measured power at a transformation's inputs into the

Classes

Transformation dataclass

Transformation(matrix: ndarray, inputs: tuple[str, ...], outputs: tuple[str, ...], input_kinds: tuple[str, ...], output_kinds: tuple[str, ...], description: tuple[str, ...] = (), length_scale: float = 1.0, origin: str = '')

A matrix from named inputs to named outputs, y = T u, with the kind of each so its entries' units are known.

Made with from_geometry (the rigid-body transformation to a point), from_shapes (through any rigid set at chosen DOFs) or typed (a matrix stated by hand). Checked when it is created: the matrix is real and finite, its shape is the outputs by the inputs, the names are DOF strings with none repeated, and the length scale is a positive number of meters.

Attributes:

Name Type Description
matrix ndarray

(outputs, inputs), SI: a rotation row from translations per meter, a translation row from a rotation in meters.

inputs, outputs tuple of str

The DOF strings, in the matrix's order. An input's sign is honored when a record is matched to it: a 101Z- record at a 101Z+ input is negated.

input_kinds, output_kinds tuple of str

'translation' or 'rotation' for each (KINDS).

description tuple of str

A line per output saying what it is ('Rigid rotation about Y'), or empty.

length_scale float

The length, in meters, the rotations are scaled by for the conditioning (condition).

origin str

How the transformation was made, in a line.

Methods:

Name Description
from_shapes

The transformation from motions at inputs to the coordinates

from_geometry

The rigid-body transformation from motions at inputs to the

typed

A transformation stated by hand: a matrix and the names and

entry_unit

The unit of one entry, in length_unit: '' for a plain

output_dimension

The dimension output row takes for an input motion

matrix_at

The matrix as transform.matrix_at gives a shape set's, rows

table

The matrix as text for a controller: a row per output, a

describe

One line: the shape, the conditioning and how it was made.

inverse

The pseudo-inverse, outputs back to inputs, u = T⁺ y.

apply

Apply the transformation to a record: a time history or a

read_residual

What the outputs leave unexplained of a cross-spectral

Attributes
scaled property
scaled: ndarray

The matrix with every entry a plain number: rotation rows times length_scale, rotation columns over it. What the singular values and the conditioning are read from.

singular_values property
singular_values: ndarray

The singular values of scaled, largest first.

condition property
condition: float

The largest singular value of scaled over the smallest: how many times larger a relative error at the inputs can reach the outputs. Infinite when the matrix is short of full rank.

residual_projector property
residual_projector: ndarray

P = I − T⁺T, (inputs, inputs): the part of an input motion the outputs cannot carry back. Zero for a square, invertible transformation, which explains everything it is given and so cannot say anything about the residual.

Methods:
from_shapes classmethod
from_shapes(shapes: ShapeSet, inputs: Sequence[str], *, max_condition: float = MAX_CONDITION) -> Transformation

The transformation from motions at inputs to the coordinates of a rigid set, T = Φ⁺, the matrix transform.to_modal applies through the same set.

Parameters:

Name Type Description Default
shapes ShapeSet

Six unscaled rigid-body shapes (rigid.rigid_body_shapes about a point, with no mass properties). A mass-normalized set is refused: its coordinates carry a half power of mass and are not the motions of the point.

required
inputs sequence of str

The input DOFs, in the order wanted. A DOF the set has no column for is refused by name.

required
max_condition float

The conditioning past which the pseudo-inverse is refused.

`MAX_CONDITION`

Returns:

Type Description
Transformation

Outputs M1 … M6, three translations then three rotations, described as the set describes them, with the RMS lever arm of the inputs as the length scale.

Raises:

Type Description
ValueError

When the set is not unscaled rigid shapes, an input is not in the set, the inputs resolve fewer than six motions, or the conditioning is past the limit.

Source code in src/visualdynamics/core/transformation.py
@classmethod
def from_shapes(cls, shapes: ShapeSet, inputs: Sequence[str], *,
                max_condition: float = MAX_CONDITION) -> Transformation:
    """The transformation from motions at `inputs` to the coordinates
    of a rigid set, `T = Φ⁺`, the matrix `transform.to_modal` applies
    through the same set.

    Parameters
    ----------
    shapes : ShapeSet
        Six unscaled rigid-body shapes (`rigid.rigid_body_shapes`
        about a point, with no mass properties). A mass-normalized
        set is refused: its coordinates carry a half power of mass
        and are not the motions of the point.
    inputs : sequence of str
        The input DOFs, in the order wanted. A DOF the set has no
        column for is refused by name.
    max_condition : float, default `MAX_CONDITION`
        The conditioning past which the pseudo-inverse is refused.

    Returns
    -------
    Transformation
        Outputs `M1` … `M6`, three translations then three
        rotations, described as the set describes them, with the
        RMS lever arm of the inputs as the length scale.

    Raises
    ------
    ValueError
        When the set is not unscaled rigid shapes, an input is not in
        the set, the inputs resolve fewer than six motions, or the
        conditioning is past the limit.
    """
    from .rigid import is_rigid_set

    if not is_rigid_set(shapes) or not shapes.unscaled:
        raise ValueError(
            'a transformation to the motions of a point is built from '
            'six unscaled rigid-body shapes; this set is '
            + ('mass-normalized, so its coordinates are not motions'
               if is_rigid_set(shapes) else 'not a rigid set'))
    names = [str(dof) for dof in inputs]
    if not names:
        raise ValueError('no inputs: a transformation needs channels '
                         'to transform')
    report = TransformReport()
    kept, phi = _columns(shapes, names, report)
    if report.dropped:
        raise ValueError(
            f'{report.dropped[0]} is not a DOF of the set'
            + (f' (nor {len(report.dropped) - 1} more)'
               if len(report.dropped) > 1 else '')
            + ': the shapes say nothing of how it moves')
    output_kinds = ('translation',) * 3 + ('rotation',) * 3
    input_kinds = ('translation',) * len(kept)
    # the rotation columns of Φ are each input's lever arm about an
    # axis through the point; their RMS over the inputs is the
    # length that makes a rotation and a translation comparable
    arms = phi[:, 3:]
    length = float(np.sqrt(np.mean(np.sum(arms ** 2, axis=1))))
    if not length > 0.0:
        raise ValueError('every input is on the point itself, so no '
                         'rotation can be resolved')
    # Φ is (inputs, modes): its columns are the outputs' kinds and
    # its rows the inputs'
    inverse = _checked_pinv(phi, output_kinds, input_kinds, length,
                            max_condition, phi.shape[1],
                            f'the {len(kept)} inputs')
    return cls(inverse, tuple(kept), tuple(modal_dofs(shapes)),
               input_kinds, output_kinds,
               description=tuple(shapes.description),
               length_scale=length,
               origin=f'rigid body through {len(kept)} channels')
from_geometry classmethod
from_geometry(geometry: Geometry, point: Sequence[float], inputs: Sequence[str], *, max_condition: float = MAX_CONDITION) -> Transformation

The rigid-body transformation from motions at inputs to the three translations and three rotations of point.

The six unit rigid-body shapes about the point (rigid.rigid_body_shapes) at the inputs, pseudo-inverted (from_shapes). The geometry's length unit must be declared: the rotation rows are per meter, and coordinates with no unit would make them per nothing anyone could name.

Parameters:

Name Type Description Default
geometry Geometry

The nodes the inputs are on, in declared units.

required
point sequence of float

The point, three coordinates in meters.

required
inputs sequence of str

The input DOFs, in the order wanted.

required
max_condition float

The conditioning past which the pseudo-inverse is refused.

`MAX_CONDITION`

Returns:

Type Description
Transformation

As from_shapes makes it.

Raises:

Type Description
ValueError

When the geometry's length unit is not declared, or as from_shapes refuses.

Source code in src/visualdynamics/core/transformation.py
@classmethod
def from_geometry(cls, geometry: Geometry,
                  point: Sequence[float], inputs: Sequence[str], *,
                  max_condition: float = MAX_CONDITION
                  ) -> Transformation:
    """The rigid-body transformation from motions at `inputs` to the
    three translations and three rotations of `point`.

    The six unit rigid-body shapes about the point
    (`rigid.rigid_body_shapes`) at the inputs, pseudo-inverted
    (`from_shapes`). The geometry's length unit must be declared:
    the rotation rows are per meter, and coordinates with no unit
    would make them per nothing anyone could name.

    Parameters
    ----------
    geometry : Geometry
        The nodes the inputs are on, in declared units.
    point : sequence of float
        The point, three coordinates in meters.
    inputs : sequence of str
        The input DOFs, in the order wanted.
    max_condition : float, default `MAX_CONDITION`
        The conditioning past which the pseudo-inverse is refused.

    Returns
    -------
    Transformation
        As `from_shapes` makes it.

    Raises
    ------
    ValueError
        When the geometry's length unit is not declared, or as
        `from_shapes` refuses.
    """
    from .rigid import MassProperties, rigid_body_shapes

    if not geometry.units_defined:
        raise ValueError(
            'the geometry\'s length unit is not declared, so a rotation '
            'row would be per an unknown length — declare it with '
            'define_units first')
    properties = MassProperties(point=point)
    shapes = rigid_body_shapes(geometry, properties)
    made = cls.from_shapes(shapes, inputs, max_condition=max_condition)
    where = ', '.join(f'{v:.4g}' for v in properties.point)
    return replace(made, origin=f'rigid body about ({where}) m through '
                                f'{len(made.inputs)} channels')
typed classmethod
typed(matrix: Any, inputs: Sequence[str], outputs: Sequence[str], *, input_kinds: Sequence[str] | None = None, output_kinds: Sequence[str] | None = None, length_unit: str = 'm', description: Sequence[str] = ()) -> Transformation

A transformation stated by hand: a matrix and the names and kinds of what goes in and what comes out.

The entries are read in length_unit, as a controller's matrix is usually typed: a rotation row from translations per that unit, a translation row from a rotation in it. They are stored in SI. The length scale for the conditioning is one of that unit.

Parameters:

Name Type Description Default
matrix array - like

(outputs, inputs), real.

required
inputs sequence of str

The DOF strings. A virtual point is a node of its own ('1000X+' … '1000RZ+'), or the modal coordinates M1 … Mn.

required
outputs sequence of str

The DOF strings. A virtual point is a node of its own ('1000X+' … '1000RZ+'), or the modal coordinates M1 … Mn.

required
input_kinds sequence of str

'translation' or 'rotation' for each. Read from each DOF's direction when left out ('1000RX+' is a rotation); a modal coordinate needs its kind stated.

None
output_kinds sequence of str

'translation' or 'rotation' for each. Read from each DOF's direction when left out ('1000RX+' is a rotation); a modal coordinate needs its kind stated.

None
length_unit str

The length the entries are read in.

'm'
description sequence of str

A line per output.

()

Returns:

Type Description
Transformation

Raises:

Type Description
ValueError

When the matrix, the names or the kinds do not agree, an entry is not a finite real number, or the unit is not a length.

Source code in src/visualdynamics/core/transformation.py
@classmethod
def typed(cls, matrix: Any, inputs: Sequence[str],
          outputs: Sequence[str], *,
          input_kinds: Sequence[str] | None = None,
          output_kinds: Sequence[str] | None = None,
          length_unit: str = 'm',
          description: Sequence[str] = ()) -> Transformation:
    """A transformation stated by hand: a matrix and the names and
    kinds of what goes in and what comes out.

    The entries are read in `length_unit`, as a controller's matrix
    is usually typed: a rotation row from translations per that
    unit, a translation row from a rotation in it. They are stored
    in SI. The length scale for the conditioning is one of that
    unit.

    Parameters
    ----------
    matrix : array-like
        `(outputs, inputs)`, real.
    inputs, outputs : sequence of str
        The DOF strings. A virtual point is a node of its own
        (`'1000X+'` … `'1000RZ+'`), or the modal coordinates
        `M1` … `Mn`.
    input_kinds, output_kinds : sequence of str, optional
        'translation' or 'rotation' for each. Read from each DOF's
        direction when left out (`'1000RX+'` is a rotation); a modal
        coordinate needs its kind stated.
    length_unit : str, default 'm'
        The length the entries are read in.
    description : sequence of str, optional
        A line per output.

    Returns
    -------
    Transformation

    Raises
    ------
    ValueError
        When the matrix, the names or the kinds do not agree, an
        entry is not a finite real number, or the unit is not a
        length.
    """
    from ..units import si_transform

    matrix = np.asarray(matrix)
    if np.iscomplexobj(matrix):
        raise ValueError('a transformation is real: a complex entry '
                         'would turn a motion into a phase shift')
    matrix = np.array(matrix, dtype=np.float64)
    inputs = [str(dof) for dof in inputs]
    outputs = [str(dof) for dof in outputs]
    ins = _kinds(inputs, input_kinds, 'input')
    outs = _kinds(outputs, output_kinds, 'output')
    try:
        meters, _offset = si_transform(length_unit, 'length')
    except Exception as error:
        raise ValueError(f'{length_unit!r} is not a length unit') \
            from error
    if matrix.ndim == 2 and matrix.shape == (len(outs), len(ins)):
        # a rotation row from a translation is per length unit, so
        # to per meter it divides by the unit's meters; a
        # translation row from a rotation multiplies
        power = (np.asarray([kind == 'rotation' for kind in ins], float)
                 [np.newaxis, :]
                 - np.asarray([kind == 'rotation' for kind in outs],
                              float)[:, np.newaxis])
        matrix = matrix * float(meters) ** power
    return cls(matrix, tuple(inputs), tuple(outputs), ins, outs,
               description=tuple(description),
               length_scale=float(meters),
               origin=f'typed, entries in {length_unit}')
entry_unit
entry_unit(row: int, column: int, length_unit: str = 'm') -> str

The unit of one entry, in length_unit: '' for a plain number, '1/m' or 'm'.

Source code in src/visualdynamics/core/transformation.py
def entry_unit(self, row: int, column: int,
               length_unit: str = 'm') -> str:
    """The unit of one entry, in `length_unit`: '' for a plain
    number, '1/m' or 'm'."""
    power = ((self.input_kinds[column] == 'rotation')
             - (self.output_kinds[row] == 'rotation'))
    return {0: '', 1: length_unit, -1: f'1/{length_unit}'}[int(power)]
output_dimension
output_dimension(quantity: str, row: int) -> str

The dimension output row takes for an input motion quantity: the quantity for a translation, its angular quantity for a rotation.

Source code in src/visualdynamics/core/transformation.py
def output_dimension(self, quantity: str, row: int) -> str:
    """The dimension output `row` takes for an input motion
    `quantity`: the quantity for a translation, its angular quantity
    for a rotation."""
    return (ROTATIONAL[quantity] if self.output_kinds[row] == 'rotation'
            else quantity)
matrix_at
matrix_at() -> MatrixAt

The matrix as transform.matrix_at gives a shape set's, rows the outputs and columns the inputs, so its weights draw on a geometry as arrows (viz.geometry.plot_transformation).

Source code in src/visualdynamics/core/transformation.py
def matrix_at(self) -> MatrixAt:
    """The matrix as `transform.matrix_at` gives a shape set's, rows
    the outputs and columns the inputs, so its weights draw on a
    geometry as arrows (`viz.geometry.plot_transformation`)."""
    return MatrixAt('response', list(self.outputs), list(self.inputs),
                    np.array(self.matrix))
table
table(length_unit: str = 'm') -> tuple[list[str], list[list[str]]]

The matrix as text for a controller: a row per output, a column per input, each entry in length_unit with the row's unit named in the last column.

Parameters:

Name Type Description Default
length_unit str

The length a rotation row's entries are written per.

'm'

Returns:

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

The column headings and the rows.

Source code in src/visualdynamics/core/transformation.py
def table(self, length_unit: str = 'm'
          ) -> tuple[list[str], list[list[str]]]:
    """The matrix as text for a controller: a row per output, a
    column per input, each entry in `length_unit` with the row's
    unit named in the last column.

    Parameters
    ----------
    length_unit : str, default 'm'
        The length a rotation row's entries are written per.

    Returns
    -------
    tuple of (list of str, list of list of str)
        The column headings and the rows.
    """
    from ..units import si_transform

    meters, _offset = si_transform(length_unit, 'length')
    headings = ['Output', *self.inputs, 'Unit']
    rows = []
    for i, name in enumerate(self.outputs):
        values = []
        units = set()
        # a pseudo-inverse leaves round-off where an entry is zero
        # (1e-20 for a sensor that cannot see a rotation); written
        # out it reads as a weight, so it is written as the zero it is
        largest = float(np.max(np.abs(self.matrix[i])))
        for j in range(len(self.inputs)):
            power = ((self.input_kinds[j] == 'rotation')
                     - (self.output_kinds[i] == 'rotation'))
            value = self.matrix[i, j]
            if abs(value) <= ROUND_OFF * largest:
                value = 0.0
            values.append(f'{value / float(meters) ** power:.6g}')
            units.add(self.entry_unit(i, j, length_unit))
        unit = ' or '.join(sorted(u or '1' for u in units))
        rows.append([name, *values, unit])
    return headings, rows
describe
describe() -> str

One line: the shape, the conditioning and how it was made.

Source code in src/visualdynamics/core/transformation.py
def describe(self) -> str:
    """One line: the shape, the conditioning and how it was made."""
    condition = self.condition
    text = (f'{len(self.inputs)} input{"s" * (len(self.inputs) != 1)} '
            f'to {len(self.outputs)} output'
            f'{"s" * (len(self.outputs) != 1)}, conditioning '
            + (f'{condition:.3g}' if np.isfinite(condition)
               else 'infinite (short of full rank)'))
    return text + (f', {self.origin}' if self.origin else '')
inverse
inverse(*, max_condition: float = MAX_CONDITION) -> Transformation

The pseudo-inverse, outputs back to inputs, u = T⁺ y.

For a rigid-body transformation this is the expansion: the six motions of the point carried out to the channels, the rigid part of what they measure. It is refused when the outputs are more than the matrix's rank (they could not all be told apart) or the conditioning is past max_condition.

Parameters:

Name Type Description Default
max_condition float

The conditioning past which it is refused.

`MAX_CONDITION`

Returns:

Type Description
Transformation

Inputs and outputs exchanged, with the same length scale.

Raises:

Type Description
ValueError

When the rank is short or the conditioning is past the limit.

Source code in src/visualdynamics/core/transformation.py
def inverse(self, *, max_condition: float = MAX_CONDITION
            ) -> Transformation:
    """The pseudo-inverse, outputs back to inputs, `u = T⁺ y`.

    For a rigid-body transformation this is the expansion: the six
    motions of the point carried out to the channels, the rigid
    part of what they measure. It is refused when the outputs are
    more than the matrix's rank (they could not all be told apart)
    or the conditioning is past `max_condition`.

    Parameters
    ----------
    max_condition : float, default `MAX_CONDITION`
        The conditioning past which it is refused.

    Returns
    -------
    Transformation
        Inputs and outputs exchanged, with the same length scale.

    Raises
    ------
    ValueError
        When the rank is short or the conditioning is past the limit.
    """
    inverse = _checked_pinv(self.matrix, self.input_kinds,
                            self.output_kinds, self.length_scale,
                            max_condition, len(self.outputs),
                            'the inverse')
    return Transformation(
        inverse, self.outputs, self.inputs, self.output_kinds,
        self.input_kinds, length_scale=self.length_scale,
        origin=f'inverse of: {self.origin}' if self.origin
        else 'inverse')
apply
apply(data: DataArray, records: Sequence[int] | None = None, *, quantity: str | None = None) -> tuple[DataArray, TransformReport]

Apply the transformation to a record: a time history or a spectrum row by row, a cross-spectral density or specification as the quadratic form.

Every input must be present as a record of one motion quantity in declared units: a translation input as that quantity, a rotation input as its angular one. Records at other DOFs are left out and listed (the report's dropped), and records of another quantity at the inputs left out and counted by quantity (skipped). A specification's bands carry through when every input wears the same band, as transform.to_modal carries them.

Parameters:

Name Type Description Default
data DataArray

A TimeHistory, Spectrum, or a Psd with its cross terms (a Specification stays one).

required
records sequence of int

Which records to take the inputs from; all when omitted.

None
quantity str

The motion to transform ('acceleration', 'velocity' or 'length') when the record holds more than one at the inputs.

None

Returns:

Type Description
tuple of (DataArray, TransformReport)

The outputs, of the source's kind, and the report: the inputs used, the records left out, and each input's residual fraction (of its RMS, or of its power for a density).

Raises:

Type Description
ValueError

When an input is missing, in undefined units, of the wrong quantity or kind, or holds a value that is not a finite number, or a density's cross terms are missing.

TypeError

When the data is an FRF or another kind that does not transform by one matrix.

Source code in src/visualdynamics/core/transformation.py
def apply(self, data: DataArray, records: Sequence[int] | None = None,
          *, quantity: str | None = None
          ) -> tuple[DataArray, TransformReport]:
    """Apply the transformation to a record: a time history or a
    spectrum row by row, a cross-spectral density or specification
    as the quadratic form.

    Every input must be present as a record of one motion quantity
    in declared units: a translation input as that quantity, a
    rotation input as its angular one. Records at other DOFs are
    left out and listed (the report's `dropped`), and records of
    another quantity at the inputs left out and counted by quantity
    (`skipped`). A specification's bands carry through when
    every input wears the same band, as `transform.to_modal`
    carries them.

    Parameters
    ----------
    data : DataArray
        A `TimeHistory`, `Spectrum`, or a `Psd` with its cross terms
        (a `Specification` stays one).
    records : sequence of int, optional
        Which records to take the inputs from; all when omitted.
    quantity : str, optional
        The motion to transform ('acceleration', 'velocity' or
        'length') when the record holds more than one at the inputs.

    Returns
    -------
    tuple of (DataArray, TransformReport)
        The outputs, of the source's kind, and the report: the
        inputs used, the records left out, and each input's residual
        fraction (of its RMS, or of its power for a density).

    Raises
    ------
    ValueError
        When an input is missing, in undefined units, of the wrong
        quantity or kind, or holds a value that is not a finite
        number, or a density's cross terms are missing.
    TypeError
        When the data is an FRF or another kind that does not
        transform by one matrix.
    """
    from .data import Frf, Psd, Spectrum, TimeHistory

    if isinstance(data, Psd):
        return self._apply_density(data, records, quantity)
    if isinstance(data, Frf):
        raise TypeError(
            'an FRF transforms in both halves, its responses by this '
            'matrix and its references by the force rule — use '
            'transform.to_modal through the shape set')
    if not isinstance(data, (TimeHistory, Spectrum)):
        raise TypeError(f'{type(data).__name__} does not transform '
                         'row by row; transform the time history')
    return self._apply_rows(data, records, quantity)
read_residual
read_residual(density: DataArray, records: Sequence[int] | None = None, *, threshold: float = RESIDUAL_THRESHOLD, quantity: str | None = None) -> TransformationResidual

What the outputs leave unexplained of a cross-spectral density at the inputs, line by line (read_residual).

Source code in src/visualdynamics/core/transformation.py
def read_residual(self, density: DataArray,
                  records: Sequence[int] | None = None, *,
                  threshold: float = RESIDUAL_THRESHOLD,
                  quantity: str | None = None) -> TransformationResidual:
    """What the outputs leave unexplained of a cross-spectral
    density at the inputs, line by line (`read_residual`)."""
    return read_residual(self, density, records, threshold=threshold,
                         quantity=quantity)

TransformationResidual dataclass

TransformationResidual(transformation: Transformation, frequency: ndarray, channels: list[str], quantity: str, measured: ndarray, explained: ndarray, residual: ndarray, threshold: float = RESIDUAL_THRESHOLD, notes: list[str] = list())

The measured power at a transformation's inputs split in two at every line: the part the outputs carry back, and the residual.

Attributes:

Name Type Description
transformation Transformation

What was read.

frequency ndarray

The lines, Hz.

channels list of str

The inputs, in the transformation's order.

quantity str

The motion the density is of.

measured, explained, residual ndarray

(channels, lines): each input's autospectrum, the diagonal of Q G Q (what the outputs explain) and the diagonal of P G P (the rest), in SI. Summed over the channels, measured = explained + residual exactly, since P Q = 0; a single channel's two parts also share the cross terms Q G P + P G Q, so they need not add up to its autospectrum, and a quiet channel's residual can pass its own power where the projector carries a louder channel's motion into it.

threshold float

The share of the measured power the residual may reach and the transformation still be said to hold (holds_to).

notes list of str

Anything the numbers should be read with: dead channels, lines with no measured power.

Methods:

Name Description
describe

One line, worded as the status line shows it.

Attributes
dimension property
dimension: str

The dimension of the spectra: the quantity's density.

total property
total: ndarray

The measured power summed over the inputs, at every line.

fraction property
fraction: ndarray

The residual's share of the summed measured power at every line, 0 to 1, not a number where nothing was measured.

overall property
overall: float

The residual's share of the measured power over the whole band, or not a number when nothing was measured.

channel_fractions property
channel_fractions: ndarray

Each input's residual over its own power across the band, not a number for a channel that measured nothing. It can pass one on a quiet channel, where the projector carries a louder channel's motion into it (residual).

worst_channel property
worst_channel: str | None

The input whose own residual share is largest, or None when no channel measured anything.

holds_to property
holds_to: float | None

The highest frequency up to which the residual stays at or under threshold of the measured power at every line, from the first line with power. The last line when it never passes the threshold; None when it is past it at the first line, or nothing was measured.

holds_throughout property
holds_throughout: bool

Whether the residual never passes the threshold.

Methods:
describe
describe() -> str

One line, worded as the status line shows it.

Source code in src/visualdynamics/core/transformation.py
def describe(self) -> str:
    """One line, worded as the status line shows it."""
    if not np.isfinite(self.overall):
        return 'nothing measured at the inputs'
    share = f'residual {self.overall:.1%} of the measured power'
    limit = f'{self.threshold:.0%}'
    if self.holds_throughout:
        return f'{share}; under {limit} at every line'
    holds = self.holds_to
    if holds is None:
        return f'{share}; past {limit} from the first line'
    return f'{share}; under {limit} up to {holds:.4g} Hz'

Functions:

read_residual

read_residual(transformation: Transformation, density: DataArray, records: Sequence[int] | None = None, *, threshold: float = RESIDUAL_THRESHOLD, quantity: str | None = None) -> TransformationResidual

Split the measured power at a transformation's inputs into the part its outputs explain and the residual, at every line.

With Q = T⁺T and P = I − Q, the explained part of the cross-spectral matrix at the inputs is Q G Q and the residual P G P; their traces sum to the measured power because P Q = 0, and each is read on its own diagonal, since channel by channel the cross terms Q G P + P G Q need not vanish. For a rigid-body transformation the residual is the motion that is not rigid, and holds_to says up to what frequency it stays under threshold of the measured power.

Parameters:

Name Type Description Default
transformation Transformation

Its inputs all translations, so their powers can be summed.

required
density DataArray

A Psd with its cross terms at every input.

required
records sequence of int

Which records to read; all when omitted.

None
threshold float

The share of the measured power, above 0 and under 1.

`RESIDUAL_THRESHOLD`
quantity str

As for Transformation.apply.

None

Returns:

Type Description
TransformationResidual

Raises:

Type Description
ValueError

When an input is a rotation, the threshold is not between 0 and 1, the density has no cross terms, or as Transformation.apply refuses.

TypeError

When the density is not a Psd.

Source code in src/visualdynamics/core/transformation.py
def read_residual(transformation: Transformation, density: DataArray,
                  records: Sequence[int] | None = None, *,
                  threshold: float = RESIDUAL_THRESHOLD,
                  quantity: str | None = None) -> TransformationResidual:
    """Split the measured power at a transformation's inputs into the
    part its outputs explain and the residual, at every line.

    With `Q = T⁺T` and `P = I − Q`, the explained part of the
    cross-spectral matrix at the inputs is `Q G Q` and the residual
    `P G P`; their traces sum to the measured power because `P Q = 0`,
    and each is read on its own diagonal, since channel by channel the
    cross terms `Q G P + P G Q` need not vanish. For a rigid-body
    transformation the residual is
    the motion that is not rigid, and `holds_to` says up to what
    frequency it stays under `threshold` of the measured power.

    Parameters
    ----------
    transformation : Transformation
        Its inputs all translations, so their powers can be summed.
    density : DataArray
        A `Psd` with its cross terms at every input.
    records : sequence of int, optional
        Which records to read; all when omitted.
    threshold : float, default `RESIDUAL_THRESHOLD`
        The share of the measured power, above 0 and under 1.
    quantity : str, optional
        As for `Transformation.apply`.

    Returns
    -------
    TransformationResidual

    Raises
    ------
    ValueError
        When an input is a rotation, the threshold is not between 0 and
        1, the density has no cross terms, or as `Transformation.apply`
        refuses.
    TypeError
        When the density is not a `Psd`.
    """
    from .data import Psd

    if not 0.0 < float(threshold) < 1.0:
        raise ValueError('the threshold is a share of the measured power, '
                         'above 0 and under 1')
    if not transformation._translational():
        raise ValueError('the residual is summed over the inputs, so they '
                         'must all be translations of one quantity')
    if not isinstance(density, Psd):
        raise TypeError(f'a residual against frequency is read from a '
                         f'cross-spectral density, not a '
                         f'{type(density).__name__}')
    if np.linalg.matrix_rank(transformation.matrix) >= len(
            transformation.inputs):
        notes = [('the transformation is square and invertible, so it '
                  'explains everything at its inputs and the residual is '
                  'zero by construction')]
    else:
        notes = []
    expansion = transformation.inverse(max_condition=np.inf)
    # through the matrix both ways: the outputs, then back out to the
    # inputs, `Q G Q`; the measured matrix itself from the same records
    outputs, _report = transformation.apply(density, records,
                                            quantity=quantity)
    explained, _back = expansion.apply(outputs)
    identity = Transformation(np.eye(len(transformation.inputs)),
                              transformation.inputs, transformation.inputs,
                              transformation.input_kinds,
                              transformation.input_kinds)
    measured, _same = identity.apply(density, records, quantity=quantity)
    # and `P G P` read on its own: by difference, each channel's
    # residual would carry the cross terms `Q G P + P G Q`, which sum to
    # nothing over the channels but not channel by channel
    projector = Transformation(transformation.residual_projector,
                               transformation.inputs, transformation.inputs,
                               transformation.input_kinds,
                               transformation.input_kinds)
    left, _left = projector.apply(density, records, quantity=quantity)
    base = next(iter(_report.shared))

    def diagonal(psd):
        at = {(str(r), str(c)): i for i, (r, c) in enumerate(
            zip(psd.response_dof, psd.reference_dof))}
        return np.real(np.asarray([psd.ordinate[at[(name, name)]]
                                   for name in transformation.inputs]))

    g = diagonal(measured)
    q = diagonal(explained)
    # each is a power, so a few parts in 1e15 under zero is round-off
    r = np.maximum(diagonal(left), 0.0)
    dead = [name for name, row in zip(transformation.inputs, g)
            if not np.any(row > 0.0)]
    if dead:
        notes.append(f'{", ".join(dead)} measured nothing, so '
                     f'{"they say" if len(dead) > 1 else "it says"} nothing '
                     'about the residual')
    with np.errstate(divide='ignore', invalid='ignore'):
        over = [name for name, mine, power in zip(
            transformation.inputs, np.sum(r, axis=1), np.sum(g, axis=1))
            if power > 0.0 and mine > power]
    if over:
        # a residual larger than the motion measured reads as an error
        # unless the reading says how it arises
        many = len(over) > 1
        notes.append(f'{", ".join(over)} {"have" if many else "has"} a '
                     f'residual larger than {"their" if many else "its"} '
                     'own measured power: the fit the outputs make moves '
                     f'{"them" if many else "it"} against '
                     f'{"their" if many else "its"} own motion, so more is '
                     'left than was measured')
    total = np.sum(g, axis=0)
    empty = int(np.sum(~(total > 0.0)))
    if empty and empty < len(total):
        notes.append(f'{empty} line{"s" * (empty != 1)} with no measured '
                     'power, where the residual is not read')
    return TransformationResidual(
        transformation, np.asarray(density.abscissa, dtype=np.float64),
        list(transformation.inputs), base, g, np.maximum(q, 0.0), r,
        float(threshold), notes)