Skip to content

visualdynamics.deform

deform

Turning data at DOFs into node motion.

Both mode shape animation and time history playback reduce to 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.

Everything expensive is done once, here, so playback is a scalar multiply and a scatter write:

  • direction signs and coordinate-system rotations are baked into a unit vector per DOF
  • only the nodes that actually move are touched per frame
  • DOFs are grouped into passes with no repeated node, so a frame is a handful of vectorized writes rather than a scatter-add

Rotational DOFs are ignored: a rotation does not displace the node it belongs to. Nodes with no data hold still.

Classes:

Name Description
Deflection

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

ShapeDeflection

One mode: offsets sweep with phase, Re(phi * exp(i*theta)).

OdsDeflection

An operating deflection shape read off complex spectra.

EnvelopeDeflection

The envelope a PSD names at one movable line.

TimeDeflection

Records over time: offsets are the samples at one index.

Functions:

Name Description
animation_records

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

envelope_records

Which records an envelope may honestly show, one quantity at a

dof_directions

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

node_displacements

(num_nodes, 3) displacement from one value per DOF.

model_size

Bounding-box diagonal, the yardstick deflections are scaled against.

auto_scale

Scale putting the largest deflection at fraction of the model size.

Classes

Deflection

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

Subclasses expose rows — the node rows that move — and offsets(p), giving those rows' displacement. Everything else holds still, which is what keeps playback independent of model size.

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

Attributes:

Name Type Description
peak_magnitude float

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

Attributes
peak_magnitude property
peak_magnitude: float

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

Not the same as peak, which is the largest value of a single DOF: a node moving in both X and Z travels sqrt(x^2 + z^2), further than either. This is what the top of a colour scale should mean, so that the top colour appears at the one instant the model is at its furthest and nowhere else.

ShapeDeflection

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

Bases: Deflection

One mode: offsets sweep with phase, Re(phi * exp(i*theta)).

Methods:

Name Description
offsets

parameter is the phase in radians.

Attributes:

Name Type Description
peak_magnitude float

Closed form, so no phase sweep is needed.

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_magnitude property
peak_magnitude: float

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

parameter is the phase in radians.

Source code in src/visualdynamics/deform.py
def offsets(self, parameter: float) -> np.ndarray:
    """`parameter` is the phase 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 off complex spectra.

At one abscissa line the records' complex values are a deflection pattern — magnitude and phase per DOF — and the animation sweeps it exactly like a complex mode, Re(H(w_line) * exp(i*theta)). The line is movable: the plot cursor picks which frequency deflects, so moving it must cost one small bake, not a scene rebuild.

Every line deflects at full scale: the pattern is normalized by its own largest record, so an anti-resonance shows its shape as plainly as a peak. Keeping the true relative amplitude was tried first and read as broken — away from resonance the model barely moved, and what a frequency does is exactly what the cursor is there to ask. How much it responds is the plot's own curve, one glance away.

Methods:

Name Description
offsets

parameter is the phase in radians.

Attributes:

Name Type Description
line int

The abscissa index whose pattern is deflecting.

strongest_line int

The line where a record is largest — where a cursor should

peak float

1.0 — the yardstick the animator scales against. Every line

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

strongest_line property
strongest_line: int

The line where a record is largest — where a cursor should start, because a flat-spectrum line 0 deflects as nothing.

peak property
peak: float

1.0 — the yardstick the animator scales against. Every line is normalized to its own strongest record, so the largest DOF value any line ever shows is exactly one.

peak_magnitude property
peak_magnitude: float

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

Per line the closed form is ShapeDeflection's; walking the lines in chunks keeps the working set bounded the way TimeDeflection's sample walk does.

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

parameter is the phase in radians.

Source code in src/visualdynamics/deform.py
def offsets(self, parameter: float) -> np.ndarray:
    """`parameter` is the phase 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 a PSD names at one movable line.

An autospectrum gives each DOF an amplitude — the square root of the PSD value — and no phase and no sign. The honest picture is the envelope: every extreme every DOF reaches, with no claim about when. One of these deflects a copy of the geometry by +pattern; the animator mirrors a second copy to −pattern through the sign of its scale, and the pair is the envelope.

The line moves like OdsDeflection's and each line shows at its own full scale, for the same reason. Colour is the one channel left to carry level, so it is absolute: node_decibels reads each node against the loudest node at any line, floored at FLOOR_DB.

A node measured along two axes deflects to their in-phase diagonal — two copies cannot show the four corners of the true rectangle — which is exact for single-axis surveys and stated in the guide for the rest.

Methods:

Name Description
offsets

The +pattern; parameter is unused — the line is the state,

node_decibels

Each moving node's level, dB below the loudest node at any

Attributes:

Name Type Description
line int

The abscissa index whose envelope is showing.

strongest_line int

Where a record is largest — where the cursor starts.

peak float

1.0 — every line is normalized to its own strongest record.

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

strongest_line property
strongest_line: int

Where a record is largest — where the cursor starts.

peak property
peak: float

1.0 — every line is normalized to its own strongest record.

peak_magnitude property
peak_magnitude: float

The furthest any node gets with each line at its normalized scale — unused when the animator pins an absolute colour range, but every deflection answers it.

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

The +pattern; parameter is unused — the line is the state, and the sign lives in the animator's scale.

Source code in src/visualdynamics/deform.py
def offsets(self, parameter: float) -> np.ndarray:
    """The +pattern; `parameter` is unused — the line is the state,
    and the sign lives in the animator's scale."""
    return self._offsets
node_decibels
node_decibels() -> ndarray

Each moving node's level, dB below the loudest node at any line — the absolute reading the colour carries.

Source code in src/visualdynamics/deform.py
def node_decibels(self) -> np.ndarray:
    """Each moving node's level, dB below the loudest node at any
    line — the absolute reading the colour carries."""
    return self._db

TimeDeflection

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

Bases: Deflection

Records over time: offsets are the samples at one index.

Methods:

Name Description
offsets

parameter is the sample index.

Attributes:

Name Type Description
peak_magnitude float

Walk the samples in chunks, tracking the furthest any node gets.

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_magnitude property
peak_magnitude: float

Walk the samples in chunks, tracking the furthest any node gets.

Chunked rather than all at once: the full (nodes, 3, samples) array would be far larger than the data it came from.

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

parameter is the sample index.

Source code in src/visualdynamics/deform.py
def offsets(self, parameter: float) -> np.ndarray:
    """`parameter` is the sample index."""
    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

Functions:

animation_records

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

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

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

  • A whole object holding repeated captures animates its first one — all twenty averages summed into one waveform is not a measurement anyone took. The same convention as a shape set: the first, with a note saying how to pick another.
  • Within what remains, the first record per DOF wins and the rest are dropped with a note. A drive point carries a load cell beside its accelerometer, and metres per second squared plus newtons is not a deflection. An FRF's reference columns repeat every response DOF the same way, so a whole FRF animates against its first reference. Incompatibility warns; it does not block.
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, and why, as (indices, note).

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

    - A whole object holding repeated captures animates its **first** one —
      all twenty averages summed into one waveform is not a measurement
      anyone took. The same convention as a shape set: the first, with a
      note saying how to pick another.
    - Within what remains, the first record per DOF wins and the rest are
      dropped with a note. A drive point carries a load cell beside its
      accelerometer, and metres per second squared plus newtons is not a
      deflection. An FRF's reference columns repeat every response DOF the
      same way, so a whole FRF animates against its first reference.
      Incompatibility warns; it does not block.
    """
    notes = ''
    if records is None and data.block is not None:
        first = data.block[0]
        indices = [i for i in range(data.num_records)
                   if data.block[i] == first]
        distinct = len(dict.fromkeys(data.block))
        notes += (f' — animating {first} of {distinct} averages; pick a '
                  'grid column for another')
    else:
        indices = list(range(data.num_records) if records is None
                       else 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

envelope_records

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

Which records an envelope may honestly show, one quantity at a time — (indices, quantity, cross records dropped, other-quantity records dropped).

The envelope is the autospectra's reading: a cross row's phase belongs to an operating deflection shape, so cross records go first. Then one quantity — newtons and metres per second squared cannot share a normalization — with the commonest kind answering when quantity is None. Both filters run before the one-record-per-DOF rule in animation_records, or a cross row or a drive point's force PSD listed first would shadow its own DOF's genuine accelerometer auto. That ordering broke twice, which is why the app and the headless call now share this one 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 an envelope may honestly show, one quantity at a
    time — (indices, quantity, cross records dropped, other-quantity
    records dropped).

    The envelope is the autospectra's reading: a cross row's phase
    belongs to an operating deflection shape, so cross records go
    first. Then one quantity — newtons and metres per second squared
    cannot share a normalization — with the commonest kind answering
    when `quantity` is None. Both filters run **before** the
    one-record-per-DOF rule in `animation_records`, or a cross row or
    a drive point's force PSD listed first would shadow its own DOF's
    genuine accelerometer auto. That ordering broke twice, which is
    why the app and the headless call now share this one function.
    """
    from .core.report import base_quantity

    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]])
    kinds = [base_quantity(data.known_dim(i)) for i in kept]
    if quantity is None and kinds:
        quantity = max(set(kinds), key=kinds.count)
    of_kind = [i for i, kind in zip(kept, kinds) if kind == quantity]
    return (of_kind, quantity,
            len(initial) - len(kept), len(kept) - len(of_kind))

dof_directions

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

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

rows indexes into the geometry's nodes, directions is the global unit vector each DOF displaces along, and used masks which of the given DOFs are usable — rotational, unknown, or absent-node DOFs 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 DOFs this geometry can move.

    `rows` indexes into the geometry's nodes, `directions` is the global
    unit vector each DOF displaces along, and `used` masks which of the
    given DOFs are usable — rotational, unknown, or absent-node DOFs 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

(num_nodes, 3) displacement from one value per DOF.

Values may be complex; the result matches. Contributions to the same node accumulate.

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

    Values may be complex; the result matches. Contributions to the same
    node accumulate.
    """
    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

Bounding-box diagonal, the yardstick deflections are scaled against.

Source code in src/visualdynamics/deform.py
def model_size(geometry: Geometry) -> float:
    """Bounding-box diagonal, the yardstick deflections are scaled against."""
    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

Scale putting the largest deflection at fraction of the model size.

Source code in src/visualdynamics/deform.py
def auto_scale(geometry: Geometry, peak: float,
               fraction: float = 0.1) -> float:
    """Scale putting the largest deflection at `fraction` of the model size."""
    peak = float(abs(peak))
    if not peak or not np.isfinite(peak):
        return 1.0
    return fraction * model_size(geometry) / peak