visualdynamics.core.filters¶
filters
¶
Filtering, integration and differentiation of records.
The shock workflow's chain (Brandon, 2026-08-24): an acceleration record is low-pass filtered, integrated to velocity, integrated again to displacement — and each step is a derived object with provenance, so a changed filter corner cascades staleness down the chain the same way a changed averaging does.
Whole record, never the shock windows. Three reasons, decided before the first line: a zero-phase filter's edge transients land at the record's ends, far from the events, where per-window filtering would put them exactly on the data that matters; per-window integration hands each window its own arbitrary velocity baseline; and a derived record keeping the source's abscissa keeps its marks — the shock windows and averaging frames carry over untouched, so the report numbers the same events on the displacement it numbered on the acceleration.
Time domain, on purpose. The frequency-domain route (divide by iw) is the more familiar one, but it wraps the record onto a circle: the FFT's periodicity turns the mismatch between first and last sample into content. The trapezoid and the central difference read the record as the finite thing it is. Their cost is a known amplitude roll-off near Nyquist — sin(wT)/wT for the central difference — which is exactly why the chain filters first: below a corner of a tenth of the sample rate the round trip acceleration - velocity - displacement - velocity - acceleration comes back within ~2% RMS (measured, not assumed, 2026-08-24; ~4% at a corner of a fifth).
Differentiation is np.gradient, not the trapezoid's inverse.
The exact algebraic inverse (x[i] = 2*(y[i]-y[i-1])/dt - x[i-1]) looks
like it should round-trip perfectly and was tried: its recursion holds
a pole at Nyquist, and on real noise it rang to 280% error where the
central difference sat under 4%. Exactness that diverges is not
accuracy.
Classes:
| Name | Description |
|---|---|
Filtering |
The pass band a record is read through: its edges and order. |
Functions:
| Name | Description |
|---|---|
design |
The filter itself, as second-order sections. |
carrying_marks |
The source's readings, carried onto a derived record. |
filtered |
The record through its filter, every channel, zero phase. |
integrate |
One integration: acceleration to velocity, velocity to |
differentiate |
One differentiation: displacement to velocity, velocity to |
response |
(frequencies, magnitude in dB) the record actually sees. |
Classes¶
Filtering
dataclass
¶
The pass band a record is read through: its edges and order.
low and high are the edges of the pass band, in Hz: set
high alone for a low-pass (everything below it passes), low
alone for a high-pass, both for a band-pass. Which filter this is
follows from which edges exist (kind), so an invalid pairing —
a band-pass with one corner, a kind that contradicts its numbers
— cannot be stated at all. Keyword-only on purpose: the fields
were (corner, order) until 2026-08-28, and a bare
Filtering(200.0) quietly flipping its meaning from low-pass to
high-pass is exactly the kind of silent change a call site must
not survive.
Frozen like Averaging, and for the same reason — the settings
ride the history, the staleness fingerprint is their fields, and
a mutable setting would be a fingerprint that lies.
The filter itself is a Butterworth applied forward and backward
(sosfiltfilt): zero phase, so a shock's peak stays at its
measured instant — a causal filter would shift and smear the
front, and the report's time axis would be quietly wrong by the
group delay. The doubled pass also doubles the effective order;
order here is the design order of the underlying filter. A
band-pass of design order N carries N poles per edge (scipy's
convention), so each skirt rolls off like the matching low- or
high-pass of the same order.
Methods:
| Name | Description |
|---|---|
describe |
The filter in words — 'low-pass at 500 Hz'. One |
Attributes:
| Name | Type | Description |
|---|---|---|
kind |
str
|
'low-pass', 'high-pass' or 'band-pass' — derived from |
Attributes¶
kind
property
¶
'low-pass', 'high-pass' or 'band-pass' — derived from which edges exist, never stored beside them.
Methods:¶
describe
¶
The filter in words — 'low-pass at 500 Hz'. One implementation: the status line, the staleness story and the report prose all say it this way.
Source code in src/visualdynamics/core/filters.py
Functions:¶
design
¶
design(filtering: Filtering, sample_rate: float) -> Any
The filter itself, as second-order sections.
The one place a Filtering becomes a scipy design — the
application, the response plot and the GUI previews all take
their sections from here, so they cannot disagree about what the
settings mean. Refuses an edge at or above Nyquist for the same
reason: every caller refuses the same way.
Source code in src/visualdynamics/core/filters.py
carrying_marks
¶
The source's readings, carried onto a derived record.
Same abscissa, so the marks still say what they said: the averaging frames and shock windows land on the same samples, and a report numbers the same events on the displacement it numbered on the acceleration — or on the modal coordinate it numbered on the channels, since a transform through a shape set combines samples and moves none (Brandon, 2026-09-04). The filtering is not carried — the result of a filter has been filtered; offering to filter it again with the same settings would be a button that does nothing.
Source code in src/visualdynamics/core/filters.py
filtered
¶
filtered(
data: TimeHistory, filtering: Filtering
) -> TimeHistory
The record through its filter, every channel, zero phase.
Every channel whatever it measures: a filter reads shape, not quantity, and the drive force belongs at the same bandwidth as the responses it drove.
Source code in src/visualdynamics/core/filters.py
integrate
¶
integrate(
data: TimeHistory,
drift_corner: float | None = DRIFT_CORNER,
) -> TimeHistory
One integration: acceleration to velocity, velocity to displacement, channel by channel.
Cumulative trapezoid, then the drift control: the channel's mean
removed before integrating (a bias integrates to a ramp, and
sosfiltfilt's edge handling scales with how far the ramp runs)
and a gentle zero-phase high-pass at drift_corner after. Pass
None to integrate raw — the caller is saying the record's own
low end is trusted, drift and all.
Records of quantities the chain has nowhere to take — forces, voltages, anything undefined — are left out of the result; a record with none of its channels mappable is refused, because an empty answer delivered politely is still nothing.
Source code in src/visualdynamics/core/filters.py
differentiate
¶
differentiate(data: TimeHistory) -> TimeHistory
One differentiation: displacement to velocity, velocity to acceleration, channel by channel.
Second-order central differences (np.gradient) — see the module
docstring for why the trapezoid's exact inverse was tried and
thrown out. No drift control: differentiation kills offsets
instead of growing them.
Source code in src/visualdynamics/core/filters.py
response
¶
response(
filtering: Filtering,
sample_rate: float,
lines: int = 400,
) -> tuple[ndarray, ndarray]
(frequencies, magnitude in dB) the record actually sees.
The squared magnitude, not the filter's own (Brandon,
2026-08-25). filtered runs the filter forward and backward for
zero phase, so the data is multiplied by |H| twice and the
effective response is |H|². The consequences are worth stating
because they contradict what the settings say: a Butterworth is
−3 dB at its nominal corner by definition, so the zero-phase pair
is −6.02 dB there, and its true −3 dB point sits at about
0.90 of the corner. Plotting |H| instead would draw a filter
nothing here applies.
Log-spaced over RESPONSE_DECADES below Nyquist, which is where
a filter has anything to say.