visualdynamics.core.sine_tracking¶
sine_tracking
¶
A swept sine read the way a controller's tracking filter reads it.
core.sine.extract_sine is the best estimate of a tone's level there
is: every tone solved jointly, debiased, its smoothing chosen from the
data. That is the right number for a report and the wrong one for a
question a test floor asks often — why does the controller's level
disagree with mine? — because the controller did not read it that
way. It read the response through a tracking filter, a band-pass that
follows the drive frequency, of a fixed width in Hz or a width
proportional to the drive, and turned what came out into a level with
a detector: the peak, the RMS or the mean absolute value (the last two
reported as the peak of the sine they would be), or the filter's own
output. This module does that, so the readings can be laid side by
side and the difference seen rather than argued.
What the readings show, and why the module exists: a harmonic or broadband noise moves the level by an amount that depends on the detector and on the filter. Read unfiltered, a peak detector counts the harmonic's peaks and an RMS detector counts its power; read through the filter, neither is seen. The filter's price 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.
Demodulation. The record is multiplied by the complex exponential of the drive's own phase, which moves the drive frequency to zero; a low-pass of half the bandwidth there is 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 afresh at every frequency of the sweep; the low-pass only changes its corner.
Causal, on purpose. core.filters.filtered runs forward and
backward for zero phase, which is right for a record and wrong here:
the point of the reading is 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 proper begins.
One filter, integrated through every sample. A proportional
band's corner moves with the drive, so no fixed digital design fits
it. The first cut redesigned core.filters.design's Butterworth each
time the corner had moved one percent and carried the state across,
and it measured wrong two ways (2026-10-05). Carried raw, the state of
a narrow section rang at each redesign: 5.7 % of error on a clean log
sweep read at 10 %. Carried as its settled part plus the transient in
flight, the redesigns still came at a steady rate along a log sweep,
and the demodulation's image (below) mixed down against that rate: at
50 % the image got through ten times stronger than the filter allows,
and a step a third the size moved the mixing frequency without curing
the leak. So the filter is the same Butterworth — scipy's analog
prototype, the one design's butter maps to a digital filter — split
into its first-order modes, and each mode is integrated exactly across
each sample at the corner that sample has, the input held over the
sample. Nothing is redesigned; 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 image gets through at the filter's
own response, measured equal to it. 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 it differs by 0.3 % RMS on white
noise, the hold against the bilinear map, which no level reads.
Fourth order by default, because of the 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. At second order that is 1.6 % at 50 % proportional and 6.2 % at 100 %, a demodulator's artifact rather than a reading; at fourth order, 0.02 % and 0.4 %. A wide fixed band at the bottom of a sweep — half the bandwidth near or past the drive frequency — is the case where the band is no longer about the drive at all, and there the ripple is what the reading shows.
A peak is the peak of the samples, as a sampled detector reads
it: it under-reads a sine by at most cos(pi x drive / sample rate),
0.3 % at a fortieth of the sample rate. RMS and mean read over the
same span, a whole number of the drive's cycles (DETECTOR_CYCLES
by default), where a pure sine's RMS times the square root of two and
its mean absolute value times pi/2 are its peak exactly.
They are exact only if the span really is a whole number of cycles
and the waveform between the samples is seen. Read off the samples
alone, neither holds when a cycle is a few samples long: the span
snapped to whole samples, and |x| has a corner at every zero crossing
that a handful of samples cannot place. On a clean 2 m/s**2 tone
sampled at 4096 Hz, one cycle, RMS read 3.6 % off at 400 Hz (10
samples a cycle) and mean 9 % at 750 Hz (5.5) (2026-10-05, found in
review; the tests here run at 51 samples a cycle and never saw it).
So the two are read from the band-limited waveform: the samples
around each span upsampled until a cycle has FINE_POINTS points
(scipy.signal.resample_poly), and averaged over exactly the span's
phase, its first point interpolated at the span's start. Measured
the same way, a clean sweep to 780 Hz: RMS within 0.005 % and mean
within 0.08 % sampled at 4096 Hz (5.3 samples a cycle at the top),
both within 0.09 % at 2048 Hz (2.6). The reconstruction leans on MARGIN samples
either side of a span, so a reading looks that far past its instant:
interpolation, not a lag. The peak stays the peak of the samples, by
design: that is how a sampled controller reads it.
The waveform, the shape and the weight, from the same filter. A
level is what a controller reports; the view that explains it draws
the record through the band, the band's magnitude about the drive and
the band's weight on the record behind any instant
(plot.tracking_filter). All three come from here, so the picture
cannot describe a different filter from the one that read the level:
track_waveform is track_sine's own band run 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 one at every instant.
Memory. One channel's span of the tone is held at a time, with its phase, the demodulated record, the filter's output and one mode's working arrays beside it — about a hundred bytes a sample. The extraction streams where this does not; a recording of tens of millions of samples per channel wants the extraction's chunking, which this first cut does not have.
References
The tracking filter here is complex demodulation followed by a Butterworth low-pass, integrated with its input held over each sample; these are where the three are set out, and the test this reading is used to understand.
- Bloomfield, P. (2000). Fourier Analysis of Time Series: An Introduction, 2nd ed. Wiley. Complex demodulation: a component near a known frequency moved to zero and low-passed to read its amplitude and phase as they vary.
- Oppenheim, A. V., & Schafer, R. W. (2010). Discrete-Time Signal Processing, 3rd ed. Prentice Hall. The Butterworth low-pass and its analog prototype.
- Franklin, G. F., Powell, J. D., & Workman, M. L. (1998). Digital Control of Dynamic Systems, 3rd ed. Addison-Wesley. A continuous system integrated exactly across a sample with its input held: the zero-order-hold equivalent each mode is stepped with.
- IEC 60068-2-6:2007, Environmental testing - Part 2-6: Tests - Test Fc: Vibration (sinusoidal). The swept sine test whose drive level a tracking filter and detector measure.
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 width as a fraction of the drive
frequency (0.5 is 50 %); fixed is a width in Hz. Neither is an
unfiltered reading, where the detector reads the record as it
stands. Both at once is refused — a band is one or the other — and
so is the filter's own output with no filter. Which band this is
follows from which width exists (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 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 would be
(TO_PEAK). The three waveform detectors read over the last
cycles cycles of the drive; the filter's output ignores it.
order is the low-pass's design order (ORDER).
A proportional band must sit under twice the drive frequency: past
that, half of it reaches below zero frequency at every point of the
sweep, and the band is not about the drive anywhere. Frozen like
Filtering, so a setting cannot change under the levels read with
it.
Methods:
| Name | Description |
|---|---|
bandwidth |
The band's full width in Hz at drive frequency |
settling |
Roughly how long the band takes to follow a change of level, |
response |
The band's magnitude in dB at |
weighting |
How much the record |
describe |
The reading in words — 'rms as peak, 10 % proportional'. One |
Attributes:
| Name | Type | Description |
|---|---|---|
kind |
str
|
'unfiltered', 'proportional' or 'fixed' — derived from which |
Attributes¶
kind
property
¶
'unfiltered', 'proportional' or 'fixed' — derived from which width exists, never stored beside it.
Methods:¶
bandwidth
¶
The band's full width in Hz at drive frequency frequency.
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 |
Source code in src/visualdynamics/core/sine_tracking.py
settling
¶
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 — the fourth-order filter reaches half of a step in level at about 0.9 of it and nine tenths at about 1.3, the same at every width, measured on a step in a swept tone (2026-10-05).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
frequency
|
float or array - like
|
The drive frequency, Hz. |
required |
Returns:
| Type | Description |
|---|---|
ndarray
|
Seconds, the shape of |
Source code in src/visualdynamics/core/sine_tracking.py
response
¶
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 what a band-pass about the drive is after demodulation; −3 dB at the drive plus and minus half the bandwidth, by the Butterworth's definition. What the image at twice the drive adds (the module docstring's ripple) is not in it: that is the demodulator's artifact, not the band's shape. The analog prototype's response, the one each mode is integrated from; the held input moves it by nothing a picture shows this far below the sample rate.
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 |
Source code in src/visualdynamics/core/sine_tracking.py
weighting
¶
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 overshoot a fourth order 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). Zero at negative lags: the
band cannot see ahead.
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 |
Source code in src/visualdynamics/core/sine_tracking.py
describe
¶
The reading in words — 'rms as peak, 10 % proportional'. One implementation: a legend, the levels' comments and the guide all say it this way.
Source code in src/visualdynamics/core/sine_tracking.py
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; drive the drive
frequency at each of those samples; passed the record through
the band, in the record's units — 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. Not compared by value (arrays do not have one), and frozen
like the setting it was read with.
Methods:
| Name | Description |
|---|---|
instant |
The first second, on the record's clock, at which the drive |
Methods:¶
instant
¶
The first second, on the record's clock, at which the drive
reaches frequency — where a cursor goes to look at the band
as the tone passes a 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
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 laid 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 lines instants,
evenly spaced through the tone's span from the first instant every
detector has a whole span behind it, so the levels overlay line for
line.
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. Several from one record is the point: 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. |
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 stamped
with the second it was read ( |
Source code in src/visualdynamics/core/sine_tracking.py
632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 | |
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 laid on the recording where its sweep begins,
demodulated against its phase, low-passed causally from settled —
and 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. One with no band is refused: there is nothing 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. |