Skip to content

visualdynamics.viz.scalogram

scalogram

The scalogram as a surface: time across, frequency back, amplitude up.

The flat picture shows magnitude as color, which is the usual way to draw three quantities on two axes, but a level read from color is only as accurate as the eye can match a shade against the color bar. A surface puts the third quantity on a third axis, where a ridge stands up and a floor is flat, and keeps the color as well, so each value is shown twice. A scalogram is a function of two variables, so it has a natural 3-D form, and the wavelet view opens in it (principle 1).

The depth axis is log frequency, spaced as the transform's rows are. A wavelet's bandwidth is a constant fraction of its center frequency, so the rows are evenly spaced by octave, and drawing them evenly spaced in linear frequency would put them in the wrong places. The axis is labeled in Hz.

The cone of influence is drawn as two translucent walls where the record's ends reach in. On the flat picture it is shading over the affected region; here the ridge passes behind it.

Functions:

Name Description
add_scalogram

Draw one channel's scalogram as a surface, colored by level, with

plot_scalogram_stage

One channel's scalogram as a surface, as the app's wavelet view

Functions:

add_scalogram

add_scalogram(plotter: Any, magnitude: ndarray, times: ndarray, frequencies: ndarray, *, theme: Any = None, omega0: float = OMEGA0, time_label: str = 'time [s]', level_label: str = 'amplitude') -> dict[str, Any]

Draw one channel's scalogram as a surface, colored by level, with the cone of influence and the frequency labels.

Parameters:

Name Type Description Default
plotter Plotter

The plotter to draw into, cleared by the caller as for the waterfall.

required
magnitude ndarray

Shape (frequencies, times), already the magnitude; this function only draws it.

required
times ndarray

Seconds, one per column.

required
frequencies ndarray

Hz, one per row, ascending and spaced evenly by octave.

required
theme optional

The theme name or mapping.

None
omega0 float

The wavelet's width, for the cone of influence.

OMEGA0
time_label str

Axis titles, units included.

'time [s]'
level_label str

Axis titles, units included.

'time [s]'

Returns:

Type Description
dict

extents as (t0, t1, f0, f1, z0, z1) in real units (the ranges the stage stands for, so anything drawn after this can be placed at a data position; the level runs from zero), and points, the vertex count.

Source code in src/visualdynamics/viz/scalogram.py
@one_render
def add_scalogram(plotter: Any, magnitude: np.ndarray, times: np.ndarray,
                  frequencies: np.ndarray, *, theme: Any = None,
                  omega0: float = wavelet.OMEGA0,
                  time_label: str = 'time [s]',
                  level_label: str = 'amplitude') -> dict[str, Any]:
    """Draw one channel's scalogram as a surface, colored by level, with
    the cone of influence and the frequency labels.

    Parameters
    ----------
    plotter : pyvista.Plotter
        The plotter to draw into, cleared by the caller as for the
        waterfall.
    magnitude : numpy.ndarray
        Shape ``(frequencies, times)``, already the magnitude; this
        function only draws it.
    times : numpy.ndarray
        Seconds, one per column.
    frequencies : numpy.ndarray
        Hz, one per row, ascending and spaced evenly by octave.
    theme : optional
        The theme name or mapping.
    omega0 : float
        The wavelet's width, for the cone of influence.
    time_label, level_label : str
        Axis titles, units included.

    Returns
    -------
    dict
        ``extents`` as ``(t0, t1, f0, f1, z0, z1)`` in real units (the
        ranges the stage stands for, so anything drawn after this can
        be placed at a data position; the level runs from zero), and
        ``points``, the vertex count.
    """
    import pyvista as pv

    colors = resolve_theme(theme)
    sx, sy, sz = STAGE

    values = np.asarray(magnitude, dtype=float)
    rows = np.log10(np.asarray(frequencies, dtype=float))
    clock = np.asarray(times, dtype=float)

    t0, t1 = float(clock[0]), float(clock[-1])
    f0, f1 = float(rows[0]), float(rows[-1])
    finite = np.isfinite(values)
    z0 = 0.0
    z1 = float(values[finite].max()) if finite.any() else 1.0
    if z1 <= z0:
        z1 = z0 + 1.0

    # the stage is a unit box whatever the data's ranges, exactly as the
    # waterfall's is, so a scene does not change shape with its units
    xn = (clock - t0) / (t1 - t0) * sx if t1 > t0 else np.zeros_like(clock)
    yn = (rows - f0) / (f1 - f0) * sy if f1 > f0 else np.zeros_like(rows)
    zn = (values - z0) / (z1 - z0) * sz

    # x fastest, which is the order a StructuredGrid of dimensions
    # (nx, ny, 1) reads its points in. `meshgrid` gives (ny, nx) arrays
    # and `values` is already (frequencies, times) = (ny, nx), so a
    # plain C-order ravel of all three agrees. Getting this wrong does
    # not fail — it draws a surface, fanned and spiked, that looks like
    # a scalogram of something.
    mesh_x, mesh_y = np.meshgrid(xn, yn)
    grid = pv.StructuredGrid()
    grid.points = np.column_stack([mesh_x.ravel(), mesh_y.ravel(),
                                   zn.ravel()]).astype(np.float32)
    grid.dimensions = (len(clock), len(rows), 1)
    grid.point_data['level'] = values.ravel()
    plotter.add_mesh(grid, scalars='level', cmap='viridis',
                     clim=(z0, z1), show_scalar_bar=False,
                     name='scalogram')

    _add_cone(plotter, clock, frequencies, rows, colors, omega0,
              sx, sy, sz, t0, t1, f0, f1)
    _label_frequencies(plotter, frequencies, rows, colors, sy, f0, f1)

    # **Last**, and that is not a style choice: every actor added after
    # `show_bounds` refits the cube axes and rewrites their labeled
    # ranges back to the raw bounds, so a scene built in the other order
    # reads its stage coordinates out as seconds — 0 to 1.6 s on a
    # four-second record (STATUS.md records the same trap for the
    # stage's marks). The cone and the frequency labels go on first.
    #
    # The depth axis keeps its grid and loses its numbers, the way the
    # waterfall's does: `axes_ranges` interpolates labels *linearly*
    # between the ends, and this axis is log-spaced, so its numbers
    # would be wrong everywhere except at the two corners — a reader
    # would take a ridge at 100 Hz for one at 210.
    plotter.show_bounds(
        axes_ranges=(t0, t1, 0.0, 1.0, z0, z1),
        xtitle=time_label, ytitle='frequency [Hz]', ztitle=level_label,
        show_ylabels=False, grid='back', location='outer',
        use_3d_text=False, color=colors['scene_text'])
    return {'extents': (t0, t1, float(frequencies[0]),
                        float(frequencies[-1]), z0, z1),
            'points': int(grid.n_points)}

plot_scalogram_stage

plot_scalogram_stage(history: Any, channel: int = 0, *, low: float | None = None, high: float | None = None, per_octave: int | None = None, omega0: float | None = None, unit_system: Any = None, screenshot: str | None = None, theme: Any = None, show: bool = True) -> Any

One channel's scalogram as a surface, as the app's wavelet view first shows it, shown interactively or rendered to screenshot. The settings (low, high, per_octave, omega0) are those of plot.plot_scalogram and are read the same way (core.wavelet.reading). Returns the image array when rendering to a file, and otherwise the plotter.

from visualdynamics.viz.scalogram import plot_scalogram_stage
plot_scalogram_stage(history, screenshot='scalogram-3d.png')
Source code in src/visualdynamics/viz/scalogram.py
def plot_scalogram_stage(history: Any, channel: int = 0, *,
                         low: float | None = None,
                         high: float | None = None,
                         per_octave: int | None = None,
                         omega0: float | None = None,
                         unit_system: Any = None,
                         screenshot: str | None = None,
                         theme: Any = None, show: bool = True) -> Any:
    """One `channel`'s scalogram as a surface, as the app's wavelet view
    first shows it, shown interactively or rendered to `screenshot`.
    The settings (`low`, `high`, `per_octave`, `omega0`) are those of
    `plot.plot_scalogram` and are read the same way
    (`core.wavelet.reading`). Returns the image array when rendering to
    a file, and otherwise the plotter.

        from visualdynamics.viz.scalogram import plot_scalogram_stage
        plot_scalogram_stage(history, screenshot='scalogram-3d.png')
    """
    import pyvista as pv

    from ..units import DEFAULT_SYSTEM
    from .waterfall import finish_scene, place_camera

    clock, magnitude, frequencies, width = wavelet.reading(
        history, channel, low=low, high=high, per_octave=per_octave,
        omega0=omega0)
    us = unit_system or DEFAULT_SYSTEM
    units = history.ordinate_unit[channel] or ''
    colors = resolve_theme(theme)
    plotter = pv.Plotter(off_screen=screenshot is not None or not show)
    plotter.set_background(colors['scene_background'],
                           top=colors['scene_background_top'])
    add_scalogram(plotter, magnitude, clock, frequencies, theme=theme,
                  omega0=width,
                  time_label=f'time [{us.label_html("time")}]',
                  level_label=f'{history.ordinate_dim[channel]}'
                              + (f' [{units}]' if units else ''))
    place_camera(plotter)
    return finish_scene(plotter, screenshot, show)