Skip to content

visualdynamics.core.signals

signals

Generated reference signals: random, burst random, periodic random, an impulse or classical pulse, sine, and a shock synthesized to a shock response spectrum.

Each generator returns a TimeHistory at the reference DOFs, in the quantity a specification is the density of (a force, a voltage, an acceleration), in SI. They are the inputs a response is later computed from, and the drives a test plan is tried with before the test.

Random signals are shaped to a power spectral density. A frame of N samples is built in the frequency domain: at line k, a complex amplitude of magnitude N·sqrt(G_k·df/2) with a random phase, so that the frame's one-sided PSD is G_k and, by the central limit theorem over many lines, its samples are Gaussian. DC and the Nyquist line are left at zero. A stationary record is made of independent frames, each windowed by a half-cycle sine and overlapped by half a frame: the squares of two overlapping sine windows sum to one at every sample, so the variance is the same throughout, there is no seam, and the record does not repeat. The window smooths the spectrum over about three lines, so a target is drawn as it would be read with a frame of the same length.

Several DOFs are drawn from a cross-spectral density matrix S at each line. With S factored as F Fᴴ (its eigenvectors scaled by the square root of their eigenvalues, so a matrix of lower rank is drawn too), the amplitudes F·z for independent unit-magnitude random phases z have the cross spectrum F Fᴴ. This package stores a CPSD record (a, b) as conj(X_a)·X_b (TimeHistory.compute_cpsds), so the matrix factored is the conjugate of the one stored. Uncorrelated DOFs are the diagonal matrix, the same rule.

Each record carries the averaging it is meant to be read with, so the analysis settings opened on it match it: random in frames of the length it was drawn at, under a Hann window, overlapped by half; burst random in one rectangular frame per burst, and periodic random in one per period, so each is one leakage-free average; an impulse or a pulse as the whole record in one rectangular frame. A sine has none of its own.

An impulse is a half-sine of a chosen width, the force a hammer tip makes: a pulse with a finite width has a finite bandwidth, where a single sample is flat to the Nyquist frequency whatever the sample rate. Its spectrum, |F(f)| = (2PT/π)·|cos(πfT)/(1 - 4f²T²)| for a peak P and a width T, is flat at low frequency and falls 3 dB by 0.59/T, 10 dB by 1.02/T, and to its first zero at 1.5/T (impulse_rolloff), so the width is chosen for the band the impulse should excite.

A shock specification gives a shock synthesized to its spectrum, a sum of wavelets a shaker can play, checked against the shaker's shock ratings (core.shock_synthesis).

A specification is read between its breakpoints as it is everywhere in the package (author.read_at_lines: the magnitude log-log, the phase linearly), or per bin when it holds a controller's lines, and is zero outside the band it is written on.

References

.. [1] J. S. Bendat and A. G. Piersol, Random Data: Analysis and Measurement Procedures, 4th ed., Wiley, 2010, ch. 5 and 11. .. [2] D. O. Smallwood and T. L. Paez, "A frequency domain method for the generation of partially coherent normal stationary time domain signals", Shock and Vibration, vol. 1, no. 1, pp. 45–53, 1993.

Classes:

Name Description
SignalSettings

Everything a generated signal is made from, besides its source.

Functions:

Name Description
random_history

Stationary Gaussian random at the reference DOFs, shaped to a PSD

burst_random_history

Burst random: bursts frames, each random for the first burst

periodic_random_history

Periodic random: one random frame repeated, so that a frame of

impulse_history

An impulse, or a classical pulse, at the same instant at every DOF.

impulse_rolloff

Where a half-sine impulse width seconds wide rolls off: the

impulse_spectrum

The magnitude of a record's Fourier transform, |X(f)| = |Σ x_n

sine_history

A sine specification's tone as the signal it describes, at each of

flat_spectrum

A flat target over a band at every DOF, uncorrelated, for a

kinds_for

The kinds of signal source can make, in KINDS order: random

reference_unit

The unit a typed level and peak are read in for a channel

generate

The signal settings describe, made from source.

target_of

The spectrum a random signal is drawn to, for drawing beside its

preview

The signal settings describe, cut short to PREVIEW_SAMPLES so

frame_used

The frame length the random kinds are drawn with: the setting's,

suggested

Settings to open a pane with: the first kind the source makes,

target_at

A spectrum's autospectra read at lines the way the random kinds

Classes

SignalSettings dataclass

SignalSettings(kind: str = 'random', duration: float = 10.0, sample_rate: float = 8192.0, frame_length: int | None = None, seed: int | None = None, correlated: bool = True, dofs: tuple[str, ...] | None = None, bursts: int = 10, burst: float = 0.5, taper: float = BURST_TAPER, periods: int = 10, repeats: int | None = None, at: float = 0.0, width: float = 0.001, shape: str = 'half-sine', peak: float = 1.0, pulse_duration: float = 0.011, edge: float | None = None, band: tuple[float, float] = (20.0, 2000.0), level: float = 1.0, tone: str | None = None, half_cycles: int = HALF_CYCLES, stroke: float | None = None, velocity: float | None = None, force: float | None = None, moving_mass: float | None = None, body_mass: float | None = None)

Everything a generated signal is made from, besides its source.

One record for every kind; each reads the fields it needs and the rest are kept as they were, so switching kinds in a pane and back loses nothing. Stored with the object it made (Project.generate_signal records it, with the seed), so the same signal can be made again.

Methods:

Name Description
rating

The shaker's shock ratings these settings give, as a

to_dict

The settings as plain values, for a recipe or a file.

from_dict

Settings from to_dict's values. A field this version does not

describe

The settings in words, as '10 s of random at 8192 Hz'.

Methods:
rating
rating() -> Any

The shaker's shock ratings these settings give, as a core.shaker.ShakerRating; a rating at zero is none, as the stroke reading takes it.

Source code in src/visualdynamics/core/signals.py
def rating(self) -> Any:
    """The shaker's shock ratings these settings give, as a
    `core.shaker.ShakerRating`; a rating at zero is none, as the
    stroke reading takes it."""
    from .shaker import ShakerRating
    from .stroke import _rating

    return ShakerRating(**{name: _rating(getattr(self, name))
                           for name in ('force', 'stroke', 'velocity',
                                        'moving_mass', 'body_mass')})
to_dict
to_dict() -> dict[str, Any]

The settings as plain values, for a recipe or a file.

Source code in src/visualdynamics/core/signals.py
def to_dict(self) -> dict[str, Any]:
    """The settings as plain values, for a recipe or a file."""
    out = asdict(self)
    out['band'] = list(self.band)
    if self.dofs is not None:
        out['dofs'] = list(self.dofs)
    return out
from_dict classmethod
from_dict(values: Mapping[str, Any]) -> SignalSettings

Settings from to_dict's values. A field this version does not know is left out, and one it knows that is missing keeps its default, so a recipe written by a later or an earlier version still reads.

Source code in src/visualdynamics/core/signals.py
@classmethod
def from_dict(cls, values: Mapping[str, Any]) -> SignalSettings:
    """Settings from `to_dict`'s values. A field this version does not
    know is left out, and one it knows that is missing keeps its
    default, so a recipe written by a later or an earlier version
    still reads."""
    known = {f.name for f in fields(cls)}
    kept = {k: v for k, v in dict(values).items() if k in known}
    if 'band' in kept:
        kept['band'] = tuple(float(v) for v in kept['band'])
    if kept.get('dofs') is not None:
        kept['dofs'] = tuple(kept['dofs'])
    return cls(**kept)
describe
describe() -> str

The settings in words, as '10 s of random at 8192 Hz'.

Source code in src/visualdynamics/core/signals.py
def describe(self) -> str:
    """The settings in words, as '10 s of random at 8192 Hz'."""
    what = KINDS.get(self.kind, self.kind).lower()
    if self.kind == 'burst':
        return (f'{self.bursts} bursts of random, {self.burst:.0%} on, '
                f'at {self.sample_rate:g} Hz')
    if self.kind == 'periodic':
        return f'{self.periods} periods of {what} at {self.sample_rate:g} Hz'
    if self.kind == 'sine':
        return f'sine at {self.sample_rate:g} Hz'
    if self.kind == 'srs':
        return f'SRS synthesis at {self.sample_rate:g} Hz'
    return f'{self.duration:g} s of {what} at {self.sample_rate:g} Hz'

Functions:

random_history

random_history(spectrum: Psd, *, duration: float, sample_rate: float, frame_length: int | None = None, dofs: Sequence[str] | None = None, correlated: bool = True, seed: int | Generator | None = None) -> TimeHistory

Stationary Gaussian random at the reference DOFs, shaped to a PSD or a full CPSD.

Parameters:

Name Type Description Default
spectrum Psd or Specification

The target, in declared units: an autospectrum per DOF, and the cross terms between them for correlated signals.

required
duration float

Seconds.

required
sample_rate float

Samples per second, above twice the highest frequency written.

required
frame_length int

Samples per frame, even, which sets the line spacing the target is drawn at; by default a power of two with lines no coarser than 1 Hz or a quarter of the lowest frequency written.

None
dofs sequence of str

Which of the spectrum's DOFs to draw, in this order; all of them by default.

None
correlated bool

Draw the cross terms the spectrum holds. False draws each DOF on its own, uncorrelated, whatever cross terms it holds; a spectrum of autospectra alone is drawn uncorrelated either way.

True
seed int or Generator

The same seed gives the same record.

None

Returns:

Type Description
TimeHistory

One record per DOF, from time zero.

Raises:

Type Description
ValueError

If the spectrum is not a density in declared units, holds no autospectrum at a DOF asked for, is zero everywhere, is written at or above the Nyquist frequency, or is a matrix no set of signals has at some line; or if the duration is shorter than two samples.

Source code in src/visualdynamics/core/signals.py
def random_history(spectrum: Psd, *, duration: float, sample_rate: float,
                   frame_length: int | None = None,
                   dofs: Sequence[str] | None = None,
                   correlated: bool = True,
                   seed: int | np.random.Generator | None = None
                   ) -> TimeHistory:
    """Stationary Gaussian random at the reference DOFs, shaped to a PSD
    or a full CPSD.

    Parameters
    ----------
    spectrum : Psd or Specification
        The target, in declared units: an autospectrum per DOF, and the
        cross terms between them for correlated signals.
    duration : float
        Seconds.
    sample_rate : float
        Samples per second, above twice the highest frequency written.
    frame_length : int, optional
        Samples per frame, even, which sets the line spacing the target
        is drawn at; by default a power of two with lines no coarser
        than 1 Hz or a quarter of the lowest frequency written.
    dofs : sequence of str, optional
        Which of the spectrum's DOFs to draw, in this order; all of them
        by default.
    correlated : bool, default True
        Draw the cross terms the spectrum holds. False draws each DOF on
        its own, uncorrelated, whatever cross terms it holds; a spectrum
        of autospectra alone is drawn uncorrelated either way.
    seed : int or numpy.random.Generator, optional
        The same seed gives the same record.

    Returns
    -------
    TimeHistory
        One record per DOF, from time zero.

    Raises
    ------
    ValueError
        If the spectrum is not a density in declared units, holds no
        autospectrum at a DOF asked for, is zero everywhere, is written at
        or above the Nyquist frequency, or is a matrix no set of signals
        has at some line; or if the duration is shorter than two samples.
    """
    total = _samples(duration, sample_rate)
    wanted, quantities, n, factors = _plan(spectrum, sample_rate,
                                           frame_length, dofs, correlated)
    hop = n // 2
    count = int(np.ceil(total / hop)) + 1
    rng = _rng(seed)
    window = np.sin(np.pi * (np.arange(n) + 0.5) / n)
    out = np.zeros((len(wanted), (count + 1) * hop))
    for first in range(0, count, FRAMES_AT_ONCE):
        frames = _frames(factors, n, sample_rate,
                         min(FRAMES_AT_ONCE, count - first), rng)
        for m, frame in enumerate(frames, start=first):
            out[:, m * hop:m * hop + n] += window * frame
    # the first half frame has one window over it, not two; start where
    # every sample has its two
    # read as it was drawn: frames of its own length under a Hann window,
    # overlapped by half
    return _history(out[:, hop:hop + total], sample_rate, wanted, quantities,
                    'random', frame=n, overlap=0.5, window='hann')

burst_random_history

burst_random_history(spectrum: Psd, *, bursts: int, sample_rate: float, frame_length: int, burst: float = 0.5, taper: float = BURST_TAPER, dofs: Sequence[str] | None = None, correlated: bool = True, seed: int | Generator | None = None) -> TimeHistory

Burst random: bursts frames, each random for the first burst share of the frame and quiet for the rest.

The level while a burst is on is the target's; averaged over the whole frame the PSD is the target times the share the burst is on (less its tapers). Each frame holds one burst, from the frame's start, so a capture of the frame's length holds the burst and the response's decay, and is read leakage-free with a rectangular window once the response has died away.

Parameters:

Name Type Description Default
spectrum Psd or Specification

The target, as for random_history.

required
bursts int

How many frames.

required
sample_rate float

Samples per second.

required
frame_length int

Samples per frame: the capture length the bursts are aligned to.

required
burst float

The share of each frame the burst is on, above 0 and at most 1.

0.5
taper float

The share of the burst each cosine ramp takes, at either end.

`BURST_TAPER`
dofs Sequence[str] | None

As for random_history.

None
correlated Sequence[str] | None

As for random_history.

None
seed Sequence[str] | None

As for random_history.

None

Returns:

Type Description
TimeHistory

bursts frames end to end.

Raises:

Type Description
ValueError

As random_history, and for a burst share or taper out of range or fewer than one burst.

Source code in src/visualdynamics/core/signals.py
def burst_random_history(spectrum: Psd, *, bursts: int, sample_rate: float,
                         frame_length: int, burst: float = 0.5,
                         taper: float = BURST_TAPER,
                         dofs: Sequence[str] | None = None,
                         correlated: bool = True,
                         seed: int | np.random.Generator | None = None
                         ) -> TimeHistory:
    """Burst random: `bursts` frames, each random for the first `burst`
    share of the frame and quiet for the rest.

    The level while a burst is on is the target's; averaged over the
    whole frame the PSD is the target times the share the burst is on
    (less its tapers). Each frame holds one burst, from the frame's
    start, so a capture of the frame's length holds the burst and the
    response's decay, and is read leakage-free with a rectangular window
    once the response has died away.

    Parameters
    ----------
    spectrum : Psd or Specification
        The target, as for `random_history`.
    bursts : int
        How many frames.
    sample_rate : float
        Samples per second.
    frame_length : int
        Samples per frame: the capture length the bursts are aligned to.
    burst : float, default 0.5
        The share of each frame the burst is on, above 0 and at most 1.
    taper : float, default `BURST_TAPER`
        The share of the burst each cosine ramp takes, at either end.
    dofs, correlated, seed
        As for `random_history`.

    Returns
    -------
    TimeHistory
        `bursts` frames end to end.

    Raises
    ------
    ValueError
        As `random_history`, and for a burst share or taper out of range
        or fewer than one burst.
    """
    if int(bursts) < 1:
        raise ValueError('at least one burst')
    if not 0.0 < burst <= 1.0:
        raise ValueError(f'a burst on for {burst:g} of its frame: it has to '
                         'be above 0 and at most 1')
    if not 0.0 <= taper < 0.5:
        raise ValueError(f'a taper of {taper:g} of the burst: it has to be '
                         'at least 0 and under one half')
    wanted, quantities, n, factors = _plan(spectrum, sample_rate,
                                           frame_length, dofs, correlated)
    on = round(burst * n)
    ramp = round(taper * on)
    gate = np.zeros(n)
    gate[:on] = 1.0
    if ramp > 0:
        rise = 0.5 - 0.5 * np.cos(np.pi * (np.arange(ramp) + 0.5) / ramp)
        gate[:ramp] = rise
        gate[on - ramp:on] = rise[::-1]
    rng = _rng(seed)
    rows = np.empty((len(wanted), int(bursts) * n))
    for first in range(0, int(bursts), FRAMES_AT_ONCE):
        frames = _frames(factors, n, sample_rate,
                         min(FRAMES_AT_ONCE, int(bursts) - first), rng)
        for m, frame in enumerate(frames, start=first):
            rows[:, m * n:(m + 1) * n] = frame * gate
    # one rectangular frame per burst: the burst and its quiet tail are
    # whole in each, so nothing leaks and each burst is one average
    return _history(rows, sample_rate, wanted, quantities,
                    f'burst random, {burst:.0%} on', frame=n)

periodic_random_history

periodic_random_history(spectrum: Psd, *, periods: int, sample_rate: float, frame_length: int, repeats: int | None = None, dofs: Sequence[str] | None = None, correlated: bool = True, seed: int | Generator | None = None) -> TimeHistory

Periodic random: one random frame repeated, so that a frame of the same length read with a rectangular window has no leakage.

With repeats omitted the one frame is repeated periods times (pseudo-random). With repeats r, a new frame is drawn every r periods (periodic random), so averages over many realizations are taken from a record whose every frame is still periodic; the first period of each realization carries the change from the last and is the one to discard once a response is computed from it.

Parameters:

Name Type Description Default
spectrum Psd or Specification

The target, as for random_history.

required
periods int

How many frames in all.

required
sample_rate float

Samples per second.

required
frame_length int

Samples per period.

required
repeats int

Periods per realization; all of them by default.

None
dofs Sequence[str] | None

As for random_history.

None
correlated Sequence[str] | None

As for random_history.

None
seed Sequence[str] | None

As for random_history.

None

Returns:

Type Description
TimeHistory

periods frames end to end.

Raises:

Type Description
ValueError

As random_history, and for fewer than one period or repeat.

Source code in src/visualdynamics/core/signals.py
def periodic_random_history(spectrum: Psd, *, periods: int,
                            sample_rate: float, frame_length: int,
                            repeats: int | None = None,
                            dofs: Sequence[str] | None = None,
                            correlated: bool = True,
                            seed: int | np.random.Generator | None = None
                            ) -> TimeHistory:
    """Periodic random: one random frame repeated, so that a frame of
    the same length read with a rectangular window has no leakage.

    With `repeats` omitted the one frame is repeated `periods` times
    (pseudo-random). With `repeats` r, a new frame is drawn every r
    periods (periodic random), so averages over many realizations are
    taken from a record whose every frame is still periodic; the first
    period of each realization carries the change from the last and is
    the one to discard once a response is computed from it.

    Parameters
    ----------
    spectrum : Psd or Specification
        The target, as for `random_history`.
    periods : int
        How many frames in all.
    sample_rate : float
        Samples per second.
    frame_length : int
        Samples per period.
    repeats : int, optional
        Periods per realization; all of them by default.
    dofs, correlated, seed
        As for `random_history`.

    Returns
    -------
    TimeHistory
        `periods` frames end to end.

    Raises
    ------
    ValueError
        As `random_history`, and for fewer than one period or repeat.
    """
    periods = int(periods)
    if periods < 1:
        raise ValueError('at least one period')
    repeats = periods if repeats is None else int(repeats)
    if repeats < 1:
        raise ValueError('at least one period per realization')
    wanted, quantities, n, factors = _plan(spectrum, sample_rate,
                                           frame_length, dofs, correlated)
    count = int(np.ceil(periods / repeats))
    frames = _frames(factors, n, sample_rate, count, _rng(seed))
    rows = np.repeat(frames, repeats, axis=0)[:periods]
    rows = rows.transpose(1, 0, 2).reshape(len(wanted), -1)
    kind = ('pseudo-random' if repeats >= periods
            else f'periodic random, {repeats} periods each')
    # one rectangular frame per period, leakage-free
    return _history(rows, sample_rate, wanted, quantities, kind, frame=n)

impulse_history

impulse_history(dofs: str | Sequence[str], *, quantity: str, duration: float, sample_rate: float, at: float = 0.0, peak: float = 1.0, width: float = 0.001, pulse: ClassicalPulse | None = None) -> TimeHistory

An impulse, or a classical pulse, at the same instant at every DOF.

With no pulse, an impulse: a half-sine of peak and width from at, whose spectrum rolls off as impulse_rolloff gives. With a pulse, its shape, peak and duration from at (ClassicalPulse.at).

Parameters:

Name Type Description Default
dofs str or sequence of str

The reference DOFs.

required
quantity str

What the signal is, such as 'force' or 'acceleration'.

required
duration float

Seconds of record.

required
sample_rate float

Samples per second.

required
at float

When the impulse or the pulse starts, in seconds.

0.0
peak float

The impulse's peak, signed, in SI; not used with a pulse.

1.0
width float

The impulse's width, the half-sine's half cycle, in seconds; not used with a pulse. At least two samples.

1e-3
pulse ClassicalPulse

A half-sine, sawtooth or trapezoid instead of the impulse.

None

Returns:

Type Description
TimeHistory

One identical record per DOF.

Raises:

Type Description
ValueError

If the start is outside the record or the impulse or pulse runs past its end, the peak is not a number other than zero, the impulse is narrower than two samples, or the duration is shorter than two samples.

Source code in src/visualdynamics/core/signals.py
def impulse_history(dofs: str | Sequence[str], *, quantity: str,
                    duration: float, sample_rate: float, at: float = 0.0,
                    peak: float = 1.0, width: float = 1e-3,
                    pulse: ClassicalPulse | None = None) -> TimeHistory:
    """An impulse, or a classical pulse, at the same instant at every DOF.

    With no `pulse`, an impulse: a half-sine of `peak` and `width` from
    `at`, whose spectrum rolls off as `impulse_rolloff` gives. With a
    `pulse`, its shape, peak and duration from `at` (`ClassicalPulse.at`).

    Parameters
    ----------
    dofs : str or sequence of str
        The reference DOFs.
    quantity : str
        What the signal is, such as 'force' or 'acceleration'.
    duration : float
        Seconds of record.
    sample_rate : float
        Samples per second.
    at : float, default 0.0
        When the impulse or the pulse starts, in seconds.
    peak : float, default 1.0
        The impulse's peak, signed, in SI; not used with a pulse.
    width : float, default 1e-3
        The impulse's width, the half-sine's half cycle, in seconds; not
        used with a pulse. At least two samples.
    pulse : ClassicalPulse, optional
        A half-sine, sawtooth or trapezoid instead of the impulse.

    Returns
    -------
    TimeHistory
        One identical record per DOF.

    Raises
    ------
    ValueError
        If the start is outside the record or the impulse or pulse runs
        past its end, the peak is not a number other than zero, the
        impulse is narrower than two samples, or the duration is shorter
        than two samples.
    """
    dofs = [dofs] if isinstance(dofs, str) else list(dofs)
    if not dofs:
        raise ValueError('name at least one DOF')
    total = _samples(duration, sample_rate)
    start = round(at * sample_rate)
    if not 0 <= start < total:
        raise ValueError(f'an impulse at {at:g} s is outside the record of '
                         f'{total / sample_rate:g} s')
    if pulse is None:
        # a half-sine, not one sample (Brandon, 2026-10-25): its width
        # sets the band it excites, where a single sample's spectrum is
        # flat to whatever the Nyquist frequency happens to be
        if not width * sample_rate >= 2.0:
            raise ValueError(f'an impulse {1e3 * width:.4g} ms wide is under '
                             f'two samples at {sample_rate:g} Hz; the '
                             f'narrowest is {2e3 / sample_rate:.4g} ms')
        shape = ClassicalPulse('half-sine', peak, width)
        what = f'impulse, {1e3 * width:.4g} ms half-sine'
    else:
        shape, what = pulse, pulse.describe()
    if start + shape.extent * sample_rate > total:
        raise ValueError(f'the {what} from {at:g} s runs past the end of '
                         f'the record at {total / sample_rate:g} s')
    row = shape.at(np.arange(total) / sample_rate - start / sample_rate)
    return _history(np.tile(row, (len(dofs), 1)), sample_rate, dofs,
                    [quantity] * len(dofs), what)

impulse_rolloff

impulse_rolloff(width: float) -> tuple[float, float, float]

Where a half-sine impulse width seconds wide rolls off: the frequencies, in Hz, at which its spectrum is 3 dB and 10 dB below its level at low frequency, and its first zero, 1.5/width.

The ratio |cos(πfT)/(1 - 4f²T²)| falls monotonically to its first zero, so each level is solved for once on that span. The rule of thumb that a hammer excites up to about one over its contact time is the 10 dB point, 1.02/T.

Source code in src/visualdynamics/core/signals.py
def impulse_rolloff(width: float) -> tuple[float, float, float]:
    """Where a half-sine impulse `width` seconds wide rolls off: the
    frequencies, in Hz, at which its spectrum is 3 dB and 10 dB below its
    level at low frequency, and its first zero, 1.5/`width`.

    The ratio |cos(πfT)/(1 - 4f²T²)| falls monotonically to its first
    zero, so each level is solved for once on that span. The rule of
    thumb that a hammer excites up to about one over its contact time is
    the 10 dB point, 1.02/T.
    """
    from scipy.optimize import brentq

    if not width > 0.0:
        raise ValueError(f'the width ({width:g} s) has to be above zero')
    points = [brentq(lambda x, db=db: _half_sine_ratio(x) - 10 ** (-db / 20),
                     1e-9, 1.5 - 1e-9) for db in (3.0, 10.0)]
    return points[0] / width, points[1] / width, 1.5 / width

impulse_spectrum

impulse_spectrum(history: TimeHistory, record: int = 0) -> Spectrum

The magnitude of a record's Fourier transform, |X(f)| = |Σ x_n e^(-i2πf n Δt)|·Δt, in the record's quantity times seconds (N·s for a force): what a transient puts into each frequency, flat at its integral at low frequency.

The record is zero-padded to at least SPECTRUM_LINES lines, so a short impulse is drawn as a smooth curve and its rolloff and zeros can be read off it. Read from the samples, so it is what the record holds, sampling included.

Source code in src/visualdynamics/core/signals.py
def impulse_spectrum(history: TimeHistory, record: int = 0) -> Spectrum:
    """The magnitude of a record's Fourier transform, |X(f)| = |Σ x_n
    e^(-i2πf n Δt)|·Δt, in the record's quantity times seconds (N·s for
    a force): what a transient puts into each frequency, flat at its
    integral at low frequency.

    The record is zero-padded to at least `SPECTRUM_LINES` lines, so a
    short impulse is drawn as a smooth curve and its rolloff and zeros
    can be read off it. Read from the samples, so it is what the record
    holds, sampling included.
    """
    dt = float(np.median(np.diff(history.abscissa)))
    row = np.asarray(history.ordinate[record], dtype=float)
    n = max(2 * SPECTRUM_LINES, 1 << int(np.ceil(np.log2(max(row.size, 2)))))
    magnitude = np.abs(np.fft.rfft(row, n)) * dt
    return Spectrum(np.fft.rfftfreq(n, dt), magnitude[np.newaxis, :],
                    response_dof=[history.response_dof[record]],
                    ordinate_dim=[f'{history.ordinate_dim[record]}*time'],
                    comment=['Fourier transform magnitude'])

sine_history

sine_history(specification: SineSweepSpecification, *, sample_rate: float, tone: str | None = None) -> TimeHistory

A sine specification's tone as the signal it describes, at each of its control DOFs.

The phase is the tone's own (SineTone.argument), so the frequency follows its segments at their rates, dwells included. The amplitude and the phase of each DOF are interpolated linearly in time between the tone's breakpoints, which is the target the controller follows (SineTone.target). The record starts at the tone's start.

Parameters:

Name Type Description Default
specification SineSweepSpecification

The tones and their control DOFs, in declared units.

required
sample_rate float

Samples per second, above twice the tone's highest frequency.

required
tone str

Which tone; the first by default.

None

Returns:

Type Description
TimeHistory

One record per control DOF.

Raises:

Type Description
ValueError

If the tone is not one the specification holds, its units are not declared, or it reaches the Nyquist frequency.

Source code in src/visualdynamics/core/signals.py
def sine_history(specification: SineSweepSpecification, *,
                 sample_rate: float, tone: str | None = None) -> TimeHistory:
    """A sine specification's tone as the signal it describes, at each of
    its control DOFs.

    The phase is the tone's own (`SineTone.argument`), so the frequency
    follows its segments at their rates, dwells included. The amplitude
    and the phase of each DOF are interpolated linearly in time between
    the tone's breakpoints, which is the target the controller follows
    (`SineTone.target`). The record starts at the tone's start.

    Parameters
    ----------
    specification : SineSweepSpecification
        The tones and their control DOFs, in declared units.
    sample_rate : float
        Samples per second, above twice the tone's highest frequency.
    tone : str, optional
        Which tone; the first by default.

    Returns
    -------
    TimeHistory
        One record per control DOF.

    Raises
    ------
    ValueError
        If the tone is not one the specification holds, its units are not
        declared, or it reaches the Nyquist frequency.
    """
    if not specification.tones:
        raise ValueError('the specification holds no tone')
    names = [t.name for t in specification.tones]
    if tone is not None and tone not in names:
        raise ValueError(f'no tone {tone!r}; the tones are {", ".join(names)}')
    chosen = specification.tones[0 if tone is None else names.index(tone)]
    if specification.ordinate_dim == UNKNOWN:
        raise ValueError('the specification does not say what its tones '
                         'are; declare its units first')
    if not sample_rate > 0.0:
        raise ValueError('the sample rate has to be above zero')
    top = float(np.max(chosen.frequency))
    if top >= sample_rate / 2.0:
        raise ValueError(f'{chosen.name} reaches {top:.6g} Hz, at or above '
                         f'the Nyquist frequency of {sample_rate / 2:.6g} Hz')
    argument = chosen.argument(1.0 / sample_rate)
    t = np.arange(len(argument)) / sample_rate
    corners = np.concatenate([[0.0], np.cumsum(chosen.segment_seconds())])
    amplitude = np.asarray(chosen.amplitude, dtype=float)
    phase = (np.zeros_like(amplitude) if chosen.phase is None
             else np.unwrap(np.asarray(chosen.phase, dtype=float), axis=0))
    rows = np.empty((amplitude.shape[1], len(t)))
    for c in range(amplitude.shape[1]):
        size = np.interp(t, corners, amplitude[:, c])
        shift = np.interp(t, corners, phase[:, c])
        rows[c] = size * np.cos(argument + shift)
    quantity = specification.ordinate_dim
    history = _history(rows, sample_rate, list(specification.response_dof),
                       [quantity] * rows.shape[0], f'sine, {chosen.name}')
    # a sweep is read by tracking, not in frames: the record's own
    # default, as any other recording
    history.averaging = None
    return history

flat_spectrum

flat_spectrum(dofs: str | Sequence[str], *, quantity: str, band: tuple[float, float], level: float, unit: str | None = None) -> Specification

A flat target over a band at every DOF, uncorrelated, for a signal typed in with no specification to read.

Parameters:

Name Type Description Default
dofs str or sequence of str

The reference DOFs.

required
quantity str

What the signal is, such as 'force'; the target is its density.

required
band (float, float)

Lowest and highest frequency, Hz.

required
level float or sequence of float

The density across the band, in unit: one for every DOF, or one per DOF.

required
unit str

The density's unit, such as 'N**2/Hz'; SI when omitted.

None

Returns:

Type Description
Specification

Two breakpoints per DOF.

Source code in src/visualdynamics/core/signals.py
def flat_spectrum(dofs: str | Sequence[str], *, quantity: str,
                  band: tuple[float, float], level: float,
                  unit: str | None = None) -> Specification:
    """A flat target over a band at every DOF, uncorrelated, for a
    signal typed in with no specification to read.

    Parameters
    ----------
    dofs : str or sequence of str
        The reference DOFs.
    quantity : str
        What the signal is, such as 'force'; the target is its density.
    band : (float, float)
        Lowest and highest frequency, Hz.
    level : float or sequence of float
        The density across the band, in `unit`: one for every DOF, or
        one per DOF.
    unit : str, optional
        The density's unit, such as 'N**2/Hz'; SI when omitted.

    Returns
    -------
    Specification
        Two breakpoints per DOF.
    """
    dofs = [dofs] if isinstance(dofs, str) else list(dofs)
    low, high = float(band[0]), float(band[1])
    if not 0.0 < low < high:
        raise ValueError(f'a band from {low:g} to {high:g} Hz: the lowest '
                         'has to be above zero and below the highest')
    each = np.broadcast_to(np.asarray(level, dtype=float), (len(dofs),))
    bad = each[~(np.isfinite(each) & (each > 0.0))]
    if len(bad):
        raise ValueError(f'a level of {bad[0]:g}: it has to be above zero')
    levels = np.repeat(each[:, None], 2, axis=1)
    return Specification([low, high], levels, response_dof=dofs,
                         ordinate_dim=quantity + _DENSITY,
                         ordinate_unit=unit)

kinds_for

kinds_for(source: Any) -> tuple[str, ...]

The kinds of signal source can make, in KINDS order: random kinds from a spectrum, sine from a sine specification, an SRS synthesis from a shock specification, and from a channel table every kind but sine and the synthesis, at its reference channels.

Source code in src/visualdynamics/core/signals.py
def kinds_for(source: Any) -> tuple[str, ...]:
    """The kinds of signal `source` can make, in `KINDS` order: random
    kinds from a spectrum, sine from a sine specification, an SRS
    synthesis from a shock specification, and from a channel table every
    kind but sine and the synthesis, at its reference channels."""
    from .channel_table import ChannelTable
    from .data import ShockSpecification

    if isinstance(source, SineSweepSpecification):
        return ('sine',)
    if isinstance(source, ShockSpecification):
        return ('srs',)
    if isinstance(source, Psd):
        return RANDOM_KINDS
    if isinstance(source, ChannelTable):
        return (*RANDOM_KINDS, 'impulse', 'pulse')
    return ()

reference_unit

reference_unit(table: Any, dofs: Sequence[str] | None = None) -> str

The unit a typed level and peak are read in for a channel table's reference channels: the one unit they share, SI's where none is written, or '' when they differ (each is then read in its own channel's unit).

Parameters:

Name Type Description Default
table ChannelTable

The table.

required
dofs sequence of str

Which reference DOFs; all of them when omitted.

None

Returns:

Type Description
str

The unit, such as 'lbf', or ''.

Raises:

Type Description
ValueError

As generate refuses the table's references.

Source code in src/visualdynamics/core/signals.py
def reference_unit(table: Any, dofs: Sequence[str] | None = None) -> str:
    """The unit a typed level and peak are read in for a channel
    table's reference channels: the one unit they share, SI's where
    none is written, or '' when they differ (each is then read in its
    own channel's unit).

    Parameters
    ----------
    table : ChannelTable
        The table.
    dofs : sequence of str, optional
        Which reference DOFs; all of them when omitted.

    Returns
    -------
    str
        The unit, such as 'lbf', or ''.

    Raises
    ------
    ValueError
        As `generate` refuses the table's references.
    """
    from ..units import SI

    wanted, quantity, _factors = _references(table, dofs)
    rows, own = table.chosen('reference'), table.dof_strings()
    units = table['unit']
    written = {str(units[i]) or SI.unit(quantity) for i in rows
               if own[i] in wanted}
    return written.pop() if len(written) == 1 else ''

generate

generate(source: Any, settings: SignalSettings, *, progress: Any = None) -> TimeHistory

The signal settings describe, made from source.

Parameters:

Name Type Description Default
source (Psd, Specification, SineSweepSpecification, ShockSpecification or ChannelTable)

A spectrum's DOFs and shape for random kinds; a sine specification's tone; a shock specification's spectrum for an SRS synthesis; a channel table's reference channels for an impulse, a pulse, or random to the typed band and level.

required
settings SignalSettings

The kind and its settings.

required
progress callable

Called with (done, total) as an SRS synthesis iterates (core.shock_synthesis.synthesize); the other kinds are quick and do not call it.

None

Returns:

Type Description
TimeHistory

One record per DOF.

Raises:

Type Description
ValueError

If the kind is not one source can make (kinds_for), or as the generator for the kind refuses.

Source code in src/visualdynamics/core/signals.py
def generate(source: Any, settings: SignalSettings, *,
             progress: Any = None) -> TimeHistory:
    """The signal `settings` describe, made from `source`.

    Parameters
    ----------
    source : Psd, Specification, SineSweepSpecification, ShockSpecification or ChannelTable
        A spectrum's DOFs and shape for random kinds; a sine
        specification's tone; a shock specification's spectrum for an
        SRS synthesis; a channel table's reference channels for an
        impulse, a pulse, or random to the typed `band` and `level`.
    settings : SignalSettings
        The kind and its settings.
    progress : callable, optional
        Called with ``(done, total)`` as an SRS synthesis iterates
        (`core.shock_synthesis.synthesize`); the other kinds are quick
        and do not call it.

    Returns
    -------
    TimeHistory
        One record per DOF.

    Raises
    ------
    ValueError
        If the kind is not one `source` can make (`kinds_for`), or as
        the generator for the kind refuses.
    """
    from .channel_table import ChannelTable

    s = settings
    offered = kinds_for(source)
    if s.kind not in offered:
        made = ', '.join(KINDS[k].lower() for k in offered) or 'no signal'
        raise ValueError(f'{KINDS.get(s.kind, s.kind)} is not made from '
                         f'this object; it makes {made}')
    if s.kind == 'sine':
        return sine_history(source, sample_rate=s.sample_rate, tone=s.tone)
    if s.kind == 'srs':
        from .shock_synthesis import synthesize

        return synthesize(source, sample_rate=s.sample_rate,
                          rating=s.rating(), half_cycles=s.half_cycles,
                          dofs=s.dofs, progress=progress).history
    spectrum, dofs = source, s.dofs
    if isinstance(source, ChannelTable):
        dofs, quantity, factors = _references(source, s.dofs)
        if s.kind in ('impulse', 'pulse'):
            if s.kind == 'impulse':
                history = impulse_history(dofs, quantity=quantity,
                                          duration=s.duration,
                                          sample_rate=s.sample_rate, at=s.at,
                                          peak=s.peak, width=s.width)
            else:
                pulse = ClassicalPulse(s.shape, s.peak, s.pulse_duration,
                                       s.edge)
                history = impulse_history(dofs, quantity=quantity,
                                          duration=s.duration,
                                          sample_rate=s.sample_rate, at=s.at,
                                          pulse=pulse)
            # the peak was typed in each channel's unit; the history is
            # SI, and a pulse is linear in its peak
            history.ordinate = history.ordinate * factors[:, None]
            return history
        spectrum = flat_spectrum(dofs, quantity=quantity, band=s.band,
                                 level=s.level * factors ** 2)
    common = {'dofs': dofs, 'correlated': s.correlated, 'seed': s.seed}
    if s.kind == 'random':
        return random_history(spectrum, duration=s.duration,
                              sample_rate=s.sample_rate,
                              frame_length=s.frame_length, **common)
    frame = s.frame_length or DEFAULT_FRAME
    if s.kind == 'burst':
        return burst_random_history(spectrum, bursts=s.bursts,
                                    sample_rate=s.sample_rate,
                                    frame_length=frame, burst=s.burst,
                                    taper=s.taper, **common)
    return periodic_random_history(spectrum, periods=s.periods,
                                   sample_rate=s.sample_rate,
                                   frame_length=frame, repeats=s.repeats,
                                   **common)

target_of

target_of(source: Any, settings: SignalSettings) -> Psd | None

The spectrum a random signal is drawn to, for drawing beside its own: the source itself, or the typed target at a channel table's references; None for the kinds with no spectrum to meet.

Source code in src/visualdynamics/core/signals.py
def target_of(source: Any, settings: SignalSettings) -> Psd | None:
    """The spectrum a random signal is drawn to, for drawing beside its
    own: the source itself, or the typed target at a channel table's
    references; None for the kinds with no spectrum to meet."""
    from .channel_table import ChannelTable

    if settings.kind not in RANDOM_KINDS:
        return None
    if isinstance(source, ChannelTable):
        dofs, quantity, factors = _references(source, settings.dofs)
        return flat_spectrum(dofs, quantity=quantity, band=settings.band,
                             level=settings.level * factors ** 2)
    return source

preview

preview(source: Any, settings: SignalSettings) -> TimeHistory

The signal settings describe, cut short to PREVIEW_SAMPLES so that it answers while a setting is being typed: fewer seconds, bursts or periods, every other setting as given. A sine is drawn whole only when it fits; otherwise its first PREVIEW_SAMPLES. An SRS synthesis is iterated whole, as it is made: a shorter one would meet a different spectrum.

Source code in src/visualdynamics/core/signals.py
def preview(source: Any, settings: SignalSettings) -> TimeHistory:
    """The signal `settings` describe, cut short to `PREVIEW_SAMPLES` so
    that it answers while a setting is being typed: fewer seconds, bursts
    or periods, every other setting as given. A sine is drawn whole only
    when it fits; otherwise its first `PREVIEW_SAMPLES`. An SRS
    synthesis is iterated whole, as it is made: a shorter one would meet
    a different spectrum."""
    s = settings
    rate = s.sample_rate
    if not rate > 0.0:
        raise ValueError('the sample rate has to be above zero')
    if s.kind == 'srs':
        return generate(source, s)
    if s.kind == 'sine':
        full = generate(source, s)
        if full.ordinate.shape[1] <= PREVIEW_SAMPLES:
            return full
        return TimeHistory(full.abscissa[:PREVIEW_SAMPLES],
                           full.ordinate[:, :PREVIEW_SAMPLES],
                           response_dof=full.response_dof,
                           ordinate_dim=full.ordinate_dim,
                           comment=full.comment)
    frame = s.frame_length or DEFAULT_FRAME
    shorter = replace(
        s,
        duration=min(s.duration, PREVIEW_SAMPLES / rate),
        bursts=max(1, min(s.bursts, PREVIEW_SAMPLES // frame)),
        periods=max(1, min(s.periods, PREVIEW_SAMPLES // frame)))
    return generate(source, shorter)

frame_used

frame_used(source: Any, settings: SignalSettings) -> int | None

The frame length the random kinds are drawn with: the setting's, or the one random_history chooses when it is None; None for the kinds that are not drawn in frames.

Source code in src/visualdynamics/core/signals.py
def frame_used(source: Any, settings: SignalSettings) -> int | None:
    """The frame length the random kinds are drawn with: the setting's,
    or the one `random_history` chooses when it is None; None for the
    kinds that are not drawn in frames."""
    from .channel_table import ChannelTable

    if settings.kind not in RANDOM_KINDS:
        return None
    if settings.kind != 'random':
        return int(settings.frame_length or DEFAULT_FRAME)
    if settings.frame_length:
        return int(settings.frame_length)
    spectrum = target_of(source, settings) if isinstance(source, ChannelTable) \
        else source
    return _plan(spectrum, settings.sample_rate, None, settings.dofs,
                 settings.correlated)[2]

suggested

suggested(source: Any) -> SignalSettings

Settings to open a pane with: the first kind the source makes, and a sample rate 2.56 times the highest frequency it holds, as an analyzer's span is set, rounded up to a multiple of 1024; for an SRS synthesis, core.shock_synthesis.suggested_rate.

Source code in src/visualdynamics/core/signals.py
def suggested(source: Any) -> SignalSettings:
    """Settings to open a pane with: the first kind the source makes,
    and a sample rate 2.56 times the highest frequency it holds, as an
    analyzer's span is set, rounded up to a multiple of 1024; for an SRS
    synthesis, `core.shock_synthesis.suggested_rate`."""
    from .channel_table import ChannelTable
    from .data import ShockSpecification

    if isinstance(source, ShockSpecification):
        from .shock_synthesis import suggested_rate

        return SignalSettings(kind='srs', sample_rate=suggested_rate(source))
    kinds = kinds_for(source)
    top = None
    if isinstance(source, SineSweepSpecification):
        top = max(float(np.max(t.frequency)) for t in source.tones) \
            if source.tones else None
    elif isinstance(source, Psd):
        magnitude = np.abs(np.asarray(source.ordinate))
        written = np.asarray(source.abscissa)[np.any(
            np.isfinite(magnitude) & (magnitude > 0), axis=0)]
        top = float(written.max()) if written.size else None
    elif isinstance(source, ChannelTable):
        top = SignalSettings().band[1]
    rate = SignalSettings().sample_rate
    if top:
        rate = float(1024 * int(np.ceil(2.56 * top / 1024.0)))
    return SignalSettings(kind=kinds[0] if kinds else 'random',
                          sample_rate=rate)

target_at

target_at(spectrum: Psd, lines: Any) -> Psd

A spectrum's autospectra read at lines the way the random kinds draw them (between breakpoints log-log, a controller's lines per bin), NaN outside the band each is written on: the line the signal is drawn to, to plot beside it.

Source code in src/visualdynamics/core/signals.py
def target_at(spectrum: Psd, lines: Any) -> Psd:
    """A spectrum's autospectra read at `lines` the way the random kinds
    draw them (between breakpoints log-log, a controller's lines per
    bin), NaN outside the band each is written on: the line the signal
    is drawn to, to plot beside it."""
    lines = np.asarray(lines, dtype=float)
    autos = [i for i in range(spectrum.num_records)
             if spectrum.is_autospectrum(i)]
    rows = []
    for i in autos:
        level = np.real(_level_at(spectrum, i, lines)).astype(float)
        level[level <= 0.0] = np.nan
        rows.append(level)
    return Psd(lines, np.array(rows), response_dof=[spectrum.response_dof[i]
                                                     for i in autos],
               ordinate_dim=[spectrum.ordinate_dim[i] for i in autos])