Skip to content

visualdynamics.core.sine_tracking

sine_tracking

A swept sine read the way a controller's tracking filter reads it.

core.sine.extract_sine gives the best estimate of a tone's level: every tone solved jointly, debiased, with its smoothing chosen from the data. That is the right value for a report, but it does not answer a question often asked on a test floor, why the controller's level differs from the extracted one, because the controller did not read the level that way. A controller reads the response through a tracking filter, a band-pass that follows the drive frequency, with a fixed width in Hz or a width proportional to the drive frequency. A detector then turns the filter's output into a level: the peak, the RMS or the mean absolute value (the last two reported as the peak of the sine they correspond to), or the filter's own output. This module does the same, so the readings can be plotted side by side and the difference seen directly.

A harmonic or broadband noise changes the level by an amount that depends on the detector and on the filter. Read unfiltered, a peak detector includes the harmonic's peaks and an RMS detector includes its power; read through the filter, neither includes it. The cost of the filter is time: it settles in roughly one over its bandwidth, so a narrow band lags a change in level that a wide one follows. Which reading a controller used is a setting, and the same response record gives a different level curve for each.

The record is demodulated: multiplied by the complex exponential of the drive's own phase, which moves the drive frequency to zero. A low-pass with its corner at half the bandwidth is then the band-pass about the drive, and its magnitude is the tone's amplitude. A band-pass run on the raw record would have to be centered again at every frequency of the sweep; the low-pass only changes its corner.

The filter is causal. core.filters.filtered runs forward and backward for zero phase, which is right for a record and wrong here: the reading is meant to show the lag a controller's filter has, and a zero-phase pass would spread it to both sides of a change and hide it. The filter runs forward only, starting settled on the tone's first cycle, the way a controller's filter has settled during the ramp-up before the sweep begins.

One filter is integrated through every sample. A proportional band's corner moves with the drive, so no fixed digital design fits it. The filter is the Butterworth low-pass from scipy's analog prototype (the one core.filters.design maps to a digital filter through butter), split into its first-order modes, and each mode is integrated exactly across each sample at that sample's corner, with the input held over the sample. Nothing is redesigned along the sweep. A constant input stays constant to rounding whatever the corner does, because a mode's settled state does not depend on its corner, and the demodulation's image (below) is attenuated by the filter's own response, as measured. A fixed band is the same code with a corner that does not move. Against design's bilinear filter at a fixed corner well below Nyquist, the output differs by 0.3 % RMS on white noise (the held input against the bilinear map), which is too small to change a level.

The order is four by default because of that image. Demodulating a real record moves the tone to zero and also leaves its mirror image at twice the drive frequency, which the low-pass attenuates by its response there, about (bandwidth / (4 x drive)) to the power of the order. What gets through is a ripple at twice the drive frequency. At second order it is 1.6 % at 50 % proportional and 6.2 % at 100 %, an error from the demodulation and not part of the reading; at fourth order it is 0.02 % and 0.4 %. A wide fixed band at the bottom of a sweep, with half the bandwidth near or above the drive frequency, is no longer a band about the drive, and there the reading shows the ripple.

A peak is the peak of the samples, as a sampled detector reads it. It under-reads a sine by at most a factor of cos(pi x drive / sample rate), 0.3 % at a fortieth of the sample rate. RMS and mean are read over a span of the drive's cycles (one, DETECTOR_CYCLES, by default). Over a whole number of cycles a pure sine's RMS times the square root of two, and its mean absolute value times pi/2, equal its peak exactly.

That is exact only if the span really is a whole number of cycles and the waveform between the samples is included. Read from the samples alone, neither holds when a cycle is only a few samples long: the span is rounded to whole samples, and |x| has a corner at every zero crossing that a few samples cannot locate. On a clean 2 m/s**2 tone sampled at 4096 Hz, over one cycle, RMS read 3.6 % off at 400 Hz (10 samples a cycle) and mean 9 % off at 750 Hz (5.5 samples a cycle). So both are read from the band-limited waveform: the samples around each span are upsampled until a cycle has FINE_POINTS points (scipy.signal.resample_poly) and averaged over exactly the span's phase, with its first point interpolated at the span's start. Measured the same way on a clean sweep to 780 Hz, RMS is within 0.005 % and mean within 0.08 % sampled at 4096 Hz (5.3 samples a cycle at the top), and both are within 0.09 % at 2048 Hz (2.6 samples a cycle). The reconstruction uses MARGIN samples on either side of a span, so a reading looks that far past its instant; that is interpolation, not a lag. The peak is still the peak of the samples, because that is how a sampled controller reads it.

The waveform, the band's shape and its weighting all come from the same filter. A controller reports a level; the view that explains it draws the record through the band, the band's magnitude about the drive, and the band's weighting of the record before any instant (plot.tracking_filter). All three come from here, so the plot cannot describe a different filter from the one that read the level. track_waveform runs track_sine's own band over every sample of the span, and SineTracking.response and SineTracking.weighting are the frequency and impulse responses of the same Butterworth modes the band is integrated with, at the corner a given drive frequency has, since a proportional band has a different corner at every instant.

One channel's span of the tone is held in memory at a time, with its phase, the demodulated record, the filter's output and one mode's working arrays, about a hundred bytes a sample. The extraction streams its record in chunks and this module does not, so a recording of tens of millions of samples per channel is better read with the extraction.

References

The tracking filter here is complex demodulation followed by a Butterworth low-pass from scipy's analog prototype, integrated with its input held over each sample, each worked through above; these are where reading a tone along a known frequency history, and the test this reading is used to understand, are set out.

  1. Brandt, A. (2011). Noise and Vibration Analysis: Signal Analysis and Experimental Procedures. Wiley. Order tracking: a component that follows a known frequency history, read as an amplitude and a phase that vary slowly along it.
  2. Department of Defense (2019). MIL-STD-810H, Environmental Engineering Considerations and Laboratory Tests. Method 514.8, Table 514.8-III and its note 3: the tolerances of a sinusoidal test, among them the unfiltered RMS held within 5 percent of the filtered RMS, the level a tracking filter reads.

Classes:

Name Description
SineTracking

How a tone is read: through which band, by which detector.

TrackedWaveform

One channel's span of a tone as a tracking band passes it,

Functions:

Name Description
track_sine

Read one tone through a tracking filter and detector, one level

track_waveform

One channel's span of a tone through a tracking band, at every

Classes

SineTracking dataclass

SineTracking(*, detector: str = 'filtered', proportional: float | None = None, fixed: float | None = None, order: int = ORDER, cycles: float = DETECTOR_CYCLES)

How a tone is read: through which band, by which detector.

proportional is the band's full width as a fraction of the drive frequency (0.5 is 50 %); fixed is a width in Hz. With neither, the reading is unfiltered and the detector reads the record as it is. Both at once raises ValueError, since a band is one or the other, and so does the filter's own output with no filter. Which band this is follows from which width is set (kind), the way core.filters.Filtering derives low- or high-pass from its edges, so no setting can contradict its numbers.

detector is 'filtered' (the default: the band's own output, the demodulated amplitude at the instant of the reading), 'peak', 'rms' or 'mean', the last two reported as the peak of the sine they correspond to (TO_PEAK). The three waveform detectors read over the last cycles cycles of the drive (default DETECTOR_CYCLES, more than zero); the filter's output does not use it. order is the low-pass's design order (default ORDER, at least one).

A proportional band must be narrower than twice the drive frequency (a fraction between zero and two): wider, half of it reaches below zero frequency at every point of the sweep, and the band is not about the drive anywhere. A fixed band must be wider than zero. The setting is frozen like Filtering, so it cannot change under the levels read with it.

Methods:

Name Description
bandwidth

The band's full width in Hz at drive frequency frequency.

settling

Roughly how long the band takes to follow a change of level,

response

The band's magnitude in dB at frequencies when the drive is

weighting

How much the record lags seconds before an instant counts

describe

The reading in words, such as 'rms as peak, 10 % proportional'.

Attributes:

Name Type Description
kind str

'unfiltered', 'proportional' or 'fixed', derived from which

Attributes
kind property
kind: str

'unfiltered', 'proportional' or 'fixed', derived from which width is set rather than stored beside it.

Methods:
bandwidth
bandwidth(frequency: Any) -> ndarray

The band's full width in Hz at drive frequency frequency.

Raises ValueError on an unfiltered reading, which has no band.

Parameters:

Name Type Description Default
frequency float or array - like

The drive frequency, Hz.

required

Returns:

Type Description
ndarray

The width, Hz, the shape of frequency.

Source code in src/visualdynamics/core/sine_tracking.py
def bandwidth(self, frequency: Any) -> np.ndarray:
    """The band's full width in Hz at drive frequency `frequency`.

    Raises ValueError on an unfiltered reading, which has no band.

    Parameters
    ----------
    frequency : float or array-like
        The drive frequency, Hz.

    Returns
    -------
    ndarray
        The width, Hz, the shape of `frequency`.
    """
    frequency = np.asarray(frequency, dtype=float)
    if self.proportional is not None:
        return self.proportional * frequency
    if self.fixed is not None:
        return np.full(frequency.shape, self.fixed)
    raise ValueError('an unfiltered reading has no band')
settling
settling(frequency: Any) -> ndarray

Roughly how long the band takes to follow a change of level, in seconds: one over its width.

A rule of thumb rather than a bound. On a step in level in a swept tone, the fourth-order filter reaches half of the step at about 0.9 of this time and nine tenths at about 1.3, the same at every width.

Parameters:

Name Type Description Default
frequency float or array - like

The drive frequency, Hz.

required

Returns:

Type Description
ndarray

Seconds, the shape of frequency.

Source code in src/visualdynamics/core/sine_tracking.py
def settling(self, frequency: Any) -> np.ndarray:
    """Roughly how long the band takes to follow a change of level,
    in seconds: one over its width.

    A rule of thumb rather than a bound. On a step in level in a
    swept tone, the fourth-order filter reaches half of the step at
    about 0.9 of this time and nine tenths at about 1.3, the same at
    every width.

    Parameters
    ----------
    frequency : float or array-like
        The drive frequency, Hz.

    Returns
    -------
    ndarray
        Seconds, the shape of `frequency`.
    """
    # the 0.9 and 1.3 measured on a step in a swept tone (2026-10-05)
    return 1.0 / self.bandwidth(frequency)
response
response(drive: float, frequencies: Any) -> ndarray

The band's magnitude in dB at frequencies when the drive is at drive Hz: the shape the record is read through at that instant.

The low-pass's response at the offset from the drive, which is the band-pass about the drive after demodulation. It is -3 dB at the drive plus and minus half the bandwidth, by the Butterworth's definition. The ripple from the image at twice the drive (see the module docstring) is not included, since it comes from the demodulation, not from the band's shape. This is the analog prototype's response, the one each mode is integrated from; well below the sample rate, the held input changes it by less than a plot can show.

Parameters:

Name Type Description Default
drive float

The drive frequency, Hz.

required
frequencies float or array - like

Where to read the band, Hz.

required

Returns:

Type Description
ndarray

dB, the shape of frequencies; 0 dB at the drive.

Source code in src/visualdynamics/core/sine_tracking.py
def response(self, drive: float, frequencies: Any) -> np.ndarray:
    """The band's magnitude in dB at `frequencies` when the drive is
    at `drive` Hz: the shape the record is read through at that
    instant.

    The low-pass's response at the offset from the drive, which is
    the band-pass about the drive after demodulation. It is -3 dB at
    the drive plus and minus half the bandwidth, by the Butterworth's
    definition. The ripple from the image at twice the drive (see
    the module docstring) is not included, since it comes from the
    demodulation, not from the band's shape. This is the analog
    prototype's response, the one each mode is integrated from; well
    below the sample rate, the held input changes it by less than a
    plot can show.

    Parameters
    ----------
    drive : float
        The drive frequency, Hz.
    frequencies : float or array-like
        Where to read the band, Hz.

    Returns
    -------
    ndarray
        dB, the shape of `frequencies`; 0 dB at the drive.
    """
    poles, residues = _modes(self.order)
    corner = float(self.bandwidth(float(drive))) / 2.0
    offset = (np.asarray(frequencies, dtype=float) - float(drive)) / corner
    gain = np.zeros(offset.shape, dtype=complex)
    for pole, residue in zip(poles, residues):
        gain += residue / (1j * offset - pole)
    return 20.0 * np.log10(np.maximum(np.abs(gain), 1e-300))
weighting
weighting(drive: float, lags: Any) -> ndarray

How much the record lags seconds before an instant counts in the band's output at that instant, when the drive is at drive Hz: the low-pass's impulse response, per second.

It integrates to one (a settled band reads a steady tone at its amplitude), rises from zero, peaks and has a small negative lobe, the cause of the overshoot a fourth-order filter has on a step. Its running integral is the band's step response, half way at about 0.9 of one over the bandwidth (settling). It is zero at negative lags, since the filter is causal.

Parameters:

Name Type Description Default
drive float

The drive frequency, Hz, which sets a proportional band's corner.

required
lags float or array - like

Seconds before the instant.

required

Returns:

Type Description
ndarray

Per second, the shape of lags.

Source code in src/visualdynamics/core/sine_tracking.py
def weighting(self, drive: float, lags: Any) -> np.ndarray:
    """How much the record `lags` seconds before an instant counts
    in the band's output at that instant, when the drive is at
    `drive` Hz: the low-pass's impulse response, per second.

    It integrates to one (a settled band reads a steady tone at its
    amplitude), rises from zero, peaks and has a small negative
    lobe, the cause of the overshoot a fourth-order filter has on a
    step. Its running integral is the band's step response, half way
    at about 0.9 of one over the bandwidth (`settling`). It is zero
    at negative lags, since the filter is causal.

    Parameters
    ----------
    drive : float
        The drive frequency, Hz, which sets a proportional band's
        corner.
    lags : float or array-like
        Seconds before the instant.

    Returns
    -------
    ndarray
        Per second, the shape of `lags`.
    """
    poles, residues = _modes(self.order)
    corner = np.pi * float(self.bandwidth(float(drive)))   # rad/s
    lags = np.asarray(lags, dtype=float)
    ahead = np.maximum(lags, 0.0)
    total = np.zeros(lags.shape, dtype=complex)
    for pole, residue in zip(poles, residues):
        total += residue * corner * np.exp(pole * corner * ahead)
    return np.where(lags >= 0.0, total.real, 0.0)
describe
describe() -> str

The reading in words, such as 'rms as peak, 10 % proportional'.

The legend, the levels' comments and the guide all use this wording.

Source code in src/visualdynamics/core/sine_tracking.py
def describe(self) -> str:
    """The reading in words, such as 'rms as peak, 10 % proportional'.

    The legend, the levels' comments and the guide all use this
    wording."""
    if self.proportional is not None:
        band = f'{self.proportional * 100.0:g} % proportional'
    elif self.fixed is not None:
        band = f'{self.fixed:g} Hz fixed'
    else:
        band = 'unfiltered'
    return f'{DETECTOR_WORDS[self.detector]}, {band}'

TrackedWaveform dataclass

TrackedWaveform(*, setting: SineTracking, tone: str, onset: float, dof: str, dimension: str, unit: str, time: ndarray, drive: ndarray, passed: ndarray, level: ndarray)

One channel's span of a tone as a tracking band passes it, sample by sample: what track_waveform returns.

time is the record's own clock over the span, in seconds; drive the drive frequency in Hz at each of those samples; passed the record through the band, in the record's units, which is the waveform a detector after the band reads; and level the band's output amplitude, the 'filtered' reading at every sample rather than at a reading's lines. Two of these compare equal only if they are the same object, since the arrays have no single truth value, and they are frozen like the setting they were read with.

Methods:

Name Description
instant

The first second, on the record's clock, at which the drive

Methods:
instant
instant(frequency: float) -> float

The first second, on the record's clock, at which the drive reaches frequency: where a cursor is placed to show the band as the tone passes that frequency.

Interpolated linearly between samples. Raises ValueError when the drive never reaches frequency.

Parameters:

Name Type Description Default
frequency float

Hz.

required

Returns:

Type Description
float

Seconds on the record's clock.

Source code in src/visualdynamics/core/sine_tracking.py
def instant(self, frequency: float) -> float:
    """The first second, on the record's clock, at which the drive
    reaches `frequency`: where a cursor is placed to show the band
    as the tone passes that frequency.

    Interpolated linearly between samples. Raises ValueError when
    the drive never reaches `frequency`.

    Parameters
    ----------
    frequency : float
        Hz.

    Returns
    -------
    float
        Seconds on the record's clock.
    """
    side = np.sign(self.drive - float(frequency))
    crossed = np.flatnonzero((side[:-1] != side[1:]) | (side[:-1] == 0))
    if not crossed.size:
        raise ValueError(f'the drive never passes {frequency:g} Hz; it '
                         f'runs {self.drive.min():g} to '
                         f'{self.drive.max():g} Hz')
    k = int(crossed[0])
    if side[k] == 0 or self.drive[k + 1] == self.drive[k]:
        return float(self.time[k])
    share = ((float(frequency) - self.drive[k])
             / (self.drive[k + 1] - self.drive[k]))
    return float(self.time[k] + share * (self.time[k + 1]
                                         - self.time[k]))

Functions:

track_sine

track_sine(history: Any, specification: SineSweepSpecification, settings: SineTracking | Sequence[SineTracking], *, tone: str | None = None, onset: float | None = None, channels: Sequence[str] | None = None, lines: int = LINES) -> list[SineLevel]

Read one tone through a tracking filter and detector, one level per setting.

The tone's sweep is rebuilt from the specification and placed on the recording where its sweep begins (found by matched filter, as the extraction finds it, or at onset seconds into the recording). Each channel is demodulated against the sweep's phase once, and each setting reads it through its band, if it has one, then by its detector. Every setting is read at the same instants, up to lines of them, evenly spaced from the first instant at which every detector has a whole span behind it to the end of the tone's span, so the levels overlay line for line.

Raises ValueError when no setting is given, when the recording is not evenly sampled, when a channel to be read is not in it, or when it holds less of the tone than the longest detector span.

Parameters:

Name Type Description Default
history TimeHistory

The recording, evenly sampled.

required
specification SineSweepSpecification

The sweep the drive followed.

required
settings SineTracking or sequence of SineTracking

The readings to take: usually several, the same response through different bands and detectors.

required
tone str

Which tone, by name; the specification's first by default.

None
onset float

Seconds into the recording where the tone's sweep begins. Found by matched filter when omitted.

None
channels sequence of str

The DOFs to read; the specification's control channels by default. Any channel of the recording may be named.

None
lines int

How many readings along the sweep, at most.

`LINES`

Returns:

Type Description
list of SineLevel

One per setting, in order: the level against frequency on every channel read, ascending in frequency whichever way the tone swept, in the recording's own units, each line with the second it was read (SineLevel.seconds). Each channel's comment names the tone and the reading (SineTracking.describe). The levels are real: a peak or an RMS has no phase.

Source code in src/visualdynamics/core/sine_tracking.py
def track_sine(history: Any, specification: SineSweepSpecification,
               settings: SineTracking | Sequence[SineTracking], *,
               tone: str | None = None, onset: float | None = None,
               channels: Sequence[str] | None = None,
               lines: int = LINES) -> list[SineLevel]:
    """Read one tone through a tracking filter and detector, one level
    per setting.

    The tone's sweep is rebuilt from the specification and placed on the
    recording where its sweep begins (found by matched filter, as the
    extraction finds it, or at `onset` seconds into the recording). Each
    channel is demodulated against the sweep's phase once, and each
    setting reads it through its band, if it has one, then by its
    detector. Every setting is read at the same instants, up to `lines`
    of them, evenly spaced from the first instant at which every
    detector has a whole span behind it to the end of the tone's span,
    so the levels overlay line for line.

    Raises ValueError when no setting is given, when the recording is not
    evenly sampled, when a channel to be read is not in it, or when it holds
    less of the tone than the longest detector span.

    Parameters
    ----------
    history : TimeHistory
        The recording, evenly sampled.
    specification : SineSweepSpecification
        The sweep the drive followed.
    settings : SineTracking or sequence of SineTracking
        The readings to take: usually several, the same response
        through different bands and detectors.
    tone : str, optional
        Which tone, by name; the specification's first by default.
    onset : float, optional
        Seconds into the recording where the tone's sweep begins.
        Found by matched filter when omitted.
    channels : sequence of str, optional
        The DOFs to read; the specification's control channels by
        default. Any channel of the recording may be named.
    lines : int, default `LINES`
        How many readings along the sweep, at most.

    Returns
    -------
    list of SineLevel
        One per setting, in order: the level against frequency on
        every channel read, ascending in frequency whichever way the
        tone swept, in the recording's own units, each line with the
        second it was read (`SineLevel.seconds`). Each channel's
        comment names the tone and the reading
        (`SineTracking.describe`). The levels are real: a peak or an
        RMS has no phase.
    """
    if isinstance(settings, SineTracking):
        settings = [settings]
    settings = list(settings)
    if not settings:
        raise ValueError('nothing to read: give at least one setting')
    dt = _even_steps(np.asarray(history.abscissa, dtype=float),
                      'Tracking sine levels')
    rate = 1.0 / dt
    rows = _rows(history, specification, channels)
    chosen, onset = _tone_at(history, specification, rows, tone, onset, dt)
    first, last, frequency, phase = _laid(history, chosen, onset, dt)
    # phase runs forward whichever way the frequency sweeps, so the
    # detectors find their spans by searching it
    widest = max([setting.cycles for setting in settings
                  if setting.detector != 'filtered'] or [1.0])
    begin = int(np.searchsorted(phase, phase[0] + 2.0 * np.pi * widest))
    if begin >= len(phase) - 1:
        raise ValueError(f'{chosen.name}: the recording holds less than '
                         f'{widest:g} cycles of the tone; nothing to read')
    reads = np.unique(np.linspace(begin, len(phase) - 1,
                                  int(lines)).astype(np.int64))
    order = np.argsort(frequency[reads], kind='stable')

    readings = np.zeros((len(settings), len(rows), len(reads)))
    for c, row in enumerate(rows):
        x = np.real(np.asarray(history.ordinate[row, first:last],
                               dtype=float))
        demodulated = None
        if any(s.kind != 'unfiltered' for s in settings):
            demodulated = _Demodulated(x, phase)
        for k, setting in enumerate(settings):
            if setting.kind == 'unfiltered':
                readings[k, c] = _detect(x, phase, reads, setting.detector,
                                         setting.cycles)
                continue
            amplitude = demodulated.through(setting, frequency, rate)
            if setting.detector == 'filtered':
                readings[k, c] = np.abs(amplitude[reads])
            else:
                passed = demodulated.waveform(amplitude)
                readings[k, c] = _detect(passed, phase, reads,
                                         setting.detector, setting.cycles)

    dims = [history.ordinate_dim[row] for row in rows]
    units = [history.ordinate_unit[row] for row in rows]
    dofs = [history.response_dof[row] for row in rows]
    stamped = (first + reads) * dt
    return [SineLevel(
        abscissa=frequency[reads][order],
        ordinate=readings[k][:, order],
        response_dof=dofs, ordinate_dim=dims, ordinate_unit=units,
        comment=[f'{chosen.name} at {dof}, {setting.describe()}'
                 for dof in dofs],
        tone=chosen.name, onset=onset, seconds=stamped[order])
        for k, setting in enumerate(settings)]

track_waveform

track_waveform(history: Any, specification: SineSweepSpecification, setting: SineTracking, *, tone: str | None = None, onset: float | None = None, channel: str | None = None) -> TrackedWaveform

One channel's span of a tone through a tracking band, at every sample: the waveform the band passes and its output amplitude.

The same band track_sine reads its levels through, run the same way: the tone placed on the recording where its sweep begins, demodulated against its phase, and low-passed causally from a settled start. The output is kept at every sample instead of read at lines, so the band can be drawn on the record it was applied to.

Parameters:

Name Type Description Default
history TimeHistory

The recording, evenly sampled.

required
specification SineSweepSpecification

The sweep the drive followed.

required
setting SineTracking

The band. An unfiltered setting raises ValueError, since there is no band to pass the record through.

required
tone str

Which tone; the specification's first by default.

None
onset float

Seconds into the recording where the tone's sweep begins; found by matched filter when omitted.

None
channel str

The DOF read; the specification's first control channel by default. Any channel of the recording may be named.

None

Returns:

Type Description
TrackedWaveform

The span's clock, drive frequency, passed waveform and output amplitude, in the recording's own units.

Source code in src/visualdynamics/core/sine_tracking.py
def track_waveform(history: Any, specification: SineSweepSpecification,
                   setting: SineTracking, *, tone: str | None = None,
                   onset: float | None = None,
                   channel: str | None = None) -> TrackedWaveform:
    """One channel's span of a tone through a tracking band, at every
    sample: the waveform the band passes and its output amplitude.

    The same band `track_sine` reads its levels through, run the same
    way: the tone placed on the recording where its sweep begins,
    demodulated against its phase, and low-passed causally from a
    settled start. The output is kept at every sample instead of read at
    lines, so the band can be drawn on the record it was applied to.

    Parameters
    ----------
    history : TimeHistory
        The recording, evenly sampled.
    specification : SineSweepSpecification
        The sweep the drive followed.
    setting : SineTracking
        The band. An unfiltered setting raises ValueError, since there
        is no band to pass the record through.
    tone : str, optional
        Which tone; the specification's first by default.
    onset : float, optional
        Seconds into the recording where the tone's sweep begins;
        found by matched filter when omitted.
    channel : str, optional
        The DOF read; the specification's first control channel by
        default. Any channel of the recording may be named.

    Returns
    -------
    TrackedWaveform
        The span's clock, drive frequency, passed waveform and output
        amplitude, in the recording's own units.
    """
    if setting.kind == 'unfiltered':
        raise ValueError(f'{setting.describe()} has no band to pass the '
                         'record through')
    dt = _even_steps(np.asarray(history.abscissa, dtype=float),
                      'Tracking sine levels')
    rate = 1.0 / dt
    rows = _rows(history, specification,
                 None if channel is None else [channel])[:1]
    chosen, onset = _tone_at(history, specification, rows, tone, onset, dt)
    first, last, frequency, phase = _laid(history, chosen, onset, dt)
    row = rows[0]
    x = np.real(np.asarray(history.ordinate[row, first:last], dtype=float))
    demodulated = _Demodulated(x, phase)
    amplitude = demodulated.through(setting, frequency, rate)
    return TrackedWaveform(
        setting=setting, tone=chosen.name, onset=onset,
        dof=history.response_dof[row],
        dimension=history.ordinate_dim[row],
        unit=history.ordinate_unit[row],
        time=np.asarray(history.abscissa, dtype=float)[first:last],
        drive=frequency, passed=demodulated.waveform(amplitude),
        level=np.abs(amplitude))