visualdynamics.core.wavelet¶
wavelet
¶
The continuous wavelet transform: when each frequency occurs in a record.
A PSD gives the frequencies a record contains but not when they occur. For a stationary record that is all there is to know. For a record that is not stationary (a rattle that comes and goes, a shock's ring-down, a resonance that shifts as a structure heats, a sweep passing through a mode) the time is the question, and the spectrum has averaged it out.
The continuous wavelet transform correlates the record against a short wave packet at many scales. A Fourier transform multiplies the whole record by one sinusoid; the wavelet transform multiplies it by a short burst moved along the record, and repeats that for bursts of many durations.
Scale and frequency are the same axis. A wavelet at scale s responds
to a band around a frequency that follows from s, so a scalogram has
three quantities in it (time, frequency and magnitude), and scale is
another name for the frequency axis. This module works in Hz throughout
and converts to scale internally, because every other frequency axis in
Visual Dynamics is in Hz and a scale means nothing without the
wavelet's center frequency beside it.
The wavelet is the Morlet, the standard choice for vibration work: a
Gaussian-windowed complex sinusoid, so each coefficient has a magnitude
and a phase. omega0, the number of cycles under the window, sets the
trade between time and frequency resolution. A low value resolves when
something happens and blurs its frequency; a high value resolves the
frequency and blurs the time. Six is the conventional default and is
very nearly the smallest value for which the wavelet has a negligible
mean, which is the condition for the transform to be admissible.
The normalization and the scale-to-frequency relation have to be chosen
together. Two conventions are in use. Torrence and Compo (1998)
normalize each wavelet to unit energy and give the scale-to-period
relation 4*pi*s / (omega0 + sqrt(2 + omega0**2)). The other
convention normalizes to unit amplitude and uses 2*pi*s / omega0.
Each relation is exact for its own normalization. At omega0=6 they
differ by 1.4%, so using Torrence and Compo's relation with the
amplitude normalization reports every frequency 1.4% high: a 100 Hz
tone at 101.4 Hz. The error is a bias in one direction, and it cannot
be seen on a frequency grid coarser than about 1%.
This module normalizes for amplitude and uses 2*pi*s / omega0.
Amplitude, because the user reads g and N off the picture: under the
energy normalization the same 2 g tone is drawn four times taller at
50 Hz than at 800 Hz, which is correct for asking how the variance is
distributed and of no use for reading a value. tests/test_wavelet.py
checks the pairing by measuring it: a tone must be reported at its own
frequency, on a grid fine enough that 1.4% is two lines.
What is computed¶
For a record x, at every scale, in the frequency domain:
W(s) = IFFT[ 2 * exp(-(s*omega - omega0)**2 / 2) * H(omega) * X(omega) ]
X is the DFT of the zero-padded record with its mean removed, H is
the Heaviside step that keeps only positive frequencies, and s is the
scale that tunes the wavelet to the frequency of the row.
Where each piece comes from:
- The Morlet wavelet is standard, and its frequency-domain form is
from Torrence and Compo (1998):
psi(s*omega) = pi**-0.25 * H(omega) * exp(-(s*omega - omega0)**2 / 2). They also write the transform as a product in the frequency domain, which keeps the cost down: one multiply and one inverse FFT per scale instead of a convolution. - The factor
2in place of theirpi**-0.25is the amplitude normalization, derived here. A real tone of amplitudeAputsA*N/2in each half of its DFT; the analytic wavelet keeps one half; the peak of the tuned wavelet is its constant; the inverse divides byN. So the constant that makes|W|equalAis exactly 2.tests/test_wavelet.pychecks this numerically: a 2 g tone reads 2.0 at 50, 200 and 800 Hz. - The zero padding is also from Torrence and Compo. A transform the length of the record wraps around, and the zeros keep the two ends of the record apart.
- The cone of influence is Torrence and Compo's e-folding time for the
Morlet,
sqrt(2) * s.
The FFTs are computed with scipy.fft, which takes a workers
argument, and the inverse is one independent FFT per scale.
References
The Morlet definition, the scale-to-frequency relation and the cone of influence are Torrence and Compo's.
- Torrence, C., & Compo, G. P. (1998). "A Practical Guide to Wavelet Analysis." Bulletin of the American Meteorological Society.
- Morlet, J., Arens, G., Fourgeau, E., & Giard, D. (1982). "Wave propagation and sampling theory." Geophysics. The wavelet itself.
- Mallat, S. (2009). A Wavelet Tour of Signal Processing: The Sparse Way, 3rd ed. Academic Press. Admissibility, and why the Morlet needs its correction term at low center frequency.
Functions:
| Name | Description |
|---|---|
fourier_factor |
Seconds of Fourier period per second of wavelet scale, |
scale_for |
The wavelet scale, in seconds, tuned to each frequency in Hz. |
frequency_for |
The frequency, in Hz, that a wavelet of each scale is tuned to. |
log_frequencies |
Frequencies spaced evenly in octaves, |
cone_of_influence |
The time, in seconds, at each end of the record that edge effects reach. |
scalogram |
The complex wavelet coefficients, one row per frequency. |
scalogram_peaks |
The scalogram's magnitude, reduced to at most |
ridge |
The frequency of the largest magnitude at each instant, in Hz. |
decade_values |
The round frequencies to label a log axis with. |
default_range |
The default (low, high) frequency range for a record, in Hz. |
reading |
One channel's scalogram as the app draws it, flat or in 3-D. |
Functions:¶
fourier_factor
¶
Seconds of Fourier period per second of wavelet scale, 2*pi/omega0.
This is the exact relation for the amplitude normalization this
module uses. A tone peaks where the wavelet is tuned to it,
s*omega == omega0, because no factor weights one scale against
another.
Torrence and Compo's 4*pi/(omega0 + sqrt(2 + omega0**2)) is exact
for their unit-energy normalization, whose sqrt(s) factor tilts
the response across scales and moves the peak. Using it with the
amplitude normalization reports every frequency 1.4% high: a 100 Hz
tone at 101.37 Hz. Each relation belongs with its own normalization.
Source code in src/visualdynamics/core/wavelet.py
scale_for
¶
The wavelet scale, in seconds, tuned to each frequency in Hz.
frequency_for
¶
The frequency, in Hz, that a wavelet of each scale is tuned to.
This is the inverse of scale_for. The scale axis and the frequency
axis are one axis under a change of variable, which is why the view
can label its axis in Hz.
Source code in src/visualdynamics/core/wavelet.py
log_frequencies
¶
Frequencies spaced evenly in octaves, per_octave lines to the octave.
The first line is low and the last is the first line at or above
high, so the top line can be slightly above high. Raises
ValueError unless 0 < low < high and per_octave is at least
one.
The spacing is logarithmic because each scale has a constant fractional bandwidth: evenly spaced lines would crowd together at the top of the range and leave gaps at the bottom. It is also how a structural dynamics engineer reads a frequency axis.
Source code in src/visualdynamics/core/wavelet.py
cone_of_influence
¶
The time, in seconds, at each end of the record that edge effects reach.
One value per frequency. A wavelet near the start of a record extends past the start, and there it is correlated against the padding instead of the measurement. The transform still returns a value and the value still looks like data, so the cone is drawn on the picture; inside it, the scalogram is an artifact of where the record was cut.
The width is the e-folding time of the Morlet's Gaussian envelope,
sqrt(2) * s (Torrence and Compo): beyond it, the power of an edge
effect has fallen by a factor of e² (its amplitude by e). Low
frequencies use long wavelets, so the cone is wide at the bottom of
the axis and narrow at the top, and at the bottom of a short record
the cone can cover the whole record. sample_rate is accepted and
not used, since the cone is in seconds.
Source code in src/visualdynamics/core/wavelet.py
scalogram
¶
scalogram(values: ArrayLike, sample_rate: float, frequencies: ArrayLike, omega0: float = OMEGA0) -> ndarray
The complex wavelet coefficients, one row per frequency.
The transform is computed in the frequency domain: at each scale it is one multiply against the record's FFT and one inverse FFT, instead of a convolution in time.
It is computed a band of scales at a time (BAND_BYTES), because
every scale at once is three arrays of (scales, padded length)
complex values, and for a long record that is tens of gigabytes.
The result is still the whole transform, one complex row per
frequency as long as the record, which for a long record is itself
gigabytes. For a picture, use scalogram_peaks, which reduces each
band as it is computed and never holds the whole transform.
The magnitude is normalized to the record's own amplitude: a 2 g tone reads 2 at any frequency, so the color bar is in the record's units and a ridge's height means the same thing everywhere. Torrence and Compo normalize each wavelet to unit energy instead, which is correct for asking how a record's variance is distributed. Under that normalization a constant-amplitude tone reads in proportion to the square root of its period, so the same 2 g at 50 Hz and at 800 Hz differ by a factor of four on the picture, and no amplitude in g or N can be read from it. The two conventions differ by sqrt(scale), and a scalogram does not show which one it is in, so it is stated here.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
values
|
array_like
|
One channel's record, evenly sampled. It must be real and one-dimensional, with at least two samples. |
required |
sample_rate
|
float
|
Samples per second. |
required |
frequencies
|
array_like
|
The frequency of each row, in Hz, each above zero and below Nyquist. |
required |
omega0
|
float
|
Cycles under the wavelet's window, which sets the trade between time and frequency resolution. |
OMEGA0
|
Returns:
| Type | Description |
|---|---|
ndarray
|
Complex, shape |
Raises:
| Type | Description |
|---|---|
ValueError
|
When |
Source code in src/visualdynamics/core/wavelet.py
scalogram_peaks
¶
scalogram_peaks(values: ArrayLike, sample_rate: float, frequencies: ArrayLike, omega0: float = OMEGA0, *, columns: int = COLUMNS) -> tuple[ndarray, ndarray]
The scalogram's magnitude, reduced to at most columns time columns.
This is what a picture is drawn from. The view, the scripting plot
and the report all draw about a screen's width of columns, and a
million-sample record is far more than that. Time is cut into equal
slices and each column is the largest magnitude in its slice (a
peak hold, the same reduction decimate.peak_decimate applies to a
curve). The peaks are kept because the ridges are what a scalogram
is read for: taking every nth sample could skip the samples where a
transient's energy is, and a mean would lower its peak. When the
record has no more samples than columns, nothing is reduced and
the result is the magnitude itself.
The transform is computed band by band and each band is reduced as
it is computed, so the whole transform is never in memory at once.
A long record should go through this function, which uses
BAND_BYTES per working array whatever the record's length.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
values
|
ArrayLike
|
As for |
required |
sample_rate
|
ArrayLike
|
As for |
required |
frequencies
|
ArrayLike
|
As for |
required |
omega0
|
ArrayLike
|
As for |
required |
columns
|
int
|
The largest number of time columns to return. |
COLUMNS
|
Returns:
| Type | Description |
|---|---|
(times, magnitude)
|
|
Raises:
| Type | Description |
|---|---|
ValueError
|
As for |
Source code in src/visualdynamics/core/wavelet.py
ridge
¶
The frequency of the largest magnitude at each instant, in Hz.
This is the line a reader follows across a scalogram, as numbers a
report can quote: for each column of coefficients, the entry of
frequencies at the row with the largest magnitude. A column with
no signal in it still returns a frequency, so the ridge only means
something where there is a ridge to follow; a caller with a quiet
stretch should treat that part of the line as meaningless.
Source code in src/visualdynamics/core/wavelet.py
decade_values
¶
The round frequencies to label a log axis with.
1, 2 and 5 times each power of ten, every one inside
[low, high], in ascending order. The transform's rows are
twelfths of an octave and nobody reads 158.7 Hz off an axis, so the
flat picture, the 3-D stage and the report figure all label these
values instead, and all three call this function.
Source code in src/visualdynamics/core/wavelet.py
default_range
¶
The default (low, high) frequency range for a record, in Hz.
The panel, the scripting plot and the report all use this one
range. The top is 0.98 of Nyquist: the top line has to be below
Nyquist, and the amplitude is accurate to a fraction of a percent
right up to it. The bottom is eight cycles over the record's
duration in seconds, kept between a thousandth of Nyquist and half
the top (with no duration it is a hundredth of the top). At that
frequency the longest wavelets still fit inside the record, so the
bottom octave is not all cone of influence.
Source code in src/visualdynamics/core/wavelet.py
reading
¶
reading(history: Any, channel: int = 0, *, low: float | None = None, high: float | None = None, per_octave: int | None = None, omega0: float | None = None) -> tuple[ndarray, ndarray, ndarray, float]
One channel's scalogram as the app draws it, flat or in 3-D.
Returns (clock, magnitude, frequencies, omega0) for row channel
of history. Settings left out take the defaults the panel opens
with (default_range, PER_OCTAVE, OMEGA0), and lines at or
above Nyquist are dropped. Time is reduced to at most COLUMNS
columns by a peak hold (scalogram_peaks), so a long record is
computed in bounded memory and a transient's ridge is kept. The
clock is in seconds and starts at the record's first time value.
The window, plot.plot_scalogram and
viz.scalogram.plot_scalogram_stage all call this function.
Raises:
| Type | Description |
|---|---|
ValueError
|
When fewer than two lines of the range lie under Nyquist. |