visualdynamics.core.detect¶
detect
¶
Find the stretch of a record worth averaging.
A run holds more than the test: the shaker coming up, a reduced-level check, the full level, and whatever was still recorded after the environment stopped. Averaging all of it mixes them into one PSD that describes none of them. This finds which stretch to use.
The record is first compressed to one level per hop, in dB, and every decision after that is made on that short series. That keeps it quick on large sets: three hundred channels of a ten-minute run take one pass over the samples, and the rest is arithmetic on a few thousand points.
Two choices make the compression reliable:
- the levels are combined across channels by the median, not the mean, so a channel that drops out, spikes, or sits forty dB above its neighbors does not decide the answer;
- the mean is removed within each hop, so a DC offset or a slow drift is not read as level.
The levels are not normalized against each other and the series is not centered. The median across channels already does that work, and the threshold sweep walks every bin edge from the top, so scaling a whole record by a million moves the bins together and lands on the same hops.
The level is in dB, not power, because a level change is multiplicative. In power the scatter grows with the level, so the flattest stretch of a record is reliably its quietest, the opposite of what is wanted. In dB a quiet stretch and a loud one scatter alike.
Functions:
| Name | Description |
|---|---|
frame_length_for |
The power of two nearest one second of record at |
scatter_floor |
The least a stretch can scatter, in dB, from estimator noise |
observed_scatter |
How much this record actually scatters hop to hop, in dB. |
hop_levels |
The record as one level per hop of |
plateau |
The highest level the record holds for |
trim |
The settled middle of the stretch ( |
compress |
(levels in dB, hops per frame, hop in samples, scatter in dB). |
analysis_window |
(first sample, last sample) worth averaging in this history, |
suggest |
The averaging to use for a record that has no parameters set. |
Classes¶
Functions:¶
frame_length_for
¶
The power of two nearest one second of record at
sample_rate, in samples (nearest on a log scale, at least 2).
A power of two for the FFT, and about a second because that is the resolution (1 Hz) a random vibration test is usually specified at. When the rate is itself a power of two, as most are, the two are the same number. A rate that is not positive is refused.
Source code in src/visualdynamics/core/detect.py
scatter_floor
¶
The least a stretch can scatter, in dB, from estimator noise
alone, for a power estimated from samples samples.
That power has at most samples degrees of freedom, and a
chi-squared variable with dof degrees of freedom has a relative
standard deviation of sqrt(2/dof); in dB that is 4.343 times it, for
the small values used here.
This is a lower bound, not an estimate. Real records scatter more:
the full-level stretch of a closed-loop run scatters about a dB
against a floor here of about a quarter of one, because the loop is
adjusting the whole time and the response does wander. Band-limited
data also has fewer independent values than samples. The larger of
this and observed_scatter is used.
Source code in src/visualdynamics/core/detect.py
observed_scatter
¶
How much this record actually scatters hop to hop, in dB.
It is taken from the first differences of the levels, which a level change barely affects (one step in a hundred hops moves one difference), so what is left is the wander within a level. It uses the median absolute deviation, scaled to a standard deviation, instead of the standard deviation itself, for the same reason: the steps must not set the tolerance for what counts as the same level. 0 for fewer than three hops.
Source code in src/visualdynamics/core/detect.py
hop_levels
¶
The record as one level per hop of hop samples, in dB.
Each channel's power in a hop is its variance within the hop (the mean removed), and the live channels are combined by the median. Returns an empty array when there is not a whole hop of record, or when every channel is flat: a file of zeros has no level, and reporting a floor for it would be wrong.
loudest combines the channels by the maximum instead of the
median. The median is the right choice for detection, where one bad
channel should not decide the answer, but it cannot see one channel
still ringing under twenty quiet ones, and a shock window sized on
it closes while that channel is still responding.
sparse changes what counts as a flat channel, because the two
detectors ask different questions of the same compression. A
stationary run's channel is live when its median hop carries power:
a channel quieter than that half the time has dropped out. A shock
record's channel is quiet most of the time (three events in a
minute leave the median hop empty), so with sparse a channel is
live if it carries power anywhere. Judged by the stationary rule, a
record of sparse transients reports no level at all.
Source code in src/visualdynamics/core/detect.py
plateau
¶
plateau(level: ndarray, sigma: float, want_hops: int, tolerance: float = TOLERANCE) -> tuple[int, int]
The highest level the record holds for want_hops hops, as
(first, last) hops, last exclusive.
sigma is the scatter in dB, and tolerance is how many of it a
hop may sit from the plateau's level. Level alone is the wrong
thing to maximize: a ten-second overload in the middle of a
two-minute test is the highest level in the record and the last
thing anyone wants to average, and chosen by level it would return
a fifth of the frames the test holds. So duration comes first and
level second.
A threshold is swept down from the top in steps of tolerance
times sigma. The longest run above a threshold only grows as the
threshold falls, so the first threshold whose run is long enough
gives the highest level that is held long enough. The sweep stops
FULL_LEVEL_MARGIN (12 dB) under the record's top level (its
TOP_QUANTILE percentile), since below that a run is not the test
at full level however long it lasts. The run found is grown to the
whole level it sits in. When nothing is held that long, as in a
short record or a test that never settled, the longest stretch at
the highest level the record holds is returned instead, shorter
than asked for.
Source code in src/visualdynamics/core/detect.py
trim
¶
The settled middle of the stretch (first, last), keeping
the fractions keep of its length (KEEP, 0.20 to 0.90).
Closed-loop control reaches a level over several frames instead of
at once, so the front of a stretch is not yet at the level the rest
of it is. The tail is trimmed a little as well, because the level
often starts to come off before the environment formally stops. A
stretch too short to trim is returned whole. keep must be two
fractions in [0, 1], the first below the second.
Source code in src/visualdynamics/core/detect.py
compress
¶
compress(history: TimeHistory, overlap: float = 0.5, frame_length: int | None = None) -> tuple[ndarray, int, int, float]
(levels in dB, hops per frame, hop in samples, scatter in dB).
This is the one pass over the samples, and it returns everything a
caller needs to reason about the record afterward. The frame is
frame_length samples, or about a second (frame_length_for) when
None, and never longer than the record; the hop is the frame length
times (1 - overlap). The scatter is the larger of scatter_floor
and observed_scatter. It is separate from analysis_window
because more than one question is asked of the same compression,
and asking twice would mean reading the samples twice.
Source code in src/visualdynamics/core/detect.py
analysis_window
¶
analysis_window(history: TimeHistory, want_frames: int = WANT_FRAMES, overlap: float = 0.5, frame_length: int | None = None, keep: tuple[float, float] = KEEP) -> tuple[int, int]
(first sample, last sample) worth averaging in this history, last exclusive.
The stretch is the highest level held for want_frames frames of
frame_length samples at overlap (see plateau), trimmed to its
settled middle by keep (see trim). A noise floor capture is a
level like any other and is found the same way, so a PSD can be
computed from it. The whole record comes back when the record has
no level to compare (shorter than a hop, or every channel flat).
Source code in src/visualdynamics/core/detect.py
suggest
¶
suggest(history: TimeHistory, want_frames: int = WANT_FRAMES, window: str = 'hann', overlap: float = 0.5, frame_length: int | None = None, keep: tuple[float, float] = KEEP) -> Averaging
The averaging to use for a record that has no parameters set.
By default a Hann window at half overlap and a frame about a
second long (frame_length_for), starting where analysis_window
puts the stretch, with as many frames as fit before the stretch
ends, and at least one. The frame count is the answer to how many
averages, not a number chosen in advance. The start is marked as
chosen (start_set).