Skip to content

visualdynamics.core.rms_check

rms_check

The rms check: the rms a spectrum carries beside the rms of the record it was computed from.

The area under an autospectral density is the mean square of the record it came from (Parseval's theorem), so the square root of the sum of its lines, each times the line spacing, should come back to the rms of the time history. It is the cheapest check there is on a spectral computation, and it catches the scaling errors no plot shows: the amplitude correction of a window applied to a density (the window's noise bandwidth too high, 1.76 dB on a Hann density), the one-sided doubling left out or applied twice (3.01 dB either way), and one side in g where the other is in m/s² (19.83 dB). A spectrum that cannot give back the rms of its own record is wrong, whatever its plot looks like.

Two time-history numbers are read, because a window makes the check an estimate rather than an identity. With no window and frames laid end to end every sample is used once at the same weight and the two rms agree to rounding. A tapering window weights the middle of each frame more than its ends, so an averaged, windowed spectrum is an estimate of the record's mean square, and on a long random record it lands within a few hundredths of a decibel of it. So the check reads, beside the record's own rms over the span the frames cover (RmsCheck.time_rms), the mean square as the frames themselves see it: each frame leveled as the averaging levels it, windowed, its mean square taken with the sum of the window's squares, and the frames averaged (RmsCheck.framed_rms). A correctly scaled spectrum computed with that averaging carries exactly that, to rounding, whatever the window; so when the gap to the record is over the threshold and the gap to the frames is not, the window's weighting is the whole of it and the scaling is right.

The gap is the spectrum's rms over the record's, in decibels, positive when the spectrum reads high, and it is judged against a threshold that is an argument. Its default is a few hundredths of a decibel: wide enough for a windowed average of a long random record, and an order below the smallest scaling error the check exists to catch.

References

The check is Parseval's theorem applied to the averaged periodogram; the window's noise bandwidth is what names the commonest error.

  1. Welch, P. D. (1967). "The Use of Fast Fourier Transform for the Estimation of Power Spectra: A Method Based on Time Averaging Over Short, Modified Periodograms." IEEE Transactions on Audio and Electroacoustics, 15(2), 70-73. doi:10.1109/TAU.1967.1161901 The averaged, windowed periodogram, scaled by the sum of the window's squares, whose area is the frames' mean square.
  2. Harris, F. J. (1978). "On the Use of Windows for Harmonic Analysis with the Discrete Fourier Transform." Proceedings of the IEEE, 66(1), 51-83. doi:10.1109/PROC.1978.10837 The equivalent noise bandwidth, the factor between a window's amplitude correction and its energy correction.

Classes:

Name Description
RmsCheck

One record's rms check: the rms from the spectrum beside the rms

Functions:

Name Description
time_rms

The rms of a record's samples: the square root of their mean

rms_check

Check an autospectrum against the record it came from: its rms

matching_records

The records of history that are the channel a spectrum's record

refusal

Why record record of spectrum cannot be rms-checked, in words,

check_record

The rms check of record record of spectrum against the channel

Classes

RmsCheck dataclass

RmsCheck(dof: str, spectrum_rms: float, time_rms: float, framed_rms: float | None, threshold_db: float, lines: int, spacing: float, samples: int, window: str | None = None, noise_bandwidth: float | None = None, span: tuple[float, float] | None = None, notes: tuple[str, ...] = (), coverage: str | None = None)

One record's rms check: the rms from the spectrum beside the rms of the time history, and the gap between them in dB.

The rms are in the record's SI unit, as the data is stored (m/s² for an acceleration). gap_db is 20 log10 of the spectrum's rms over the record's, positive when the spectrum reads high; NaN when either is not a positive number. framed_rms is the mean square as the averaging's frames see it, square-rooted, when the averaging is known and has the spectrum's line spacing; None otherwise.

Methods:

Name Description
describe

One line for a status bar: both rms, the gap and the verdict.

Attributes:

Name Type Description
gap_db float

The spectrum's rms over the record's, in dB: positive when the

framed_gap_db float | None

The spectrum's rms over the frames' own, in dB; None when the

window_gap_db float | None

The frames' rms over the record's, in dB: how much of the gap

passed bool

Whether the gap is within the threshold. False when there is

explained bool

Whether a gap over the threshold is the window's alone: the

cause str | None

The scaling error a failed gap matches, in words, or None.

unjudged str | None

Why there is no gap to judge, in words, or None when there is

verdict str

The judgment in words: agree, the window's gap, a named

Attributes
gap_db property
gap_db: float

The spectrum's rms over the record's, in dB: positive when the spectrum reads high. NaN when either is not a positive number.

framed_gap_db property
framed_gap_db: float | None

The spectrum's rms over the frames' own, in dB; None when the frames are not known. Near zero for a correctly scaled spectrum of those frames, whatever the window.

window_gap_db property
window_gap_db: float | None

The frames' rms over the record's, in dB: how much of the gap the window's weighting of the samples accounts for. None when the frames are not known.

passed property
passed: bool

Whether the gap is within the threshold. False when there is no gap to judge (a record of zeros, a spectrum with no lines).

explained property
explained: bool

Whether a gap over the threshold is the window's alone: the spectrum carries the frames' own mean square, so its scaling is right and the frames weighted the record unevenly.

cause property
cause: str | None

The scaling error a failed gap matches, in words, or None.

Named only when the gap sits within CAUSE_DB of one and the window is not the whole of it: a window's amplitude correction applied to a density (the window's noise bandwidth, so only when the window is known and tapers), the one-sided doubling left out or applied twice, and a density in g²/Hz read as (m/s²)²/Hz or the other way.

unjudged property
unjudged: str | None

Why there is no gap to judge, in words, or None when there is one: the record or the spectrum carries no finite mean square, or carries zero.

verdict property
verdict: str

The judgment in words: agree, the window's gap, a named scaling error, a gap to look for one behind, or no gap to judge and why.

Methods:
describe
describe(unit: str = '', scale: float = 1.0) -> str

One line for a status bar: both rms, the gap and the verdict. scale converts the rms from SI into unit.

Source code in src/visualdynamics/core/rms_check.py
def describe(self, unit: str = '', scale: float = 1.0) -> str:
    """One line for a status bar: both rms, the gap and the verdict.
    `scale` converts the rms from SI into `unit`."""
    def rms(value: float) -> str:
        return f'{value * scale:.4g} {unit} rms'.replace('  ', ' ')

    gap = self.gap_db
    said = f'{gap:+.3f} dB' if np.isfinite(gap) else 'no gap'
    return (f'{rms(self.spectrum_rms)} from the spectrum, '
            f'{rms(self.time_rms)} from the time history, {said}: '
            f'{self.verdict}')

Functions:

time_rms

time_rms(samples: ArrayLike | Sequence[ArrayLike]) -> float

The rms of a record's samples: the square root of their mean square, with nothing removed.

samples is one array, or several (the captures of one channel), pooled sample for sample, so a long capture weighs more than a short one, as it does in the average. A sample that is not a finite number is left out, the rule core.time_statistics reads a record by, so the two give one rms for one record; rms_check counts them in its notes. NaN when no sample is finite.

Source code in src/visualdynamics/core/rms_check.py
def time_rms(samples: ArrayLike | Sequence[ArrayLike]) -> float:
    """The rms of a record's samples: the square root of their mean
    square, with nothing removed.

    `samples` is one array, or several (the captures of one channel),
    pooled sample for sample, so a long capture weighs more than a
    short one, as it does in the average. A sample that is not a finite
    number is left out, the rule `core.time_statistics` reads a record
    by, so the two give one rms for one record; `rms_check` counts them
    in its notes. NaN when no sample is finite.
    """
    finite, _dropped = _finite(samples)
    if finite.size == 0:
        return float('nan')
    return float(np.sqrt(np.mean(finite ** 2)))

rms_check

rms_check(frequencies: ArrayLike, values: ArrayLike, samples: ArrayLike | Sequence[ArrayLike], *, threshold_db: float = GAP_DB, framed_rms: float | None = None, window: Any = None, dof: str = '') -> RmsCheck

Check an autospectrum against the record it came from: its rms from the area under it beside the record's own.

The spectrum's rms is compliance.rms: every line times its bin's width, summed and square-rooted, which on evenly spaced lines is the sum of the lines times the spacing, exactly Parseval's total. The record's is time_rms of samples, which should be the span the spectrum's frames covered.

Parameters:

Name Type Description Default
frequencies array_like

The spectrum's lines, in Hz, evenly spaced.

required
values array_like

The one-sided density at each line, in the square of the record's unit per hertz.

required
samples array_like or sequence of array_like

The record over the span the frames covered, or the captures of one channel, pooled.

required
threshold_db float

The gap, in dB either way, beyond which the two disagree.

`GAP_DB`
framed_rms float

The frames' own mean square, windowed and leveled as they were averaged, square-rooted, when the averaging is known (check_record works it out); it tells a window's gap from a scaling error.

None
window str or Averaging

The window the spectrum was averaged with, which names the amplitude-correction error by its noise bandwidth.

None
dof str

The channel's name, carried on the result.

''

Returns:

Type Description
RmsCheck

Both rms, in the record's unit, the gap and the verdict.

Raises:

Type Description
ValueError

For a negative threshold, a spectrum on uneven lines or with fewer than two, or a record with no samples.

Notes

Lines that are not finite or are below zero are left out of the area (compliance.rms), and samples that are not finite out of the record's rms (time_rms); the result's notes count both.

Source code in src/visualdynamics/core/rms_check.py
def rms_check(frequencies: ArrayLike, values: ArrayLike,
              samples: ArrayLike | Sequence[ArrayLike], *,
              threshold_db: float = GAP_DB,
              framed_rms: float | None = None,
              window: Any = None, dof: str = '') -> RmsCheck:
    """Check an autospectrum against the record it came from: its rms
    from the area under it beside the record's own.

    The spectrum's rms is `compliance.rms`: every line times its bin's
    width, summed and square-rooted, which on evenly spaced lines is
    the sum of the lines times the spacing, exactly Parseval's total.
    The record's is `time_rms` of `samples`, which should be the span
    the spectrum's frames covered.

    Parameters
    ----------
    frequencies : array_like
        The spectrum's lines, in Hz, evenly spaced.
    values : array_like
        The one-sided density at each line, in the square of the
        record's unit per hertz.
    samples : array_like or sequence of array_like
        The record over the span the frames covered, or the captures of
        one channel, pooled.
    threshold_db : float, default `GAP_DB`
        The gap, in dB either way, beyond which the two disagree.
    framed_rms : float, optional
        The frames' own mean square, windowed and leveled as they were
        averaged, square-rooted, when the averaging is known
        (`check_record` works it out); it tells a window's gap from a
        scaling error.
    window : str or Averaging, optional
        The window the spectrum was averaged with, which names the
        amplitude-correction error by its noise bandwidth.
    dof : str, default ''
        The channel's name, carried on the result.

    Returns
    -------
    RmsCheck
        Both rms, in the record's unit, the gap and the verdict.

    Raises
    ------
    ValueError
        For a negative threshold, a spectrum on uneven lines or with
        fewer than two, or a record with no samples.

    Notes
    -----
    Lines that are not finite or are below zero are left out of the
    area (`compliance.rms`), and samples that are not finite out of the
    record's rms (`time_rms`); the result's notes count both.
    """
    from .compliance import rms as area_rms
    from .tone_readout import line_spacing, noise_bandwidth

    if not threshold_db >= 0.0:
        raise ValueError('the threshold is a gap in dB, zero or more')
    f = np.asarray(frequencies, dtype=float)
    if f.size < 2:
        raise ValueError('a spectrum needs two lines or more to have an '
                         'area')
    spacing = line_spacing(f)
    name = bandwidth = None
    if window is not None:
        from .averaging import Averaging, normalize_window
        name = (window.window if isinstance(window, Averaging)
                else normalize_window(window))
        bandwidth = float(noise_bandwidth(window))
    pooled = _pieces(samples)
    count = int(sum(piece.size for piece in pooled))
    notes = []
    density = np.asarray(np.real(values), dtype=float)
    with np.errstate(invalid='ignore'):
        unused = int(np.sum(~(np.isfinite(density) & (density >= 0.0))))
    if unused:
        notes.append(f'{_count(unused, "line")} of the spectrum '
                     f'{"is" if unused == 1 else "are"} not finite or below '
                     'zero, and left out of its area')
    _kept, dropped = _finite(pooled)
    if dropped:
        notes.append(f'{_count(dropped, "sample")} of the record '
                     f'{"is" if dropped == 1 else "are"} not finite, and '
                     'left out of its rms')
    return RmsCheck(
        dof=str(dof), spectrum_rms=area_rms(f, values),
        time_rms=time_rms(pooled), framed_rms=(
            None if framed_rms is None else float(framed_rms)),
        threshold_db=float(threshold_db), lines=int(f.size),
        spacing=float(spacing), samples=count, window=name,
        noise_bandwidth=bandwidth, notes=tuple(notes))

matching_records

matching_records(history: TimeHistory, dof: str, dimension: str, hint: str | None = None) -> list[int]

The records of history that are the channel a spectrum's record is the autospectrum of (TimeHistory.channel_key): the same DOF, measuring the quantity whose square per hertz dimension is ('acceleration' for 'acceleration**2/frequency'), and for a spectrum whose units are not declared, the channel its hint names. Every capture of the channel is returned, in order; none when there is no such channel or more than one could be meant.

Source code in src/visualdynamics/core/rms_check.py
def matching_records(history: TimeHistory, dof: str, dimension: str,
                     hint: str | None = None) -> list[int]:
    """The records of `history` that are the channel a spectrum's record
    is the autospectrum of (`TimeHistory.channel_key`): the same DOF,
    measuring the quantity whose square per hertz `dimension` is
    ('acceleration' for 'acceleration**2/frequency'), and for a spectrum
    whose units are not declared, the channel its `hint` names. Every
    capture of the channel is returned, in order; none when there is no
    such channel or more than one could be meant.
    """
    try:
        return _channel(history, dof, dimension, hint)[1]
    except ValueError:
        return []

refusal

refusal(spectrum: Psd, record: int = 0) -> str | None

Why record record of spectrum cannot be rms-checked, in words, or None when it can: an autospectrum on evenly spaced lines. A banded spectrum, one written at breakpoints and a cross spectrum are refused, since none of them has an area that is a mean square of one record.

Source code in src/visualdynamics/core/rms_check.py
def refusal(spectrum: Psd, record: int = 0) -> str | None:
    """Why record `record` of `spectrum` cannot be rms-checked, in words,
    or None when it can: an autospectrum on evenly spaced lines. A
    banded spectrum, one written at breakpoints and a cross spectrum
    are refused, since none of them has an area that is a mean square
    of one record."""
    from .data import has_phase
    from .tone_readout import line_spacing

    if not 0 <= int(record) < spectrum.num_records:
        # a negative index would read another record than the one asked
        # for, from the end, and say it was this one
        return (f'record {record} is not one of the '
                f'{spectrum.num_records} records of this spectrum')
    if spectrum.bandwidth is not None or spectrum.interpolation != 'bin':
        return ('the rms check sums the evenly spaced lines of an averaged '
                'spectrum; this one is banded or written at breakpoints')
    response, reference = spectrum.record_pair(record)
    # one DOF named twice over two quantities, force against
    # acceleration at a drive point, is a cross term too
    # (`Psd.is_autospectrum`), and a record with phase is one whatever
    # it is named, as `Psd.tone_refusal` reads them
    if (not spectrum.is_autospectrum(record)
            or has_phase(spectrum.ordinate[record])):
        return (f'record {record} ({response}/{reference}) is a cross '
                'spectrum; the rms check reads an autospectrum')
    if len(spectrum.abscissa) < 2:
        return 'a spectrum needs two lines or more to have an area'
    try:
        line_spacing(spectrum.abscissa)
    except ValueError as reason:
        return str(reason)
    return None

check_record

check_record(spectrum: Psd, history: TimeHistory, record: int = 0, *, averaging: Averaging | None = None, threshold_db: float = GAP_DB) -> RmsCheck

The rms check of record record of spectrum against the channel of history it is the autospectrum of.

The frames are the ones a PSD of history averages (TimeHistory._spectral_frame, under the averaging TimeHistory._resolved_averaging settles on), so the check cannot cut, level or window them differently, and the channel is the one those frames are grouped by (TimeHistory.channel_key), every capture of it pooled as the average pools them. The record's rms is read over the span the frames cover, from its start to the end of its last frame. A frame leveled before its window (Averaging.detrend) has its mean or line taken out, so the span is leveled the same way before its rms is taken, and the notes say so: leveled frame by frame, the frames shed a little more of the lowest lines than the span does. The frames' own mean square (RmsCheck.framed_rms) is read when the averaging has the spectrum's line spacing; an averaging with another is not the one the spectrum was made with, and the notes say that too. A spectrum whose lines do not run from 0 to half the sample rate holds only part of the record's power, and says so (RmsCheck.coverage).

Parameters:

Name Type Description Default
spectrum Psd

The autospectrum, computed or imported.

required
history TimeHistory

The record it was computed from.

required
record int

Which record of spectrum.

0
averaging Averaging

How the spectrum was averaged; history's own when omitted, or each record whole under a rectangle when it has none, as TimeHistory.compute_psds falls back.

None
threshold_db float

The gap, in dB either way, beyond which the two disagree.

`GAP_DB`

Returns:

Type Description
RmsCheck

Raises:

Type Description
ValueError

When the record cannot be checked (refusal), when history holds no such channel or more than one could be meant, or when the frames do not fit in it.

Source code in src/visualdynamics/core/rms_check.py
def check_record(spectrum: Psd, history: TimeHistory, record: int = 0, *,
                 averaging: Averaging | None = None,
                 threshold_db: float = GAP_DB) -> RmsCheck:
    """The rms check of record `record` of `spectrum` against the channel
    of `history` it is the autospectrum of.

    The frames are the ones a PSD of `history` averages
    (`TimeHistory._spectral_frame`, under the averaging
    `TimeHistory._resolved_averaging` settles on), so the check cannot
    cut, level or window them differently, and the channel is the one
    those frames are grouped by (`TimeHistory.channel_key`), every
    capture of it pooled as the average pools them. The record's rms is
    read over the span the frames cover, from its start to the end of
    its last frame. A frame leveled before its window
    (`Averaging.detrend`) has its mean or line taken out, so the span is
    leveled the same way before its rms is taken, and the notes say so:
    leveled frame by frame, the frames shed a little more of the lowest
    lines than the span does. The frames' own mean square
    (`RmsCheck.framed_rms`) is read when the averaging has the
    spectrum's line spacing; an averaging with another is not the one
    the spectrum was made with, and the notes say that too. A spectrum
    whose lines do not run from 0 to half the sample rate holds only
    part of the record's power, and says so (`RmsCheck.coverage`).

    Parameters
    ----------
    spectrum : Psd
        The autospectrum, computed or imported.
    history : TimeHistory
        The record it was computed from.
    record : int, default 0
        Which record of `spectrum`.
    averaging : Averaging, optional
        How the spectrum was averaged; `history`'s own when omitted, or
        each record whole under a rectangle when it has none, as
        `TimeHistory.compute_psds` falls back.
    threshold_db : float, default `GAP_DB`
        The gap, in dB either way, beyond which the two disagree.

    Returns
    -------
    RmsCheck

    Raises
    ------
    ValueError
        When the record cannot be checked (`refusal`), when `history`
        holds no such channel or more than one could be meant, or when
        the frames do not fit in it.
    """
    reason = refusal(spectrum, record)
    if reason is not None:
        raise ValueError(reason)
    dof = str(spectrum.response_dof[record])
    key, rows = _channel(history, dof, str(spectrum.ordinate_dim[record]),
                         spectrum.dimension_hint[record])
    used = history._resolved_averaging(averaging)
    # that channel's frames alone: the others of a long, many-channel
    # record are not read, and framing them is most of the time
    grid, _scale, groups = history._spectral_frame(used, channels={key})
    rate = history.sample_rate
    first = used.start_sample(rate)
    stop = first + used.span
    power = float(np.sum(used.shape() ** 2))
    frames = np.real(np.asarray(groups[key]))
    notes = []
    spans = [_level(np.asarray(np.real(history.ordinate[i]),
                               dtype=float)[first:stop], used.detrend)
             for i in rows]
    if used.detrend != 'none':
        notes.append(f"the frames were leveled ({used.detrend}) before "
                     "their window, so the record's span is leveled the "
                     'same way before its rms is taken')
    if len(rows) > 1:
        notes.append(f'{len(rows)} captures of the channel, pooled as the '
                     'average pools them')
    lines = float(grid[1] - grid[0])
    f = np.asarray(spectrum.abscissa, dtype=float)
    spacing = float(np.mean(np.diff(f)))
    framed_rms: float | None = float(np.sqrt(
        np.mean(np.sum(frames ** 2, axis=1)) / power))
    if abs(spacing - lines) > 1e-6 * max(spacing, lines):
        framed_rms = None
        notes.append(f"the averaging gives lines {lines:.4g} Hz apart and "
                     f'the spectrum has them {spacing:.4g} Hz apart, so it '
                     "was not averaged this way; the frames' own mean "
                     'square is not read')
    coverage = _coverage(f, rate)
    if coverage is not None:
        notes.append(f"{coverage}: the record's power outside them is not "
                     'in its area')
    check = rms_check(f, spectrum.ordinate[record], spans,
                      threshold_db=threshold_db, framed_rms=framed_rms,
                      window=used if framed_rms is not None else None,
                      dof=dof)
    origin = float(history.abscissa[0])
    span = (origin + first / rate, origin + stop / rate)
    return replace(check, span=span, coverage=coverage,
                   notes=tuple(notes) + check.notes)