visualdynamics.core.shocks¶
shocks
¶
Finding the shocks in a recording, and the window to analyse each in.
A shock test is a series of events in one continuous stream: the
article sits quiet, something hits it, it rings down, and it sits quiet
again until the next one. Nothing in the file says where they were.
Averaging answers "which stretch of this record is the test?" for a
stationary run; this answers "which stretches are the events?" for a
transient one, and there is more than one answer per record.
The shape of the problem is different enough from averaging to want its own algorithm, but the first move is the same: compress the record to one level per hop, across all channels at once, and decide everything afterwards on that short series. A shock record can be minutes long at 50 kHz, and no amount of care about thresholds is worth a second pass over the samples.
Three things make the detection robust rather than merely simple:
- A Schmitt trigger, not a threshold. One threshold chatters: a
ringdown crosses it on every cycle and one event comes back as forty.
An event starts when the level rises
ARMdB over the floor and does not end until it falls back underRELEASE, so the decay is followed down instead of being chopped where it happens to dip. - The floor is a low quantile, not the mean. Shocks are sparse and enormous; averaging them into the floor they are measured against raises it by tens of dB. A quantile low enough to sit in the quiet is unmoved by how hard, or how many, the shocks were.
- Everything is measured against the floor, not against the peak. A shock series is usually walked up in level, so the last event can be ten times the first. Thresholds relative to each event's own peak would find them all and give them different windows for no physical reason; relative to the floor, they get the window their ringdown actually needs.
The window is not the event. It reaches back before the rise, because an SRS oscillator has to start from rest and a window that opens mid- pulse invents a step that was never there, and it reaches past the release, because the ringdown that is still above the floor is the part the low-frequency oscillators are still answering.
Classes:
| Name | Description |
|---|---|
Shock |
One event's analysis window: when it opens and how long it runs. |
Functions:
| Name | Description |
|---|---|
hop_for |
Samples per hop — at least one, whatever the rate. |
envelope |
(levels in dB, hop in samples): the record as a short series. |
loudest |
The loudest channel's level per hop, in dB, at the follow hop. |
floor_of |
The quiet the events are measured against. |
uniform |
Whether these windows are all one length. |
with_added |
The series with one more window, placed where there is room. |
drag_settled |
What a drag of one window's edges means for the whole series. |
held_apart |
The windows with no two of them sharing a sample. |
same_length |
Every window given |
find |
The shocks in a record, as the windows to analyse them in. |
suggest |
|
Classes¶
Shock
dataclass
¶
One event's analysis window: when it opens and how long it runs.
Two numbers in seconds, both from the start of the record, and everything else derived. The window rather than the event: it already includes the lead-in and the tail, because what an SRS is computed from is the window and there is nothing to be gained by storing the event and re-deriving the window every time it is wanted.
Methods:
| Name | Description |
|---|---|
samples |
How many samples the window holds — at least two, since one |
bounds |
(first, last) sample indices, last exclusive. |
clipped |
This window pulled inside the record it belongs to. |
cut |
The samples this window covers, every channel, as a view. |
Methods:¶
samples
¶
How many samples the window holds — at least two, since one sample is not a transient.
bounds
¶
(first, last) sample indices, last exclusive.
clipped
¶
clipped(samples: int, sample_rate: float) -> Shock
This window pulled inside the record it belongs to.
Source code in src/visualdynamics/core/shocks.py
cut
¶
cut(history: TimeHistory) -> ndarray
The samples this window covers, every channel, as a view.
Functions:¶
hop_for
¶
envelope
¶
envelope(
history: TimeHistory, seconds: float = HOP_SECONDS
) -> tuple[ndarray, int]
(levels in dB, hop in samples): the record as a short series.
The same compression the averaging detector uses — median across channels so one bad channel is a vote and not the answer, mean removed within each hop so a DC offset is not read as level — but at a hop measured in milliseconds rather than in seconds, because what is being looked for lasts milliseconds.
Source code in src/visualdynamics/core/shocks.py
loudest
¶
loudest(
history: TimeHistory, seconds: float = RING_HOP
) -> ndarray
The loudest channel's level per hop, in dB, at the follow hop.
The series the ringdown is followed down. Detection stays on the
median — one bad channel is a vote, not the answer — but an SRS is
one spectrum per channel, so one channel still ringing under
twenty quiet ones is a channel whose spectrum a median-sized
window cuts short. The maximum is the series that can see it, and
the coarse hop (RING_HOP) is what lets it see slow ringing at
all.
Source code in src/visualdynamics/core/shocks.py
floor_of
¶
floor_of(
level: ndarray,
quantile: float = FLOOR_QUANTILE,
dynamic: float = DYNAMIC,
) -> float | None
The quiet the events are measured against.
A quantile rather than a mean or a median: shocks are sparse and enormous, and any average of a record containing them sits well above the quiet between them.
Never more than dynamic dB under the loudest hop. That is the
silence guard described at DYNAMIC, and it is why a synthesized
record whose gaps are exact zeros still gets thresholds that mean
something.
Source code in src/visualdynamics/core/shocks.py
uniform
¶
uniform(shocks: Sequence[Shock], places: int = 6) -> bool
Whether these windows are all one length.
The mode is read off the windows rather than stored beside them. A second copy of it in the project file is a copy that can disagree with the lengths it describes, and then the checkbox and the data say different things about the same series — so there is one place it lives, and it is the series.
Rounded, because the lengths are built by rounding to samples and two windows meant to match can differ in the last bit.
Source code in src/visualdynamics/core/shocks.py
with_added
¶
The series with one more window, placed where there is room.
The Add button's rule (Brandon, 2026-08-24): the detector misses events the user knows about — below the arm threshold, or close under a louder neighbour — and Remove's rationale cuts both ways. The new window takes the series' own length (they are usually uniform; the median otherwise, a tenth of the record when there is nothing to copy), and stands in the middle of the largest unwindowed stretch, shrunk when even the largest gap is tighter than that. The caller drags or types it into place from there.
Source code in src/visualdynamics/core/shocks.py
drag_settled
¶
drag_settled(
shocks: Sequence[Shock],
index: int,
low: float,
high: float,
common: bool,
limit: float | None,
) -> tuple[Shock, ...] | None
What a drag of one window's edges means for the whole series.
One implementation for both editors — the 2-D regions and the stage's handles commit through this. Which window moved is always just the dragged one; what can be shared is the length (an analysis choice, not a measurement), so a resize under a shared length regrows every window, while a move never resizes a neighbour. Either way the result is stopped at its neighbours.
Returns the settled series, or None when the drag changed nothing or asked for something the series has no room for — the caller puts its handles back.
Source code in src/visualdynamics/core/shocks.py
held_apart
¶
The windows with no two of them sharing a sample.
_windowed guarantees this at detection, but a window can also be
dragged on the plot or typed into the table, and neither of those
went through it — so one shock's window could be pulled over its
neighbour and its spectrum computed from the neighbour's ringdown.
This is where that invariant is restated, once, for every path that
can break it.
moved is which window the edit was aimed at, and it decides who
yields. Given it, that window is held inside the gap its untouched
neighbours leave: drag an edge into the next event and it stops at
the edge, which is what a person pulling it expects to happen and
would not expect of the event they did not touch. Without it — a
list arriving from somewhere with no single author — the earlier
window of an overlapping pair is cut back to where the later one
opens. Neither rule moves a window that was not in the way, so
neither can cascade down the record from one bad edit.
Source code in src/visualdynamics/core/shocks.py
same_length
¶
same_length(
shocks: Sequence[Shock],
length: float,
limit: float | None = None,
) -> tuple[Shock, ...]
Every window given length, each keeping its own start.
What a resize does to a series held at one length: pulling any edge sets the length for all of them, and they grow together until the closest pair runs out of room. Clamping only the window that collided and leaving the rest is how a uniform series quietly stops being uniform, which is the failure the mode exists to prevent, so the cap is shared too.
The starts are not touched, and that is the whole division of labour: a start belongs to the event it was measured from, so moving one window is moving one window, and only the length — the part that is an analysis choice rather than a measurement — is held in common.
Source code in src/visualdynamics/core/shocks.py
find
¶
find(
history: TimeHistory,
hop_seconds: float = HOP_SECONDS,
arm: float = ARM,
release: float = RELEASE,
quantile: float = FLOOR_QUANTILE,
merge: float = MERGE,
lead: float = LEAD,
tail: float = TAIL,
minimum: float = MIN_DURATION,
most: int = MAX_EVENTS,
common: bool = COMMON_LENGTH,
) -> tuple[Shock, ...]
The shocks in a record, as the windows to analyse them in.
Empty when there are none — which is the right answer for a random
vibration run, where the level never rises arm dB over its own
quiet because there is no quiet.
common gives every event a window of one length, which is the
default and the reasoning is at COMMON_LENGTH. Pass False for a
record whose events are not the same kind of event.
Source code in src/visualdynamics/core/shocks.py
suggest
¶
suggest(
history: TimeHistory, **kwargs: Any
) -> tuple[Shock, ...]
find, or the whole record when it finds nothing.
What a caller wanting something to analyse asks for. A record with no quiet in it has no shock the detector can point at, but it is still a transient somebody wants a spectrum of, and the honest fallback is the record itself rather than nothing.