Skip to content

visualdynamics.core.kurtosis

kurtosis

Kurtosis, channel by channel: how close a record is to Gaussian.

A random vibration test is specified as a spectrum, and a spectrum says nothing about the shape of the distribution that produced it. Two records with the same PSD can be Gaussian noise or a series of rare, large peaks, and the second does more fatigue damage to a test article than the first. Kurtosis is the number that separates them, and computing it per channel shows a rattling fixture or a clipping amplifier while the spectrum still looks correct.

The kurtosis here is Pearson's, not Fisher's. The Pearson kurtosis of a Gaussian is 3; Fisher's excess kurtosis subtracts 3, so a Gaussian is 0. Both are in use and they differ by exactly three, so the nominal value is defined once as NOMINAL, and every plot and label of it says which convention it is.

Within TOLERANCE of nominal a value is unremarkable, and beyond it in either direction it is worth looking at. High values are the more common concern, because they mean peaks the spectrum did not predict. Low values matter too: a record well under three has usually been clipped or is not random.

References

The fourth standardized moment is standard; these are where it and its use in vibration testing are set out.

  1. Bendat, J. S., & Piersol, A. G. (2010). Random Data: Analysis and Measurement Procedures, 4th ed. Wiley. The moments of a distribution.
  2. Joanes, D. N., & Gill, C. A. (1998). "Comparing measures of sample skewness and kurtosis." Journal of the Royal Statistical Society: Series D. The sample estimators and their bias; this reports the plain moment ratio, which is what a vibration specification means by the word.
  3. Steinwolf, A. (2006). "Shaker simulation of random vibrations with a high kurtosis value." Journal of the IEST. Why a record reading above three fatigues an article that a Gaussian one of the same spectrum does not.

Functions:

Name Description
kurtosis

The Pearson kurtosis of one record: 3 for a Gaussian.

kurtosis_of_centered

kurtosis of samples already centered on their mean, with their

frame_kurtosis

The Pearson kurtosis of each row of frames: kurtosis row by

analyzed_span

The part of a record the kurtosis is computed over, and its name.

channel_kurtosis

[(channel label, Pearson kurtosis)] for a time history.

Functions:

kurtosis

kurtosis(values: ArrayLike) -> float

The Pearson kurtosis of one record: 3 for a Gaussian.

The population (biased) estimator, m4 / m2**2, which is the value vibration-test practice quotes and the value scipy.stats.kurtosis(fisher=False, bias=True) returns; the tests compare this function against scipy. Non-finite samples are dropped first.

A record with no variance, or fewer than two finite samples, has no distribution shape to describe, so the result is NaN rather than a division by zero: a dead channel is not a Gaussian one.

Source code in src/visualdynamics/core/kurtosis.py
def kurtosis(values: ArrayLike) -> float:
    """The Pearson kurtosis of one record: 3 for a Gaussian.

    The population (biased) estimator, `m4 / m2**2`, which is the
    value vibration-test practice quotes and the value
    `scipy.stats.kurtosis(fisher=False, bias=True)` returns; the tests
    compare this function against scipy. Non-finite samples are
    dropped first.

    A record with no variance, or fewer than two finite samples, has no
    distribution shape to describe, so the result is NaN rather than a
    division by zero: a dead channel is not a Gaussian one.
    """
    x = np.asarray(values, dtype=float)
    x = x[np.isfinite(x)]
    if x.size < 2:
        return float('nan')
    centered = x - x.mean()
    return kurtosis_of_centered(centered, float(np.mean(centered ** 2)))

kurtosis_of_centered

kurtosis_of_centered(centered: ArrayLike, m2: float) -> float

kurtosis of samples already centered on their mean, with their second moment m2 already taken: the time statistics have both in hand, and centering a five-minute record twice was a fifth of its reading. NaN for no variance.

Source code in src/visualdynamics/core/kurtosis.py
def kurtosis_of_centered(centered: ArrayLike, m2: float) -> float:
    """`kurtosis` of samples already centered on their mean, with their
    second moment `m2` already taken: the time statistics have both in
    hand, and centering a five-minute record twice was a fifth of its
    reading. NaN for no variance."""
    if not m2 > 0.0:
        return float('nan')
    centered = np.asarray(centered, dtype=float)
    return float(np.mean(centered ** 4) / m2 ** 2)

frame_kurtosis

frame_kurtosis(frames: ArrayLike) -> ndarray

The Pearson kurtosis of each row of frames: kurtosis row by row, with the rows that are all finite read at once. A record's frames number in the thousands, and one call apiece was half the time the time statistics took.

Source code in src/visualdynamics/core/kurtosis.py
def frame_kurtosis(frames: ArrayLike) -> np.ndarray:
    """The Pearson kurtosis of each row of `frames`: `kurtosis` row by
    row, with the rows that are all finite read at once. A record's
    frames number in the thousands, and one call apiece was half the
    time the time statistics took."""
    frames = np.asarray(frames, dtype=float)
    result = np.full(len(frames), np.nan)
    clean = np.isfinite(frames).all(axis=1)
    centered = frames[clean] - frames[clean].mean(axis=1, keepdims=True)
    m2 = np.mean(centered ** 2, axis=1)
    m4 = np.mean(centered ** 4, axis=1)
    with np.errstate(divide='ignore', invalid='ignore'):
        result[clean] = np.where(m2 > 0.0, m4 / m2 ** 2, np.nan)
    for row in np.flatnonzero(~clean):
        result[row] = kurtosis(frames[row])
    return result

analyzed_span

analyzed_span(data: Any) -> tuple[list[tuple[int, int]], str]

The part of a record the kurtosis is computed over, and its name.

It is the same part the spectra shown with it are computed from. A record with a quiet lead-in and tail is, taken whole, a mixture of a loud distribution and a quiet one, and that mixture is not Gaussian even when the excitation is, so a kurtosis of the whole record would disagree with the PSD shown beside it.

Returns ([(first, last), ...], phrase): sample ranges, and the phrase a caption uses for them. A record with averaging frames set gives its analysis span, one with shocks found gives the shock windows (order does not matter to a distribution, so separate windows are read together), and one with neither is read whole.

Source code in src/visualdynamics/core/kurtosis.py
def analyzed_span(data: Any) -> tuple[list[tuple[int, int]], str]:
    """The part of a record the kurtosis is computed over, and its name.

    It is the same part the spectra shown with it are computed from.
    A record with a quiet lead-in and tail is, taken whole, a mixture
    of a loud distribution and a quiet one, and that mixture is not
    Gaussian even when the excitation is, so a kurtosis of the whole
    record would disagree with the PSD shown beside it.

    Returns ``([(first, last), ...], phrase)``: sample ranges, and the
    phrase a caption uses for them. A record with averaging frames set
    gives its analysis span, one with shocks found gives the shock
    windows (order does not matter to a distribution, so separate
    windows are read together), and one with neither is read whole.
    """
    # Found on a system-ID excitation (Brandon, 2026-08-24): read whole
    # it came out at 4.23, where every steady frame of it read 2.92.
    # The arithmetic was right; only a quarter of the record is
    # analyzed, and the silent lead-in and tail made the whole record a
    # mixture. A number that disagrees with the PSD printed beside it
    # is worse than no number, so this covers what the PSD covers.
    averaging = getattr(data, 'averaging', None)
    if averaging is not None:
        try:
            rate = data.sample_rate
        except ValueError:
            rate = None       # unevenly sampled: no frames to speak of
        if rate:
            first = int(averaging.start_sample(rate))
            last = round(averaging.stop(rate) * rate)
            if last > first:
                return ([(first, last)],
                        'over the frames the spectra are averaged over')
    shocks = getattr(data, 'shocks', None)
    if shocks:
        try:
            rate = data.sample_rate
        except ValueError:
            rate = None
        if rate:
            windows = [(int(shock.start * rate), round(shock.stop * rate))
                       for shock in shocks]
            windows = [(low, high) for low, high in windows if high > low]
            if windows:
                return windows, 'over the shock windows'
    return [(0, int(np.asarray(data.ordinate).shape[1]))], 'over the record'

channel_kurtosis

channel_kurtosis(data: Any, records: Sequence[int] | None = None) -> list[tuple[str, float]]

[(channel label, Pearson kurtosis)] for a time history.

Computed over analyzed_span, the part of the record the spectra shown with it are computed from, so the two describe the same data.

Every channel goes on one chart, whatever it measures: kurtosis is dimensionless, so accelerations, forces and volts can share an axis. A channel whose record is dead gives NaN and is drawn as a gap, because a zero would look like an impossibly flat distribution.

Source code in src/visualdynamics/core/kurtosis.py
def channel_kurtosis(data: Any, records: Sequence[int] | None = None
                     ) -> list[tuple[str, float]]:
    """[(channel label, Pearson kurtosis)] for a time history.

    Computed over `analyzed_span`, the part of the record the spectra
    shown with it are computed from, so the two describe the same data.

    Every channel goes on one chart, whatever it measures: kurtosis is
    dimensionless, so accelerations, forces and volts can share an
    axis. A channel whose record is dead gives NaN and is drawn as a
    gap, because a zero would look like an impossibly flat
    distribution.
    """
    # Every channel on one chart whatever it measures (Brandon,
    # 2026-08-24): this is the one value in the package where mixing
    # quantities on an axis is correct.
    wanted = (range(data.num_records) if records is None
              else [int(i) for i in records])
    ordinate = np.asarray(data.ordinate)
    spans, _phrase = analyzed_span(data)
    return [(data.record_label(i),
             kurtosis(np.concatenate(
                 [np.real(ordinate[i][low:high]) for low, high in spans])))
            for i in wanted]