Skip to content

visualdynamics.decimate

decimate

Peak-keeping decimation: fewer points, every peak kept.

A million-sample record cannot go to a renderer point for point, and plain striding is the wrong thinning: it walks straight past the one spike that was the reason to look at the record. The peak-keeping reading cuts the samples into equal slices and keeps each slice's smallest and largest value at their own abscissa positions, so no resonance, dropout or shock thins away — the same reading pyqtgraph's peak downsampling gives the 2-D plot.

One implementation, used by the report (a document, not a scope) and by the 3-D waterfall (a scene with a point budget). The core is vectorized over whole records at once because the waterfall hands it hundreds of them: as a per-bin Python loop the same work took 10.6 s on a 320-record million-sample history, batched it is 1.3 s — both measured. numpy only.

Functions:

Name Description
peak_decimate

(x, y) thinned to about budget points that keep every extreme.

peak_decimate_rows

peak_decimate for every record of one object at once.

Functions:

peak_decimate

peak_decimate(
    x: ArrayLike, y: ArrayLike, budget: int
) -> tuple[ndarray, ndarray]

(x, y) thinned to about budget points that keep every extreme.

Real values; a caller reading complex data extracts its component first, because "the extremes" of a complex value is not a question with one answer. Input at or under the budget is returned as given.

NaN marks a gap (a specification is NaN outside its band) and never wins an extreme. A slice that is all gap keeps its first point, so the gap survives to be drawn as a gap.

Source code in src/visualdynamics/decimate.py
def peak_decimate(x: ArrayLike, y: ArrayLike,
                  budget: int) -> tuple[np.ndarray, np.ndarray]:
    """(x, y) thinned to about `budget` points that keep every extreme.

    Real values; a caller reading complex data extracts its component
    first, because "the extremes" of a complex value is not a question
    with one answer. Input at or under the budget is returned as given.

    NaN marks a gap (a specification is NaN outside its band) and never
    wins an extreme. A slice that is *all* gap keeps its first point, so
    the gap survives to be drawn as a gap.
    """
    x = np.asarray(x)
    y = np.asarray(y)
    if len(x) <= budget:
        return x, y
    keep = _extreme_indices(y[np.newaxis], budget // 2)[0]
    return x[keep], y[keep]

peak_decimate_rows

peak_decimate_rows(
    x: ArrayLike, rows: ArrayLike, budget: int
) -> list[tuple[ndarray, ndarray]]

peak_decimate for every record of one object at once.

The records share x going in but not coming out: each keeps its own extremes at their own positions. One vectorized pass over the whole (records, samples) block, which is what makes decimating a 320-record million-sample history cost tenths of a second rather than tens.

Source code in src/visualdynamics/decimate.py
def peak_decimate_rows(x: ArrayLike, rows: ArrayLike,
                       budget: int) -> list[tuple[np.ndarray, np.ndarray]]:
    """`peak_decimate` for every record of one object at once.

    The records share `x` going in but not coming out: each keeps its
    own extremes at their own positions. One vectorized pass over the
    whole (records, samples) block, which is what makes decimating a
    320-record million-sample history cost tenths of a second rather
    than tens.
    """
    x = np.asarray(x)
    rows = np.atleast_2d(np.asarray(rows))
    if rows.shape[1] <= budget:
        return [(x, rows[k]) for k in range(rows.shape[0])]
    kept = _extreme_indices(rows, budget // 2)
    return [(x[keep], rows[k][keep]) for k, keep in enumerate(kept)]