Skip to content

visualdynamics.deform

deform

Turning values at DOFs into node motion.

Mode shape animation, operating deflection shapes, PSD envelopes and time history playback all answer the same question: given values at DOFs, where does each node go? A DOF is a node plus a signed direction, so a value displaces its node along that direction, rotated out of the node's displacement coordinate system into global coordinates.

The expensive work is done once, when a deflection is built, so drawing a frame is a multiply and a write:

  • direction signs and coordinate system rotations are combined into one unit vector per DOF
  • only the nodes that move are written each frame
  • for time histories, DOFs are grouped into passes with no repeated node, so a frame is a few vectorized adds instead of a scatter-add

Rotational DOFs are ignored, since a rotation does not displace the node it belongs to. Nodes with no data do not move.

Classes:

Name Description
Deflection

Node offsets as a function of one parameter (a phase, or a sample

ShapeDeflection

One mode shape, animated through phase: the offsets at phase

OdsDeflection

An operating deflection shape read from complex spectra.

EnvelopeDeflection

The envelope of a PSD at one line, which can be moved.

TimeDeflection

Time histories played back: the offsets are the samples at one

ModalTimeDeflection

Modal coordinates played back through their shapes: the offsets

Functions:

Name Description
animation_records

Which records a deflection animation shows, as (indices, note).

first_capture

The records an animation starts from, as (indices, note): the

quantity_records

The records of one quantity, as (indices, quantity, other-quantity

envelope_records

Which records a PSD envelope shows, as (indices, quantity, cross

dof_directions

(rows, directions, used) for the DOFs this geometry can move.

node_displacements

The (num_nodes, 3) displacement of every node from one value per

model_size

The length of the geometry's bounding-box diagonal, which

auto_scale

The scale that draws a deflection of peak at fraction of the

modal_deflection

The modal records among indices played through shapes on

Classes

Deflection

Node offsets as a function of one parameter (a phase, or a sample index).

Subclasses have rows, the geometry rows of the nodes that move, and offsets(parameter), those rows' (len(rows), 3) displacement. No other node moves, so the cost of a frame depends on the nodes with data and not on the size of the model.

offsets returns a buffer it reuses on every call, so a frame allocates nothing. Use the result before calling again, or copy it.

Methods:

Name Description
offsets

The (len(rows), 3) displacement of the moving nodes at

Attributes:

Name Type Description
peak float

The largest single displacement component over the whole

peak_magnitude float

The largest distance any node travels, over the whole animation.

Attributes
peak property
peak: float

The largest single displacement component over the whole animation, which the animator scales against.

peak_magnitude property
peak_magnitude: float

The largest distance any node travels, over the whole animation.

This differs from peak, the largest value of a single DOF: a node moving in both X and Z travels sqrt(x^2 + z^2), further than either. It is the top of the color scale, so the top color appears only at the instant the model is at its furthest.

Methods:
offsets
offsets(parameter: float) -> ndarray

The (len(rows), 3) displacement of the moving nodes at parameter.

Source code in src/visualdynamics/deform.py
def offsets(self, parameter: float) -> np.ndarray:
    """The (len(rows), 3) displacement of the moving nodes at
    `parameter`."""
    raise NotImplementedError

ShapeDeflection

ShapeDeflection(geometry: Geometry, dofs: Sequence[str], shape: ArrayLike)

Bases: Deflection

One mode shape, animated through phase: the offsets at phase theta are Re(phi * exp(i*theta)).

Methods:

Name Description
offsets

The offsets at phase parameter, in radians.

Attributes:

Name Type Description
peak float

The largest magnitude of any node's shape coefficient along a

peak_magnitude float

The largest distance any node travels over a cycle, in closed

Source code in src/visualdynamics/deform.py
def __init__(self, geometry: Geometry, dofs: Sequence[str],
             shape: ArrayLike) -> None:
    rows, directions, used = dof_directions(geometry, dofs)
    values = np.asarray(shape)[used]
    contributions = directions * values[:, np.newaxis]
    # a node with X, Y and Z DOFs appears three times; sum them once here
    self.rows, targets = _unique_rows(rows)
    total = np.zeros((len(self.rows), 3), dtype=contributions.dtype)
    if len(rows):
        np.add.at(total, targets, contributions)
    self._real = np.ascontiguousarray(total.real)
    self._imag = np.ascontiguousarray(
        total.imag if np.iscomplexobj(total) else np.zeros_like(total.real))
    self._complex = bool(np.any(self._imag))
    self._buffer = np.empty_like(self._real)
Attributes
peak property
peak: float

The largest magnitude of any node's shape coefficient along a global axis.

peak_magnitude property
peak_magnitude: float

The largest distance any node travels over a cycle, in closed form, so no phase sweep is needed.

With offsets rcos(t) - msin(t), the squared distance a node travels is (A+B)/2 + (A-B)/2cos(2t) - (r.m)sin(2t), where A=r.r and B=m.m. Its largest value over t is (A+B)/2 + hypot((A-B)/2, r.m).

Methods:
offsets
offsets(parameter: float) -> ndarray

The offsets at phase parameter, in radians.

Source code in src/visualdynamics/deform.py
def offsets(self, parameter: float) -> np.ndarray:
    """The offsets at phase `parameter`, in radians."""
    np.multiply(self._real, np.cos(parameter), out=self._buffer)
    if self._complex:
        self._buffer -= self._imag * np.sin(parameter)
    return self._buffer

OdsDeflection

OdsDeflection(geometry: Geometry, dofs: Sequence[str], ordinate: ArrayLike)

Bases: Deflection

An operating deflection shape read from complex spectra.

At one abscissa line the records' complex values are a deflection pattern, a magnitude and phase per DOF, and the animation sweeps it the same way as a complex mode: Re(H(w_line) * exp(i*theta)). The plot cursor sets line, the frequency shown, and moving it recomputes only the pattern at that line, not the scene.

Every line is drawn at full scale: the pattern is normalized by its own largest record, so an anti-resonance shows its shape as clearly as a peak. How large the response is at that line is shown by the plot's curve. The pattern's overall phase is also rotated so that phase zero is its fullest deflection, since a steady state has no time origin.

Methods:

Name Description
offsets

The offsets at phase parameter, in radians.

Attributes:

Name Type Description
line int

The abscissa index whose pattern is shown. Setting it clips

strongest_line int

The line where a record is largest, where the cursor

peak float

1.0, the value the animator scales against, or 0.0 when there

peak_magnitude float

The furthest any node gets, over every line and phase, with

Source code in src/visualdynamics/deform.py
def __init__(self, geometry: Geometry, dofs: Sequence[str],
             ordinate: ArrayLike) -> None:
    # Keeping the true relative amplitude was tried first and looked
    # broken: away from resonance the model barely moved, and what a
    # frequency does is what the cursor is there to ask.
    rows, directions, used = dof_directions(geometry, dofs)
    self._ordinate = np.ascontiguousarray(
        np.asarray(ordinate, dtype=np.complex128)[used])
    self._directions = directions
    self.rows, self._targets = _unique_rows(rows)
    self._real = np.zeros((len(self.rows), 3))
    self._imag = np.zeros((len(self.rows), 3))
    self._buffer = np.empty_like(self._real)
    self.num_lines: int = self._ordinate.shape[1] if len(rows) else 0
    self._line = 0
    if self.num_lines:
        self._bake()
Attributes
line property writable
line: int

The abscissa index whose pattern is shown. Setting it clips to the valid range.

strongest_line property
strongest_line: int

The line where a record is largest, where the cursor starts, since line 0 of a spectrum is often nearly zero.

peak property
peak: float

1.0, the value the animator scales against, or 0.0 when there is no data. Every line is normalized to its own largest record, so the largest DOF value any line shows is 1.

peak_magnitude property
peak_magnitude: float

The furthest any node gets, over every line and phase, with each line at its normalized scale.

Each line uses ShapeDeflection's closed form. The lines are processed in chunks to limit memory, as TimeDeflection does with its samples.

Methods:
offsets
offsets(parameter: float) -> ndarray

The offsets at phase parameter, in radians.

Source code in src/visualdynamics/deform.py
def offsets(self, parameter: float) -> np.ndarray:
    """The offsets at phase `parameter`, in radians."""
    np.multiply(self._real, np.cos(parameter), out=self._buffer)
    self._buffer -= self._imag * np.sin(parameter)
    return self._buffer

EnvelopeDeflection

EnvelopeDeflection(geometry: Geometry, dofs: Sequence[str], ordinate: ArrayLike)

Bases: Deflection

The envelope of a PSD at one line, which can be moved.

An autospectrum gives each DOF an amplitude, the square root of the PSD value, and no phase or sign. So what is drawn is the envelope: the extreme each DOF reaches, with no claim about when. One of these deflects a copy of the geometry by +pattern; the animator draws a second copy at -pattern by giving it a negative scale, and the pair is the envelope.

line moves as OdsDeflection's does, and each line is drawn at its own full scale for the same reason. The level is shown by color, on an absolute scale: node_decibels gives each node's level against the loudest node at any line, with FLOOR_DB as the bottom.

A node measured along two axes is drawn at the in-phase diagonal of the two, because two copies of the geometry cannot show the four corners of the true rectangle. That is exact for single-axis surveys, and the plotting guide says so for the rest.

Methods:

Name Description
offsets

The +pattern at the current line. parameter is not used:

node_decibels

Each moving node's level at the current line, in dB relative

Attributes:

Name Type Description
line int

The abscissa index whose envelope is shown. Setting it clips

strongest_line int

The line where a record is largest, where the cursor starts.

peak float

1.0, since every line is normalized to its own largest

peak_magnitude float

The furthest any node gets, with each line at its normalized

Source code in src/visualdynamics/deform.py
def __init__(self, geometry: Geometry, dofs: Sequence[str],
             ordinate: ArrayLike) -> None:
    rows, directions, used = dof_directions(geometry, dofs)
    # amplitude, not power: a 6 dB drop should halve the picture.
    # The clip guards measurement noise — a PSD is non-negative in
    # principle and float error can dip a tail line under zero.
    self._amplitude = np.sqrt(np.clip(
        np.asarray(ordinate, dtype=np.float64).real[used], 0.0, None))
    self._directions = directions
    self.rows, self._targets = _unique_rows(rows)
    self._offsets = np.zeros((len(self.rows), 3))
    self._db = np.zeros(len(self.rows))
    self.num_lines: int = self._amplitude.shape[1] if len(rows) else 0
    self._line = 0
    self._loudest = self._loudest_norm()
    if self.num_lines:
        self._bake()
Attributes
line property writable
line: int

The abscissa index whose envelope is shown. Setting it clips to the valid range.

strongest_line property
strongest_line: int

The line where a record is largest, where the cursor starts.

peak property
peak: float

1.0, since every line is normalized to its own largest record, or 0.0 when there is no data.

peak_magnitude property
peak_magnitude: float

The furthest any node gets, with each line at its normalized scale. The animator does not use it when it fixes the color range in dB, but every deflection provides it.

Methods:
offsets
offsets(parameter: float) -> ndarray

The +pattern at the current line. parameter is not used: the line sets the pattern, and the sign is the animator's scale.

Source code in src/visualdynamics/deform.py
def offsets(self, parameter: float) -> np.ndarray:
    """The +pattern at the current line. `parameter` is not used:
    the line sets the pattern, and the sign is the animator's
    scale."""
    return self._offsets
node_decibels
node_decibels() -> ndarray

Each moving node's level at the current line, in dB relative to the loudest node at any line (0 dB), no lower than FLOOR_DB. The color scale shows these values.

Source code in src/visualdynamics/deform.py
def node_decibels(self) -> np.ndarray:
    """Each moving node's level at the current line, in dB relative
    to the loudest node at any line (0 dB), no lower than
    `FLOOR_DB`. The color scale shows these values."""
    return self._db

TimeDeflection

TimeDeflection(geometry: Geometry, dofs: Sequence[str], ordinate: ArrayLike)

Bases: Deflection

Time histories played back: the offsets are the samples at one index.

Methods:

Name Description
offsets

The offsets at sample index parameter.

Attributes:

Name Type Description
peak float

The largest magnitude of any sample of any record used.

peak_magnitude float

The furthest any node gets over all samples.

Source code in src/visualdynamics/deform.py
def __init__(self, geometry: Geometry, dofs: Sequence[str],
             ordinate: ArrayLike) -> None:
    rows, directions, used = dof_directions(geometry, dofs)
    self._ordinate = np.ascontiguousarray(np.asarray(ordinate)[used].real)
    self.rows, targets = _unique_rows(rows)
    # groups with no repeated target, so a frame is a few vector adds
    self._passes = []
    for group in _passes(targets):
        self._passes.append((targets[group], directions[group], group,
                             np.empty(len(group)),
                             np.empty((len(group), 3))))
    self._buffer = np.zeros((len(self.rows), 3))
    self.num_frames: int = self._ordinate.shape[1] if len(rows) else 0
Attributes
peak property
peak: float

The largest magnitude of any sample of any record used.

peak_magnitude property
peak_magnitude: float

The furthest any node gets over all samples.

The samples are processed in chunks, because the full (nodes, samples, 3) array would be far larger than the data it came from.

Methods:
offsets
offsets(parameter: float) -> ndarray

The offsets at sample index parameter.

Source code in src/visualdynamics/deform.py
def offsets(self, parameter: float) -> np.ndarray:
    """The offsets at sample index `parameter`."""
    column = self._ordinate[:, int(parameter)]
    self._buffer[:] = 0.0
    for targets, directions, group, values, scratch in self._passes:
        np.take(column, group, out=values)
        np.multiply(directions, values[:, np.newaxis], out=scratch)
        self._buffer[targets] += scratch
    return self._buffer

ModalTimeDeflection

ModalTimeDeflection(geometry: Geometry, dofs: Sequence[str], shapes: ArrayLike, ordinate: ArrayLike)

Bases: Deflection

Modal coordinates played back through their shapes: the offsets at sample k are the sum over modes of q_r[k] times mode r's node displacements.

The physical response u = Φq is never formed. Each mode's node displacements (fields, one row per mode) are built once, and a frame is one vector-matrix product of the modes' values at that sample with them: modes × moving nodes × 3 multiply-adds, and no memory beyond the shapes themselves. For a model of a million DOFs and fifty modes a frame takes about 7 ms, where expanding a record of ten thousand samples first would take eighty gigabytes.

dofs are the set's DOFs, shapes its real shape values at them, one row per mode used, and ordinate the modal coordinates, one row per mode in the same order.

Methods:

Name Description
offsets

The offsets at sample index parameter.

Attributes:

Name Type Description
peak float

The largest single displacement component over the samples

peak_magnitude float

The furthest any node gets over the samples read

Source code in src/visualdynamics/deform.py
def __init__(self, geometry: Geometry, dofs: Sequence[str],
             shapes: ArrayLike, ordinate: ArrayLike) -> None:
    """`dofs` are the set's DOFs, `shapes` its real shape values at
    them, one row per mode used, and `ordinate` the modal
    coordinates, one row per mode in the same order."""
    rows, directions, used = dof_directions(geometry, dofs)
    phi = np.asarray(np.real(np.asarray(shapes))[:, used], dtype=float)
    self.rows, targets = _unique_rows(rows)
    modes = phi.shape[0]
    fields = np.zeros((modes, len(self.rows), 3))
    # the no-repeated-node passes again, so building is a few
    # vectorized adds over every mode at once and not a scatter
    for group in _passes(targets):
        fields[:, targets[group], :] += (phi[:, group, np.newaxis]
                                         * directions[group])
    self._fields = fields.reshape(modes, -1)
    # sample-major, so one sample's modal values are contiguous
    self._samples = np.ascontiguousarray(np.asarray(ordinate).real.T)
    self._buffer = np.zeros((len(self.rows), 3))
    self.num_frames: int = (self._samples.shape[0]
                            if len(self.rows) else 0)
Attributes
peak property
peak: float

The largest single displacement component over the samples read (_frames).

peak_magnitude property
peak_magnitude: float

The furthest any node gets over the samples read (_frames).

Methods:
offsets
offsets(parameter: float) -> ndarray

The offsets at sample index parameter.

Source code in src/visualdynamics/deform.py
def offsets(self, parameter: float) -> np.ndarray:
    """The offsets at sample index `parameter`."""
    np.dot(self._samples[int(parameter)], self._fields,
           out=self._buffer.reshape(-1))
    return self._buffer

Functions:

animation_records

animation_records(data: DataArray, records: Sequence[int] | None) -> tuple[list[int], str]

Which records a deflection animation shows, as (indices, note).

An animation gives every node one trajectory, so it can use at most one record per DOF. Two rules follow:

  • When records is None and the object holds repeated captures (its block labels), only the first capture is animated, since twenty averages summed into one waveform is not a measurement anyone took. This is the same convention as a shape set: the first, with a note saying how to pick another.
  • Of what remains, the first record for each response DOF is kept and the rest are dropped. A drive point has a load cell beside its accelerometer, and meters per second squared plus newtons is not a deflection. An FRF repeats every response DOF once per reference in the same way, so a whole FRF is animated against its first reference.

The note is empty when nothing was dropped, and otherwise is text to append to a status line, saying what was left out. Dropping records is reported, never refused.

Source code in src/visualdynamics/deform.py
def animation_records(data: DataArray, records: Sequence[int] | None
                      ) -> tuple[list[int], str]:
    """Which records a deflection animation shows, as (indices, note).

    An animation gives every node one trajectory, so it can use at most
    one record per DOF. Two rules follow:

    - When `records` is None and the object holds repeated captures (its
      `block` labels), only the first capture is animated, since twenty
      averages summed into one waveform is not a measurement anyone
      took. This is the same convention as a shape set: the first, with a
      note saying how to pick another.
    - Of what remains, the first record for each response DOF is kept and
      the rest are dropped. A drive point has a load cell beside its
      accelerometer, and meters per second squared plus newtons is not a
      deflection. An FRF repeats every response DOF once per reference
      in the same way, so a whole FRF is animated against its first
      reference.

    The note is empty when nothing was dropped, and otherwise is text to
    append to a status line, saying what was left out. Dropping records
    is reported, never refused.
    """
    indices, notes = first_capture(data, records)
    seen, kept, collided = set(), [], 0
    for i in indices:
        if data.response_dof[i] in seen:
            collided += 1
        else:
            seen.add(data.response_dof[i])
            kept.append(i)
    if collided:
        notes += (f' — {collided} records repeat an animated DOF and are '
                  'not shown')
    return kept, notes

first_capture

first_capture(data: DataArray, records: Sequence[int] | None) -> tuple[list[int], str]

The records an animation starts from, as (indices, note): the first capture of a whole object that holds repeated captures (its block labels), with a note saying how to pick another, and otherwise records (every record for None) with no note. The first step of animation_records, and run on its own before quantity_records, which needs the capture chosen first.

Source code in src/visualdynamics/deform.py
def first_capture(data: DataArray, records: Sequence[int] | None
                  ) -> tuple[list[int], str]:
    """The records an animation starts from, as (indices, note): the
    first capture of a whole object that holds repeated captures (its
    `block` labels), with a note saying how to pick another, and
    otherwise `records` (every record for None) with no note. The first
    step of `animation_records`, and run on its own before
    `quantity_records`, which needs the capture chosen first."""
    if records is None and data.block is not None:
        first = data.block[0]
        distinct = len(dict.fromkeys(data.block))
        note = (f' — animating {first} of {distinct} averages; pick a '
                'grid column for another')
        return ([i for i in range(data.num_records)
                 if data.block[i] == first], note)
    return (list(range(data.num_records) if records is None
                 else records), '')

quantity_records

quantity_records(data: DataArray, records: Sequence[int] | None = None, quantity: str | None = None) -> tuple[list[int], str | None, int]

The records of one quantity, as (indices, quantity, other-quantity records dropped).

A deflection shows one quantity at a time: newtons and meters per second squared do not share a scale, and an animation that moved one node by an acceleration and the next by a force would show neither. A record's quantity is its response quantity (core.report.base_quantity), so an FRF of acceleration per force is acceleration. When quantity is None it is the most common one among records, the first of them on a tie; a quantity no record measures gives no indices. The order of records is kept, so animation_records still keeps the first record of each DOF.

Source code in src/visualdynamics/deform.py
def quantity_records(data: DataArray, records: Sequence[int] | None = None,
                     quantity: str | None = None
                     ) -> tuple[list[int], str | None, int]:
    """The records of one quantity, as (indices, quantity, other-quantity
    records dropped).

    A deflection shows one quantity at a time: newtons and meters per
    second squared do not share a scale, and an animation that moved
    one node by an acceleration and the next by a force would show
    neither. A record's quantity is its response quantity
    (`core.report.base_quantity`), so an FRF of acceleration per force
    is acceleration. When `quantity` is None it is the most common one
    among `records`, the first of them on a tie; a quantity no record measures gives no indices.
    The order of `records` is kept, so `animation_records` still keeps
    the first record of each DOF.
    """
    from .core.report import base_quantity

    initial = (list(records) if records is not None
               else list(range(data.num_records)))
    kinds = [base_quantity(data.known_dim(i)) for i in initial]
    if quantity is None and kinds:
        # over the list, not a set: a tie goes to the first record's
        # quantity, where a set's order changed from run to run
        quantity = max(kinds, key=kinds.count)
    of_kind = [i for i, kind in zip(initial, kinds) if kind == quantity]
    return of_kind, quantity, len(initial) - len(of_kind)

envelope_records

envelope_records(data: DataArray, records: Sequence[int] | None = None, quantity: str | None = None) -> tuple[list[int], str | None, int, int]

Which records a PSD envelope shows, as (indices, quantity, cross records dropped, other-quantity records dropped).

An envelope is drawn from the autospectra: a cross record's phase belongs to an operating deflection shape, so cross records are dropped first. Then only one quantity is kept, since newtons and meters per second squared cannot share a normalization; when quantity is None it is the most common one among the remaining records. Both filters run before the one-record-per-DOF rule in animation_records, or a cross record or a drive point's force PSD listed first would take the place of that DOF's accelerometer autospectrum. The app and the headless call both use this function.

Source code in src/visualdynamics/deform.py
def envelope_records(data: DataArray, records: Sequence[int] | None = None,
                     quantity: str | None = None
                     ) -> tuple[list[int], str | None, int, int]:
    """Which records a PSD envelope shows, as (indices, quantity, cross
    records dropped, other-quantity records dropped).

    An envelope is drawn from the autospectra: a cross record's phase
    belongs to an operating deflection shape, so cross records are
    dropped first. Then only one quantity is kept, since newtons and
    meters per second squared cannot share a normalization; when
    `quantity` is None it is the most common one among the remaining
    records. Both filters run before the one-record-per-DOF rule in
    `animation_records`, or a cross record or a drive point's force PSD
    listed first would take the place of that DOF's accelerometer
    autospectrum. The app and the headless call both use this function.
    """
    # The filter order broke twice before the app and the headless call
    # shared this one function.
    initial = (list(records) if records is not None
               else list(range(data.num_records)))
    # a PSD computed here has no reference list at all — every record
    # is an auto
    kept = (initial if data.reference_dof is None
            else [i for i in initial
                  if not data.reference_dof[i]
                  or data.reference_dof[i] == data.response_dof[i]])
    of_kind, quantity, left_out = quantity_records(data, kept, quantity)
    return of_kind, quantity, len(initial) - len(kept), left_out

dof_directions

dof_directions(geometry: Geometry, dofs: Sequence[str]) -> tuple[ndarray, ndarray, ndarray]

(rows, directions, used) for the DOFs this geometry can move.

rows indexes into the geometry's nodes, directions is the global unit vector each DOF displaces along, and used is a mask over dofs of the ones kept. Rotational DOFs, DOFs that do not parse, and DOFs on nodes the geometry does not have are dropped.

Source code in src/visualdynamics/deform.py
def dof_directions(geometry: Geometry, dofs: Sequence[str]
                   ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
    """(rows, directions, used) for the DOFs this geometry can move.

    `rows` indexes into the geometry's nodes, `directions` is the global
    unit vector each DOF displaces along, and `used` is a mask over
    `dofs` of the ones kept. Rotational DOFs, DOFs that do not parse,
    and DOFs on nodes the geometry does not have are dropped.
    """
    rows, directions, used = [], [], np.zeros(len(dofs), dtype=bool)
    known = {int(node): row for row, node in enumerate(geometry.node_id)}
    cs_rotation = _cs_rotations(geometry)

    for i, dof in enumerate(dofs):
        node, direction = parse_dof(dof)
        if node is None or not direction:
            continue
        axis = AXES.get(direction[0].upper())
        if axis is None:          # rotational (RX/RY/RZ) or unrecognized
            continue
        row = known.get(int(node))
        if row is None:           # the geometry does not define this node
            continue
        vector = np.zeros(3)
        vector[axis] = -1.0 if direction.endswith('-') else 1.0
        rotation = cs_rotation.get(int(geometry.node_disp_cs[row]))
        if rotation is not None:
            vector = vector @ rotation
        rows.append(row)
        directions.append(vector)
        used[i] = True

    if not rows:
        return (np.empty(0, dtype=np.int64), np.empty((0, 3)), used)
    return np.asarray(rows, dtype=np.int64), np.asarray(directions), used

node_displacements

node_displacements(geometry: Geometry, dofs: Sequence[str], values: ArrayLike) -> ndarray

The (num_nodes, 3) displacement of every node from one value per DOF.

Values may be complex, and the result is then complex too. DOFs on the same node add, and nodes with no usable DOF are zero.

Source code in src/visualdynamics/deform.py
def node_displacements(geometry: Geometry, dofs: Sequence[str],
                       values: ArrayLike) -> np.ndarray:
    """The (num_nodes, 3) displacement of every node from one value per
    DOF.

    Values may be complex, and the result is then complex too. DOFs on
    the same node add, and nodes with no usable DOF are zero.
    """
    rows, directions, used = dof_directions(geometry, dofs)
    values = np.asarray(values)[used]
    result = np.zeros((geometry.num_nodes, 3),
                      dtype=np.result_type(values.dtype, np.float64))
    if len(rows):
        np.add.at(result, rows, directions * values[:, np.newaxis])
    return result

model_size

model_size(geometry: Geometry) -> float

The length of the geometry's bounding-box diagonal, which deflections are scaled against; 1.0 for a geometry with no extent.

Source code in src/visualdynamics/deform.py
def model_size(geometry: Geometry) -> float:
    """The length of the geometry's bounding-box diagonal, which
    deflections are scaled against; 1.0 for a geometry with no extent."""
    low, high = geometry.extent
    diagonal = float(np.linalg.norm(np.asarray(high) - np.asarray(low)))
    return diagonal or 1.0

auto_scale

auto_scale(geometry: Geometry, peak: float, fraction: float = 0.1) -> float

The scale that draws a deflection of peak at fraction of the model size (model_size); 1.0 when peak is zero or not finite.

Source code in src/visualdynamics/deform.py
def auto_scale(geometry: Geometry, peak: float,
               fraction: float = 0.1) -> float:
    """The scale that draws a deflection of `peak` at `fraction` of the
    model size (`model_size`); 1.0 when `peak` is zero or not finite."""
    peak = float(abs(peak))
    if not peak or not np.isfinite(peak):
        return 1.0
    return fraction * model_size(geometry) / peak

modal_deflection

modal_deflection(geometry: Geometry, shapes: ShapeSet, data: DataArray, indices: Sequence[int]) -> ModalTimeDeflection

The modal records among indices played through shapes on geometry: each record at a modal coordinate Mk of the set moves the geometry by mode k's shape, scaled by its value at each sample.

The records at other DOFs (a force beside the modal responses computed from it) are not part of it, and a coordinate past the set's last mode is not one of its modes. A complex set is refused: its shapes are not real motion, and taking their real part would show something no record says.

Source code in src/visualdynamics/deform.py
def modal_deflection(geometry: Geometry, shapes: ShapeSet,
                     data: DataArray, indices: Sequence[int]
                     ) -> ModalTimeDeflection:
    """The modal records among `indices` played through `shapes` on
    `geometry`: each record at a modal coordinate `Mk` of the set moves
    the geometry by mode k's shape, scaled by its value at each sample.

    The records at other DOFs (a force beside the modal responses
    computed from it) are not part of it, and a coordinate past the
    set's last mode is not one of its modes. A complex set is refused:
    its shapes are not real motion, and taking their real part would
    show something no record says.
    """
    from .core.validate import modal_coordinate

    if shapes.is_complex:
        raise ValueError('a complex shape set cannot play modal '
                         'coordinates as motion')
    modes, kept = [], []
    for i in indices:
        k = modal_coordinate(data.response_dof[i])
        if k is not None and k <= shapes.num_shapes:
            modes.append(k - 1)
            kept.append(i)
    if not kept:
        raise ValueError('no record is a modal coordinate of this set '
                         f'(M1 … M{shapes.num_shapes})')
    return ModalTimeDeflection(
        geometry, shapes.coordinate, np.asarray(shapes.shape_matrix)[modes],
        np.asarray(data.ordinate)[kept])