Skip to content

visualdynamics.core.sweep_rate

sweep_rate

Whether a sine sweep is slow enough for the modes it passes.

A mode needs about Q cycles at its own frequency to build to its full response. A sweep does not hold it there: it gives the mode a short time inside its half-power band (the band fn/Q wide around fn, where the response is within 3 dB of its peak) and moves on. When that time holds fewer than about Q of the mode's cycles, the swept response reads the peak low, places it late in the direction of the sweep, and spreads it, so a Q read from its half-power width comes out low as well. All three at once, and all three are properties of the rate, not of the article.

The arithmetic is short. On a logarithmic sweep at R octaves per minute the band, 1/(Q ln 2) octaves wide, is crossed in 60 / (ln 2 Q R) seconds, about 86.6 / (Q R), whatever the mode's frequency; the mode completes fn times as many cycles in that time. Setting those cycles equal to the Q the mode needs gives the rate at which a sweep only just lets it build:

R = (60 / ln 2) fn / Q**2,  about 87 fn / Q**2 oct/min.

A linear sweep at r Hz/s crosses the band, fn/Q Hz wide, in fn / (Q r) seconds, and the same equation gives r = fn**2 / Q**2 Hz/s. At the rule's rate a mode still reads only about 94 % of its steady height, and the Q read from its width comes out about 20 % low, so the rule is a ceiling to stay well under, not a target. That is the reason the rule here flags a rate above it rather than near it, and the reason each check also reports what a mode of that Q would actually read at that rate.

The rule is this package's criterion, worked out as above from the cycles a mode needs to build. It is not taken from a published standard; the references below are for the effect it guards against and for the simulation, not for the rule.

What a mode reads at a rate is found by simulating it: the one-mass resonator (srs.ramp_invariant, the shock spectrum's filter) driven through the sweep at the segment's rate and law, its response read once per cycle of the drive as that cycle's rms reported as the peak of a sine (the rms detector a controller uses), and the peak and the half-power width of that swept curve read the way they would be read off a plot (half_power.read_half_power). The simulation is made at a natural frequency of 1 Hz, since a sweep's effect on a mode depends on the rate only through R / fn (log) or r / fn**2 (linear), so its cost does not grow with the mode's frequency.

check_sweep_rate takes a SineSweepSpecification (or one SineTone) and a list of modes, each a natural frequency and a Q, and returns one SweepRateCheck per pass of a tone through a mode's band: the time and the cycles inside the band, the rule's rate for that mode, the rate as a multiple of it, the swept reading, and whether it is too fast. A tone swept up and back passes each mode twice and gives two rows. A mode whose band no tone reaches gives none.

swept_q and read_swept_q answer the reverse question, for a Q read off a level that was itself swept (a tracked sine level, the usual source of a Q in a sine test): whether the rate was slow enough for the Q it reads, and what Q a mode would need to read that low at that rate.

References

The effect is a standard result of the response of a linear oscillator to a tone of changing frequency, which the review below credits to Lewis (1932) and Cronin (1965); this is where it is set out. The one-mass simulation runs through core.srs's filter, which cites its source.

  1. Reed, C. C., & Kabe, A. M. (2019). "Peak Response of Single-Degree-of-Freedom Systems to Swept-Frequency Excitation." The Mathematica Journal. The response of a one-mass system to linear and octave sweeps in closed form: the faster the sweep, the lower its peak and the further the peak moves in the sweep's direction.

Classes:

Name Description
SweptReading

What a sweep at one rate makes of one mode: simulate_sweep's

SweepRateCheck

One pass of one tone through one mode's half-power band:

SweptQ

A Q read from a swept level, judged against the rate it was

Functions:

Name Description
half_power_band

The mode's half-power band, fn (1 - 1/(2Q)) to

rule_rate

The rule's rate for a mode: the sweep rate at which the time in

rate_unit

'oct/min' for a logarithmic segment, 'Hz/s' for a linear one: the

simulate_sweep

What a mode reads when a sweep passes it at rate, by

check_sweep_rate

Check a sine specification's sweep rate against a list of modes.

swept_q

Judge a Q read from a level swept at rate: was the rate slow

read_swept_q

Read the peak nearest near on a tracked sine level and judge its

Classes

SweptReading dataclass

SweptReading(frequency: float, q: float, peak_frequency: float, peak_share: float, q_read: float | None, simulated: bool = True)

What a sweep at one rate makes of one mode: simulate_sweep's result.

peak_frequency is where the swept peak sits, Hz; peak_share is its height over the mode's steady height at fn, Q sqrt(1 + 1/Q**2) (NaN where the sweep passed the mode in too few cycles to read a peak at all); q_read is the peak frequency over the swept curve's half-power width, None where the curve did not fall to 0.707 of its peak inside the simulated stretch. simulated is False where the rate was under STEADY_RATIO of the rule's and the steady curve, the transmissibility, was read the same way instead.

Attributes:

Name Type Description
under_read float

How far the swept peak is under the steady one, a fraction:

q_bias float | None

How far the Q read is from the mode's, a fraction: -0.46 for

Attributes
under_read property
under_read: float

How far the swept peak is under the steady one, a fraction: 0.19 for a peak at 81 % of its height.

q_bias property
q_bias: float | None

How far the Q read is from the mode's, a fraction: -0.46 for a Q of 40 read as 21.6. None where no Q could be read.

SweepRateCheck dataclass

SweepRateCheck(tone: str, frequency: float, q: float, segment_type: int, rate: float, start: float, seconds: float, cycles: float, crossed: bool, limit: float, reading: SweptReading, margin: float = MARGIN)

One pass of one tone through one mode's half-power band: check_sweep_rate's row.

rate is the specification's rate on the pass's fastest segment (the largest multiple of the rule's), as written (oct/min for a log segment, Hz/s for a linear one, negative sweeping down); limit is the rule's rate for the mode in the same unit. start is when the sweep enters the band, in seconds from the tone's start; seconds and cycles are how long it stays and how many of the drive's cycles that is. crossed is False where the sweep starts, ends or turns inside the band, so the time counted is not a full crossing. reading is what a mode of this Q reads at this rate (simulate_sweep).

Methods:

Name Description
describe

The check in a line, as the window's status bar gives it:

Attributes:

Name Type Description
unit str

'oct/min' or 'Hz/s', the unit of rate and limit.

direction str

'up' or 'down'.

ratio float

The rate as a multiple of the rule's rate for this mode.

too_fast bool

Whether the rate is above margin times the rule's.

peak_share float

The swept peak's height over the steady height.

q_read float | None

The Q a swept reading of this mode gives at this rate.

verdict str

'too fast' or 'builds', the word the table and the plot use.

Attributes
unit property
unit: str

'oct/min' or 'Hz/s', the unit of rate and limit.

direction property
direction: str

'up' or 'down'.

ratio property
ratio: float

The rate as a multiple of the rule's rate for this mode.

too_fast property
too_fast: bool

Whether the rate is above margin times the rule's.

peak_share property
peak_share: float

The swept peak's height over the steady height.

q_read property
q_read: float | None

The Q a swept reading of this mode gives at this rate.

verdict property
verdict: str

'too fast' or 'builds', the word the table and the plot use.

Methods:
describe
describe() -> str

The check in a line, as the window's status bar gives it: '22 Hz, Q 40, up at 4 oct/min: 0.54 s and 12 cycles in the band (40 to build), 3.3 times the rule's 1.2 oct/min; reads 81 % of the peak and a Q of 21.6; too fast'.

Source code in src/visualdynamics/core/sweep_rate.py
def describe(self) -> str:
    """The check in a line, as the window's status bar gives it:
    '22 Hz, Q 40, up at 4 oct/min: 0.54 s and 12 cycles in the
    band (40 to build), 3.3 times the rule's 1.2 oct/min; reads 81 %
    of the peak and a Q of 21.6; too fast'."""
    q_read = ('no Q' if self.q_read is None
              else f'a Q of {self.q_read:.3g}')
    peak = ('no peak' if not np.isfinite(self.peak_share)
            else f'{100.0 * self.peak_share:.0f} % of the peak')
    part = '' if self.crossed else ' (not a full crossing)'
    return (f'{self.frequency:.4g} Hz, Q {self.q:.3g}, '
            f'{self.direction} at {abs(self.rate):.3g} {self.unit}: '
            f'{self.seconds:.3g} s and {self.cycles:.3g} cycles in the '
            f'band{part} ({self.q:.3g} to build), {self.ratio:.2g} times '
            f'the rule\'s {self.limit:.3g} {self.unit}; reads '
            f'{peak} and {q_read}; {self.verdict}')

SweptQ dataclass

SweptQ(frequency: float, q_read: float, rate: float, segment_type: int, q_true: float | None, margin: float = MARGIN, height: float | None = None)

A Q read from a swept level, judged against the rate it was swept at: what swept_q and read_swept_q return.

frequency is the peak's, Hz; q_read the Q read from its width; rate and segment_type the sweep's at the peak. q_true is the Q a mode would need to read q_read at this rate, by inverting simulate_sweep; None where no Q up to Q_SEARCH times the one read reads it, which happens when the rate alone sets the width. height is the peak's height in the level's own units where it was read from a level, else None.

Methods:

Name Description
describe

The judgment in a line: 'Q 21.6 read at 22.6 Hz from a sweep

Attributes:

Name Type Description
unit str

'oct/min' or 'Hz/s'.

limit float

The rule's rate for the Q the reading implies (q_true), or

ratio float

The sweep's rate as a multiple of limit.

flagged bool

Whether the Q read cannot be taken as the mode's: no Q could

Attributes
unit property
unit: str

'oct/min' or 'Hz/s'.

limit property
limit: float

The rule's rate for the Q the reading implies (q_true), or for the Q read where no Q could be inferred.

ratio property
ratio: float

The sweep's rate as a multiple of limit.

flagged property
flagged: bool

Whether the Q read cannot be taken as the mode's: no Q could be inferred at all, the sweep was too fast for the Q it implies, or the Q it implies is more than Q_TOLERANCE above the one read. The last catches a sweep under the rule's rate that still spreads the width: at the rule's rate a peak reads about 94 % of its height but its Q reads about 20 % low.

Methods:
describe
describe() -> str

The judgment in a line: 'Q 21.6 read at 22.6 Hz from a sweep at 4 oct/min: reads low; a mode reads this low at a Q of about 40, for which the rate is 3.4 times the rule's'.

Source code in src/visualdynamics/core/sweep_rate.py
def describe(self) -> str:
    """The judgment in a line: 'Q 21.6 read at 22.6 Hz from a sweep
    at 4 oct/min: reads low; a mode reads this low at a Q of about
    40, for which the rate is 3.4 times the rule's'."""
    head = (f'Q {self.q_read:.3g} read at {self.frequency:.4g} Hz from '
            f'a sweep at {abs(self.rate):.3g} {self.unit}')
    if self.q_true is None:
        return (f'{head}: too fast to read any Q; the rate sets the '
                'width, and the mode\'s Q is higher than any this '
                'reading can tell apart')
    if not self.flagged:
        return f'{head}: slow enough for it'
    return (f'{head}: reads low; a mode reads this low at a Q of '
            f'about {self.q_true:.3g}, for which the rate is '
            f'{self.ratio:.2g} times the rule\'s')

Functions:

half_power_band

half_power_band(frequency: float, q: float) -> tuple[float, float]

The mode's half-power band, fn (1 - 1/(2Q)) to fn (1 + 1/(2Q)) in Hz: fn/Q wide, centered on the mode.

The band the sweep's time and cycles are counted in. It is the width the mode's Q defines, not the crossings of any measured curve, which a fast sweep moves.

Parameters:

Name Type Description Default
frequency float

The natural frequency, Hz.

required
q float

The mode's Q, 1/(2 zeta).

required

Returns:

Type Description
tuple of float

(lower, upper) in Hz.

Source code in src/visualdynamics/core/sweep_rate.py
def half_power_band(frequency: float, q: float) -> tuple[float, float]:
    """The mode's half-power band, ``fn (1 - 1/(2Q))`` to
    ``fn (1 + 1/(2Q))`` in Hz: ``fn/Q`` wide, centered on the mode.

    The band the sweep's time and cycles are counted in. It is the
    width the mode's Q defines, not the crossings of any measured
    curve, which a fast sweep moves.

    Parameters
    ----------
    frequency : float
        The natural frequency, Hz.
    q : float
        The mode's Q, ``1/(2 zeta)``.

    Returns
    -------
    tuple of float
        (lower, upper) in Hz.
    """
    frequency, q = _positive(frequency, 'a frequency'), _q(q)
    half = frequency / (2.0 * q)
    return frequency - half, frequency + half

rule_rate

rule_rate(frequency: float, q: float, segment_type: int = LOG) -> float

The rule's rate for a mode: the sweep rate at which the time in its half-power band holds just the Q cycles it needs to build.

(60 / ln 2) fn / Q**2 oct/min for a logarithmic sweep (about 87 fn / Q**2), and fn**2 / Q**2 Hz/s for a linear one. This is the package's own criterion (see the module's notes). A sweep should run well below it: at this rate a mode reads about 94 % of its steady height and a Q about 20 % low.

Parameters:

Name Type Description Default
frequency float

The natural frequency, Hz.

required
q float

The mode's Q.

required
segment_type int

sine.LOG or sine.LINEAR: which unit the rate is given in.

LOG

Returns:

Type Description
float

oct/min for LOG, Hz/s for LINEAR; always positive.

Source code in src/visualdynamics/core/sweep_rate.py
def rule_rate(frequency: float, q: float,
              segment_type: int = LOG) -> float:
    """The rule's rate for a mode: the sweep rate at which the time in
    its half-power band holds just the Q cycles it needs to build.

    ``(60 / ln 2) fn / Q**2`` oct/min for a logarithmic sweep (about
    ``87 fn / Q**2``), and ``fn**2 / Q**2`` Hz/s for a linear one. This
    is the package's own criterion (see the module's notes). A sweep
    should run well below it: at this rate a mode reads about 94 % of
    its steady height and a Q about 20 % low.

    Parameters
    ----------
    frequency : float
        The natural frequency, Hz.
    q : float
        The mode's Q.
    segment_type : int
        `sine.LOG` or `sine.LINEAR`: which unit the rate is given in.

    Returns
    -------
    float
        oct/min for LOG, Hz/s for LINEAR; always positive.
    """
    frequency, q = _positive(frequency, 'a frequency'), _q(q)
    if int(segment_type) == LINEAR:
        return frequency ** 2 / q ** 2
    return RULE_CONSTANT * frequency / q ** 2

rate_unit

rate_unit(segment_type: int) -> str

'oct/min' for a logarithmic segment, 'Hz/s' for a linear one: the units the specification writes each rate in.

Source code in src/visualdynamics/core/sweep_rate.py
def rate_unit(segment_type: int) -> str:
    """'oct/min' for a logarithmic segment, 'Hz/s' for a linear one: the
    units the specification writes each rate in."""
    return 'Hz/s' if int(segment_type) == LINEAR else 'oct/min'

simulate_sweep

simulate_sweep(frequency: float, q: float, rate: float, segment_type: int = LOG) -> SweptReading

What a mode reads when a sweep passes it at rate, by simulation of the one-mass resonator.

The resonator at frequency with q is driven from rest by a unit tone swept through its band at the constant rate and law (segment_type); a negative rate sweeps down. Its response is read once per cycle of the drive, each cycle's rms reported as the peak of a sine, and the swept curve's peak and half-power width are read from those readings. A 22 Hz, Q 40 mode swept up at 1, 4 and 8 oct/min reads 95, 81 and 71 % of its steady height, with a Q of about 33, 22 and 17.

Parameters:

Name Type Description Default
frequency float

The natural frequency, Hz.

required
q float

The mode's Q.

required
rate float

oct/min for a LOG sweep, Hz/s for a LINEAR one; negative sweeps down.

required
segment_type int

sine.LOG or sine.LINEAR.

LOG

Returns:

Type Description
SweptReading

The peak's frequency, its height as a share of the steady height, and the Q read from its width.

Source code in src/visualdynamics/core/sweep_rate.py
def simulate_sweep(frequency: float, q: float, rate: float,
                   segment_type: int = LOG) -> SweptReading:
    """What a mode reads when a sweep passes it at `rate`, by
    simulation of the one-mass resonator.

    The resonator at `frequency` with `q` is driven from rest by a unit
    tone swept through its band at the constant `rate` and law
    (`segment_type`); a negative rate sweeps down. Its response is read
    once per cycle of the drive, each cycle's rms reported as the peak
    of a sine, and the swept curve's peak and half-power width are read
    from those readings. A 22 Hz, Q 40 mode swept up at 1, 4 and
    8 oct/min reads 95, 81 and 71 % of its steady height, with a Q of
    about 33, 22 and 17.

    Parameters
    ----------
    frequency : float
        The natural frequency, Hz.
    q : float
        The mode's Q.
    rate : float
        oct/min for a LOG sweep, Hz/s for a LINEAR one; negative sweeps
        down.
    segment_type : int
        `sine.LOG` or `sine.LINEAR`.

    Returns
    -------
    SweptReading
        The peak's frequency, its height as a share of the steady
        height, and the Q read from its width.
    """
    frequency, q = _positive(frequency, 'a frequency'), _q(q)
    rate = float(rate)
    if not np.isfinite(rate) or rate == 0.0:
        raise ValueError(f'a sweep rate must be a nonzero number; got {rate}')
    linear = int(segment_type) == LINEAR
    normalized = abs(rate) / (frequency ** 2 if linear else frequency)
    # rounded so a rate typed twice is one cache entry, far finer than
    # anything the reading can tell apart
    top, share, q_read, simulated = _simulated(
        round(q, 9), float(f'{normalized:.9g}'),
        LINEAR if linear else LOG, rate < 0.0)
    return SweptReading(frequency=frequency, q=q,
                        peak_frequency=top * frequency, peak_share=share,
                        q_read=q_read, simulated=simulated)

check_sweep_rate

check_sweep_rate(specification: SineSweepSpecification | SineTone, modes: Iterable[tuple[float, float]] | Any, *, tone: str | None = None, margin: float = MARGIN) -> list[SweepRateCheck]

Check a sine specification's sweep rate against a list of modes.

For every tone (or the one named) and every mode, each pass of the sweep through the mode's half-power band (half_power_band) gives a row: when it enters, how long it stays, how many of the drive's cycles that is against the Q the mode needs, the rule's rate for the mode (rule_rate) and the specification's as a multiple of it, what a mode of that Q reads at that rate (simulate_sweep), and whether the rate is too fast. A band that straddles a breakpoint is one pass, counted across both segments, and judged on the faster of them: the one whose rate is the larger multiple of the rule's.

checks = check_sweep_rate(spec, [(22.0, 40.0), (180.0, 20.0)])
for check in checks:
    print(check.describe())

Parameters:

Name Type Description Default
specification SineSweepSpecification or SineTone

The sweep.

required
modes iterable of (frequency, Q), or ShapeSet

Natural frequencies in Hz with their Q; a ShapeSet gives its modes with Q from their damping.

required
tone str

Check only the tone of this name.

None
margin float

The multiple of the rule's rate above which a pass is flagged too fast; 1 by default, where a mode reads about 94 %.

MARGIN

Returns:

Type Description
list of SweepRateCheck

In tone order, then mode order, then pass order. A mode whose band the sweep never reaches has no row.

Source code in src/visualdynamics/core/sweep_rate.py
def check_sweep_rate(specification: SineSweepSpecification | SineTone,
                     modes: Iterable[tuple[float, float]] | Any, *,
                     tone: str | None = None,
                     margin: float = MARGIN) -> list[SweepRateCheck]:
    """Check a sine specification's sweep rate against a list of modes.

    For every tone (or the one named) and every mode, each pass of the
    sweep through the mode's half-power band (`half_power_band`) gives
    a row: when it enters, how long it stays, how many of the drive's
    cycles that is against the Q the mode needs, the rule's rate for
    the mode (`rule_rate`) and the specification's as a multiple of it,
    what a mode of that Q reads at that rate (`simulate_sweep`), and
    whether the rate is too fast. A band that straddles a breakpoint is
    one pass, counted across both segments, and judged on the faster of
    them: the one whose rate is the larger multiple of the rule's.

        checks = check_sweep_rate(spec, [(22.0, 40.0), (180.0, 20.0)])
        for check in checks:
            print(check.describe())

    Parameters
    ----------
    specification : SineSweepSpecification or SineTone
        The sweep.
    modes : iterable of (frequency, Q), or ShapeSet
        Natural frequencies in Hz with their Q; a `ShapeSet` gives its
        modes with Q from their damping.
    tone : str, optional
        Check only the tone of this name.
    margin : float
        The multiple of the rule's rate above which a pass is flagged
        too fast; 1 by default, where a mode reads about 94 %.

    Returns
    -------
    list of SweepRateCheck
        In tone order, then mode order, then pass order. A mode whose
        band the sweep never reaches has no row.
    """
    margin = _positive(margin, 'the margin')
    checks = []
    for each in _tones(specification, tone):
        for frequency, q in _modes(modes):
            low, high = half_power_band(frequency, q)
            for entry, leave, f_in, f_out, segments in _passages(
                    each, low, high):
                segment = _fastest(segments, frequency, q)
                kind, rate = segment[5], segment[6]
                cycles = float(np.diff(each.phase_at([entry, leave]))[0]
                               / (2.0 * np.pi))
                crossed = {f_in, f_out} == {low, high}
                checks.append(SweepRateCheck(
                    tone=each.name, frequency=frequency, q=q,
                    segment_type=kind, rate=rate, start=entry,
                    seconds=leave - entry, cycles=cycles, crossed=crossed,
                    limit=rule_rate(frequency, q, kind),
                    reading=simulate_sweep(frequency, q, rate, kind),
                    margin=margin))
    return checks

swept_q

swept_q(frequency: float, q_read: float, rate: float, segment_type: int = LOG, *, margin: float = MARGIN, height: float | None = None) -> SweptQ

Judge a Q read from a level swept at rate: was the rate slow enough for it, and what Q would read that low?

A swept reading's Q comes out low (high only by the fraction of a percent its per-cycle readings scatter by), so the Q read is in effect a lower bound on the mode's and the rule's rate worked out from it an upper bound. The Q a mode needs to read q_read at this rate is found on simulate_sweep: the swept Q rises with the true Q towards a ceiling the rate sets, so past that ceiling no Q reads that low and none is returned. The reading is flagged when the rate is above margin times the rule's rate for that Q, or when that Q is more than Q_TOLERANCE above the one read.

Parameters:

Name Type Description Default
frequency float

The peak's frequency, Hz.

required
q_read float

The Q read from the swept level's half-power width.

required
rate float

The sweep's rate at the peak, oct/min (LOG) or Hz/s (LINEAR).

required
segment_type int

sine.LOG or sine.LINEAR.

LOG
margin float

As for check_sweep_rate.

MARGIN
height float

The peak's height, carried through for display.

None

Returns:

Type Description
SweptQ

The reading, the Q it implies (None where none can be inferred), and the flag.

Source code in src/visualdynamics/core/sweep_rate.py
def swept_q(frequency: float, q_read: float, rate: float,
            segment_type: int = LOG, *, margin: float = MARGIN,
            height: float | None = None) -> SweptQ:
    """Judge a Q read from a level swept at `rate`: was the rate slow
    enough for it, and what Q would read that low?

    A swept reading's Q comes out low (high only by the fraction of a
    percent its per-cycle readings scatter by), so the Q read is in
    effect a lower bound on the mode's and the rule's rate worked out
    from it an upper bound. The Q a mode needs to read `q_read` at this
    rate is found on `simulate_sweep`: the swept Q rises with the true Q
    towards a ceiling the rate sets, so past that ceiling no Q reads
    that low and none is returned. The reading is flagged when the rate
    is above `margin` times the rule's rate for that Q, or when that Q
    is more than `Q_TOLERANCE` above the one read.

    Parameters
    ----------
    frequency : float
        The peak's frequency, Hz.
    q_read : float
        The Q read from the swept level's half-power width.
    rate : float
        The sweep's rate at the peak, oct/min (LOG) or Hz/s (LINEAR).
    segment_type : int
        `sine.LOG` or `sine.LINEAR`.
    margin : float
        As for `check_sweep_rate`.
    height : float, optional
        The peak's height, carried through for display.

    Returns
    -------
    SweptQ
        The reading, the Q it implies (None where none can be
        inferred), and the flag.
    """
    frequency, q_read = _positive(frequency, 'a frequency'), _q(q_read)
    margin = _positive(margin, 'the margin')
    kind = LINEAR if int(segment_type) == LINEAR else LOG

    def reads(q):
        found = simulate_sweep(frequency, q, rate, kind).q_read
        return -np.inf if found is None else found

    q_true = None
    if reads(q_read) >= q_read:
        q_true = q_read                  # read at a rate the Q reads true at
    else:
        # The swept Q rises with the true one to a ceiling the rate sets
        # and then falls, the ringing of a very sharp mode beating with
        # the tone that has moved on (at 4 oct/min a 22 Hz mode reads
        # 21.6 at Q 40, 26.3 at 200 and 10.4 at 1000). So the rising
        # branch is walked up in steps until it passes the Q read,
        # and given up on where it turns over first.
        low, last = q_read, reads(q_read)
        high = low * Q_STEP
        while high <= q_read * Q_SEARCH:
            found = reads(high)
            if found >= q_read:
                break
            if found < last:
                high = None              # turned over below the Q read
                break
            low, last, high = high, found, high * Q_STEP
        else:
            high = None
        if high is not None:
            # bisection in log Q inside the step that passed it
            while high / low > 1.0 + 1e-4:
                middle = np.sqrt(low * high)
                if reads(middle) < q_read:
                    low = middle
                else:
                    high = middle
            q_true = float(np.sqrt(low * high))
    return SweptQ(frequency=frequency, q_read=q_read, rate=float(rate),
                  segment_type=kind, q_true=q_true, margin=margin,
                  height=None if height is None else float(height))

read_swept_q

read_swept_q(level: Any, specification: SineSweepSpecification | SineTone, near: float, *, record: int = 0, within: float = 0.2, margin: float = MARGIN) -> SweptQ | None

Read the peak nearest near on a tracked sine level and judge its Q against the rate the level was swept at.

The level's readings are put back in sweep order by their clock (level.seconds), and lines under the noise floor (level.resolved) are not read. The peak is the tallest reading within within (a fraction) of near, read on the one pass of the sweep that holds it (half_power.read_half_power): its top placed between readings and its half-power crossings found by walking out along that pass. The rate is the one on the segment the tone was sweeping when that reading was made. Returns None where the window holds no peak (no readings, or the tallest at its edge, the flank of a peak elsewhere) or a crossing is missing, since then there is no Q to judge; and for a level kept without its clock, read off a tone that sweeps both ways, whose two passes cannot be told apart.

Parameters:

Name Type Description Default
level SineLevel

A tracked or extracted level.

required
specification SineSweepSpecification or SineTone

The sweep the level was read from. A specification's tone is the level's own (level.tone); a KeyError says when it holds no tone of that name.

required
near float

Hz, the mode looked for.

required
record int

The level's record (channel) to read.

0
within float

The window around near, as a fraction of it.

0.2
margin float

As for check_sweep_rate.

MARGIN

Returns:

Type Description
SweptQ or None

The judgment, with the peak's height in the level's units.

Source code in src/visualdynamics/core/sweep_rate.py
def read_swept_q(level: Any, specification: SineSweepSpecification | SineTone,
                 near: float, *, record: int = 0, within: float = 0.2,
                 margin: float = MARGIN) -> SweptQ | None:
    """Read the peak nearest `near` on a tracked sine level and judge its
    Q against the rate the level was swept at.

    The level's readings are put back in sweep order by their clock
    (`level.seconds`), and lines under the noise floor (`level.resolved`)
    are not read. The peak is the tallest reading within `within` (a
    fraction) of `near`, read on the one pass of the sweep that holds
    it (`half_power.read_half_power`): its top placed between readings
    and its half-power crossings found by walking out along that pass.
    The rate is the one on the segment the tone was sweeping when that
    reading was made. Returns None where the window holds no peak (no
    readings, or the tallest at its edge, the flank of a peak
    elsewhere) or a crossing is missing, since then there is no Q to
    judge; and for a level kept without its clock, read off a tone that
    sweeps both ways, whose two passes cannot be told apart.

    Parameters
    ----------
    level : SineLevel
        A tracked or extracted level.
    specification : SineSweepSpecification or SineTone
        The sweep the level was read from. A specification's tone is
        the level's own (`level.tone`); a KeyError says when it holds
        no tone of that name.
    near : float
        Hz, the mode looked for.
    record : int
        The level's record (channel) to read.
    within : float
        The window around `near`, as a fraction of it.
    margin : float
        As for `check_sweep_rate`.

    Returns
    -------
    SweptQ or None
        The judgment, with the peak's height in the level's units.
    """
    # The clock, the floor and the tone's name are read since
    # 2026-10-16. Before, a tracked level (sorted by frequency) of a
    # sweep up and back read its two passes interleaved, a reading
    # under the floor could be the peak, and a tone the specification
    # did not hold was quietly read against its first one.
    near = _positive(near, 'a frequency')
    tone = (specification if isinstance(specification, SineTone)
            else _tones(specification, getattr(level, 'tone', ''))[0])
    f = np.asarray(level.abscissa, dtype=float)
    values = np.abs(np.asarray(level.ordinate)[record]).astype(float)
    keep = np.isfinite(values) & (values > 0.0)
    resolved = getattr(level, 'resolved', None)
    if resolved is not None:
        keep &= np.asarray(resolved)[record]
    seconds = getattr(level, 'seconds', None)
    rates = np.sign(tone.segment_rate)
    if seconds is None and rates.min() != rates.max():
        return None
    order = (np.arange(len(f)) if seconds is None
             else np.argsort(np.asarray(seconds), kind='stable'))
    f, values, keep = f[order], np.where(keep, values, 0.0)[order], keep[order]
    window = keep & (np.abs(f - near) <= within * near)
    if np.count_nonzero(window) < 3:
        return None
    tallest = int(np.flatnonzero(window)[np.argmax(values[window])])
    first, last = _one_way(f, tallest)
    inside = np.flatnonzero(window[first:last + 1]) + first
    # the tallest reading at an end of the window is the flank of some
    # other peak, not a peak here (a 35 Hz window on a 22 Hz mode's
    # level read a Q of 4 off its skirt)
    if inside.size < 3 or tallest in (inside[0], inside[-1]):
        return None
    top, height, q_read = _read_peak(
        f[first:last + 1], values[first:last + 1],
        ((1.0 - within) * near, (1.0 + within) * near))
    if q_read is None or q_read <= 0.5:
        return None
    rows = _segment_table(tone)
    if seconds is None:
        segment = next((row for row in rows
                        if min(row[3], row[4]) <= top <= max(row[3], row[4])),
                       None)
    else:
        when = float(np.asarray(seconds)[order][tallest]) - level.onset
        segment = next((row for row in rows if when <= row[1] + row[2]),
                       rows[-1])
    if segment is None:
        return None
    return swept_q(top, q_read, segment[6], segment[5], margin=margin,
                   height=height)