visualdynamics.core.sine¶
sine
¶
Sine sweep specifications: named tones, each its own time-boxed sweep.
Phase B of the sine sweep arc (PLAN.md). A sine test's requirement is not a curve over one abscissa — it is a set of tones, each a breakpoint table with its own sweep law and its own start time, played simultaneously and only partially overlapping in the ordinary case (measured on the Phase A fixture: four tones playing 0-15, 2-13, 3-14 and 1-8 s of one 15 s environment). That shape does not fit a DataArray, so it gets its own class rather than a forced one.
The sweep law here is the one the Phase A runs pinned against the controller's own records, not assumed from documentation:
segment_rate[i]governs the segment fromfrequency[i]tofrequency[i+1]— the leading convention, matched to 0.0009 Hz over a 14 s sweep.- A linear segment's rate is in Hz/s; a logarithmic segment's rate is in oct/min (one octave at rate 10 transited in 5.87 s ~= 6 s; the controller's own docstrings disagree with each other and the measurement settled it).
- A descending segment takes a negative rate. A rate whose sign contradicts its breakpoints is refused by name here, because the controller refuses the same thing as a negative sweep time — at initialization, deep in its log.
Amplitude between breakpoints interpolates linearly in time, which
is linear in frequency on a linear segment and linear in log-frequency
on a logarithmic one — target() interpolates that way per segment,
so the compliance curve is the curve the controller was actually
chasing.
One more alignment fact for the extraction to lean on, measured on the
Phase A fixture: the controller ramps each tone up for the
environment's ramp_time before sweeping, so the recorded trajectory
leaves its start frequency at start_time + ramp_time — all four
fixture tones rebuilt against the controller's own record to 0.0000 Hz
with exactly that lag.
Everything is SI at rest, like every other object in core.
Classes:
| Name | Description |
|---|---|
SineTone |
One tone: breakpoints, a sweep law, and its own window in time. |
SineSweepSpecification |
What a sine test was controlled to: tones over control channels. |
SineTarget |
One tone's requirement laid over frequency, as a curve. |
SineLevel |
A sine sweep's measured level: amplitude against frequency. |
SineLevelSet |
One extraction, one object: the tones' levels, grouped the way |
SineExtraction |
How a sweep's levels are read out of a recording — the settings |
Functions:
| Name | Description |
|---|---|
find_tone |
Where a tone's sweep begins in a recording, by matched filter. |
find_environment |
Where the tones' shared clock starts, by joint matched filter. |
vold_kalman |
Every tone's complex envelope at every sample, solved jointly. |
sample_levels |
The levels read at |
scatter_db |
The predicted standard deviation of a reading, in dB, from the |
suggest_cycles |
The smoothing the data asks for: the first rung of |
extract_sine |
Read each tone's level out of a recording, against its own sweep. |
Classes¶
SineTone
¶
SineTone(name: str, start_time: float, frequency: Any, amplitude: Any, segment_type: Any, segment_rate: Any, phase: Any = None, warning_lower: Any = None, warning_upper: Any = None, abort_lower: Any = None, abort_upper: Any = None)
One tone: breakpoints, a sweep law, and its own window in time.
Attributes: name: What the controller called it ('Sine Tone 1'). start_time: Seconds after the environment started that this tone turns on. Tones are silent outside their own span — never held at an end frequency. frequency: Breakpoints, Hz, shape (n,). Ascending or descending per segment; each segment's rate sign must agree. amplitude: Target amplitude at each breakpoint per control channel, shape (n, m), SI. phase: Radians at each breakpoint per channel, shape (n, m). segment_type: (n-1,), LINEAR or LOG per segment. segment_rate: (n-1,), Hz/s for a linear segment, oct/min for a logarithmic one; negative for a descending segment. warning_lower, warning_upper, abort_lower, abort_upper: Band curves at the breakpoints, shape (n, m), or None where the specification carries no such limit.
Methods:
| Name | Description |
|---|---|
segment_seconds |
How long each segment takes, from its span and its rate. |
span |
When this tone plays, in environment time. |
trajectory |
Frequency versus time over the tone's own span. |
argument |
The cosine argument over the tone's span: 2piintegral(f), |
frequency_at |
The instantaneous frequency, Hz, at seconds |
phase_at |
The cosine argument, radians, at seconds |
grid |
The sample times |
grid_at |
The sample times |
target |
The specified level at given frequencies, shape (len, m). |
Source code in src/visualdynamics/core/sine.py
Methods:¶
segment_seconds
¶
How long each segment takes, from its span and its rate.
Source code in src/visualdynamics/core/sine.py
span
¶
trajectory
¶
Frequency versus time over the tone's own span.
Returns (t, f): t in seconds from the tone's start (add
start_time for environment time), f in Hz. This is the
rebuild that matched the controller's own record to 0.0009 Hz.
Source code in src/visualdynamics/core/sine.py
argument
¶
The cosine argument over the tone's span: 2piintegral(f),
at the sample times trajectory returns — phase_at on them,
so a slice of the sweep (argument_slice) is the same numbers
as the whole (2026-10-01).
Source code in src/visualdynamics/core/sine.py
frequency_at
¶
The instantaneous frequency, Hz, at seconds t from the
tone's start — the same law trajectory lays on its grid,
evaluated anywhere, so a slice of a long sweep costs the slice.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
t
|
array - like
|
Seconds from the tone's start. |
required |
Returns:
| Type | Description |
|---|---|
ndarray
|
|
Source code in src/visualdynamics/core/sine.py
phase_at
¶
The cosine argument, radians, at seconds t from the
tone's start: 2*pi times the integral of the frequency law,
segment by segment in closed form — a linear sweep's chirp, a
log sweep's exponential.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
t
|
array - like
|
Seconds from the tone's start. |
required |
Returns:
| Type | Description |
|---|---|
ndarray
|
|
Source code in src/visualdynamics/core/sine.py
grid
¶
The sample times trajectory(dt) lays down, from sample
first to last (exclusive): the whole grid's own numbers,
for a slice of it.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
dt
|
float
|
The sample interval. |
required |
first
|
int
|
The slice of the sweep's samples. |
required |
last
|
int
|
The slice of the sweep's samples. |
required |
Returns:
| Type | Description |
|---|---|
ndarray
|
|
Source code in src/visualdynamics/core/sine.py
grid_at
¶
The sample times trajectory(dt) lays down, at the given
sample indices — grid for a handful of samples picked out of
a long sweep, without laying the whole sweep down.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
dt
|
float
|
The sample interval. |
required |
samples
|
array-like of int
|
Sample indices into the sweep's grid. |
required |
Returns:
| Type | Description |
|---|---|
ndarray
|
|
Source code in src/visualdynamics/core/sine.py
target
¶
The specified level at given frequencies, shape (len, m).
curve is 'amplitude' or one of the limit names. Amplitude
interpolates linearly in time — linear in frequency on a
linear segment, linear in log-frequency on a logarithmic one —
because that is the target the controller chases. Frequencies
the tone never sweeps come back NaN: the specification says
nothing there, and NaN says so where zero would lie.
Source code in src/visualdynamics/core/sine.py
SineSweepSpecification
¶
SineSweepSpecification(tones: Sequence[SineTone], response_dof: Sequence[str], ordinate_dim: str = 'acceleration', ordinate_unit: str | None = None, comment: str = '')
What a sine test was controlled to: tones over control channels.
One object per sine environment, mirroring the controller's file: the tones (each with its own breakpoints, sweep law, bands and start time) and the control DOFs their amplitude columns belong to. Simultaneous tones are the ordinary case, not a variant.
Attributes: tones: The SineTone list, in the file's order. response_dof: The control channel DOF strings — the columns of every tone's amplitude table, in order. ordinate_dim: What the amplitudes are ('acceleration' for a controller run on accelerometers). ordinate_unit: The SI unit the amplitudes are stored in, or None while undeclared — the same convention every DataArray follows. comment: One line about the set as a whole.
Methods:
| Name | Description |
|---|---|
span |
When any tone is playing: first start to last end. |
tone_curve |
One tone's requirement as a plottable, comparable curve. |
Source code in src/visualdynamics/core/sine.py
Methods:¶
span
¶
tone_curve
¶
tone_curve(name: str, lines: int = 400) -> SineTarget
One tone's requirement as a plottable, comparable curve.
The abscissa follows the tone's own sweep — lines points
spaced the way the sweep dwells, ascending whichever way it
swept — and the bands ride along as Bounded limits.
Source code in src/visualdynamics/core/sine.py
SineTarget
¶
One tone's requirement laid over frequency, as a curve.
What SineSweepSpecification.tone_curve hands the plot and the
report: the tone's amplitude interpolated along its own sweep,
with the warning and abort bands as the four limit curves every
Bounded object carries — so the comparison against an extracted
level draws with the same zones, the same shading and the same
pairing a random specification gets, from the same machinery.
Derived on demand; the specification object stays the one source.
Source code in src/visualdynamics/core/sine.py
SineLevel
¶
SineLevel(*args: Any, tone: str = '', onset: float = 0.0, seconds: Any = None, floor: Any = None, below_floor: Any = None, drift_hz: float = 0.0, **kwargs: Any)
Bases: Spectrum
A sine sweep's measured level: amplitude against frequency.
What extract_sine reads out of a recording for one tone — the
demodulated complex amplitude of that tone at each control channel,
sampled along the sweep and laid over frequency. The magnitude is
the tracked amplitude the controller was steering; the angle is the
phase relative to the reconstructed sweep argument, meaningful
between channels rather than absolutely.
The frequency coverage is the coverage: a run stopped early, or a tone the instructions windowed, extracts fewer lines, and the comparison against the specification sees exactly how much of the required range was actually run rather than being told everything was.
Attributes: tone: The specification tone this level was extracted for. onset: Seconds into the recording where the tone's sweep proper was found (matched filter, or the caller's override) — reported so the alignment is auditable. seconds: When each line was measured, in recording seconds — the sweep's own clock, one entry per abscissa line, which is what lets the level stand on the 3-D stage without the specification beside it. None on a level from before the clock was kept. floor: The noise floor beside each reading, channels × lines, in the level's own units: the amplitude a tone would need to stand clear of the noise the smoothing let through (2026-09-30). None on a level from before it was kept. below_floor: Channels × lines, True where the tone was under its floor and the reading is reported at the floor rather than at zero — a mark on the plot and a line the comparison does not judge. None on an older level. drift_hz: The sweep-clock correction applied at the end of the tone's sweep, in Hz, when the recording's sweep had drifted from the specification's; 0 when none was needed.
Attributes:
| Name | Type | Description |
|---|---|---|
resolved |
ndarray
|
Channels × lines, True where the tone stood above its floor |
Source code in src/visualdynamics/core/sine.py
SineLevelSet
¶
SineLevelSet(levels: Sequence[SineLevel], cycles: float | None = None)
One extraction, one object: the tones' levels, grouped the way the specification groups its tones (Brandon, 2026-08-22).
Each tone sweeps its own frequencies on its own clock, so the
levels stay separate SineLevels inside — different abscissas
cannot share a DataArray — but the project holds one thing, it
expands into one row per tone, and picking rows plots a subset,
exactly as the specification does.
Attributes: levels: The per-tone SineLevels, in the specification's order. cycles: The smoothing the levels were read with, in cycles of each tone's instantaneous frequency; None on a set from before it was kept.
Source code in src/visualdynamics/core/sine.py
SineExtraction
dataclass
¶
SineExtraction(cycles: float | None = None, target_db: float = TARGET_SCATTER_DB, refine: bool = True, chosen: float | None = None)
How a sweep's levels are read out of a recording — the settings
that ride the time history, the way Averaging and Filtering
do (the sine view sets them; Extract Sine Levels reads whatever is
there).
cycles is the smoothing: how many cycles of the tone's own
instantaneous frequency each reading averages over. Shorter
follows a resonance closely; longer holds the random environment
out of the reading. None means automatic: the extraction samples
the recording at BASE_CYCLES, measures how much noise the
smoothing lets through against the tone it finds, and climbs
CYCLES_LADDER until the predicted scatter of the readings is
under target_db (Brandon, 2026-09-30: a 0.5 g sweep under a
2.3 g RMS random environment read with a 17 dB spread and a fifth
of its points at zero at ten cycles; the lever is the smoothing,
and the data can say how much). chosen records what the
automatic picked, so the setting read back says what was used.
refine corrects the specification's sweep clock against the
recording: the envelope's residual phase is fitted and the sweep
argument moved by it, then solved again, so a controller whose
sweep drifted from the commanded rate over a long run does not
read low once the drift leaves the filter's bandwidth.
Frozen like the other settings, and for the same reason: the staleness fingerprint is the fields, and a mutable setting would be a fingerprint that lies.
Methods:
| Name | Description |
|---|---|
effective_cycles |
The smoothing that applies: the one set, else the one the |
describe |
One line for a status bar. |
Attributes:
| Name | Type | Description |
|---|---|---|
automatic |
bool
|
Whether the smoothing is chosen from the data. |
Attributes¶
Methods:¶
effective_cycles
¶
The smoothing that applies: the one set, else the one the automatic chose, else None until an extraction has run.
describe
¶
One line for a status bar.
Source code in src/visualdynamics/core/sine.py
Functions:¶
find_tone
¶
find_tone(records: ndarray, dt: float, tone: SineTone, search: tuple[float, float] | None = None) -> float
Where a tone's sweep begins in a recording, by matched filter.
Correlates the reconstructed sweep template against every record
and sums the correlation power across them — the processing gain
over a whole sweep is what finds a tone under a random excitation
much louder than it (measured: a clean find under +15.6 dB of
random). search bounds the onset in seconds when the caller
knows roughly where to look. Returns the onset in seconds.
Source code in src/visualdynamics/core/sine.py
find_environment
¶
find_environment(records: ndarray, dt: float, tones: Sequence[SineTone], ticker=None) -> float
Where the tones' shared clock starts, by joint matched filter.
Every tone in one environment begins at its own start_time on
one clock, so there is one unknown — the clock's position in the
recording — and every tone's correlation votes on it at its own
lag. The sharp votes carry the ambiguous ones: a near-dwell or a
log sweep that mis-locks alone (measured: half a second off in a
four-tone mix) is pinned by the linear sweeps beside it. Returns
the clock origin in seconds; tone i's sweep begins at
origin + start_time_i.
Source code in src/visualdynamics/core/sine.py
vold_kalman
¶
vold_kalman(signal: ndarray, arguments: Sequence[ndarray], frequencies: Sequence[ndarray], starts: Sequence[int], dt: float, cycles: float = BASE_CYCLES, chunk: int | None = None) -> list[ndarray]
Every tone's complex envelope at every sample, solved jointly.
Solved in chunks (CHUNK samples, or chunk), each with a margin
of MARGIN_WINDOWS smoothing windows of the slowest tone active
at its edges solved beyond it and discarded: the penalty's memory
is a few windows, so the interior is the whole-record answer and
the memory is the chunk's, not the record's. _vold_kalman_whole
is the solve itself, on one span; _solve_piece cuts one piece
out and solves it, which the sampled reading uses too.
Source code in src/visualdynamics/core/sine.py
sample_levels
¶
sample_levels(history: Any, specification: SineSweepSpecification, cycles: float = BASE_CYCLES, windows: int = SAMPLE_WINDOWS, tones: Sequence[str] | None = None, onsets: dict[str, float] | None = None, ticker: Any = None) -> list[dict[str, Any]]
The levels read at windows stretches of each tone, cheaply.
The sine view's preview and the automatic smoothing's measurement:
rather than solve the whole record, solve one smoothing window's
worth at each of windows evenly spaced points along each tone
(with the chunked solve's margin around it), and read the
amplitude and the noise floor at the center. Costs a few short
solves per channel whatever the record's length, so it can run on
every edit of the setting.
Returns, per wanted tone, a dict: 'tone', 'frequency' (the sampled points), 'seconds', 'amplitude' and 'floor' (channels × points, debiased the way the full extraction is), 'scatter_db' (channels × points, the predicted standard deviation of a reading at this smoothing, from the floor against the amplitude; infinite where the tone is under the floor), and 'cycles'.
Source code in src/visualdynamics/core/sine.py
scatter_db
¶
The predicted standard deviation of a reading, in dB, from the debiased amplitude and the noise floor beside it.
The envelope is the tone plus a complex noise of power floor²; half of that power lies along the tone, so the amplitude's standard deviation is floor/√2, and in decibels of the amplitude 8.686 times their ratio. Infinite where the tone is under the floor — there is no reading to scatter around.
Source code in src/visualdynamics/core/sine.py
suggest_cycles
¶
suggest_cycles(history: Any, specification: SineSweepSpecification, target_db: float = TARGET_SCATTER_DB, tones: Sequence[str] | None = None, onsets: dict[str, float] | None = None, ticker: Any = None) -> float
The smoothing the data asks for: the first rung of
CYCLES_LADDER at which the predicted scatter of the readings is
under target_db (one standard deviation).
Measured at BASE_CYCLES with sample_levels, where the noise
the smoothing lets through is read beside the tone it finds; the
scatter falls as the square root of the smoothing, so the rung
follows from one measurement. The median over the sampled points
and channels speaks for a tone — a resonance or a dropout should
not set the smoothing for a whole sweep — and the widest-asking
tone speaks for the recording, since one setting rides it. The
ladder is capped where a tone's smoothing window at its lowest
frequency would reach a quarter of its own span: past that the
reading is the sweep's mean, not a level along it. A tone under
the floor at the base smoothing asks for the top of the ladder.
Source code in src/visualdynamics/core/sine.py
extract_sine
¶
extract_sine(history: Any, specification: SineSweepSpecification, tones: Sequence[str] | None = None, onsets: dict[str, float] | None = None, cycles: float | None = None, points_per_window: float = 2.0, refine: bool = True, target_db: float = TARGET_SCATTER_DB, workers: int | None = None, progress: Callable[[int, int], None] | None = None) -> SineLevelSet
Read each tone's level out of a recording, against its own sweep.
Reconstruct every wanted tone's sweep from the specification's
breakpoints, find where the environment's clock begins in the
recording (joint matched filter; onsets overrides per tone name,
and the found value rides the result as .onset), then solve for
every tone's complex envelope on every control channel at once
with the second-order Vold-Kalman filter (vold_kalman): the
record modeled as the sum of the tones on their known sweeps, each
envelope held to a slow curve over cycles cycles of its own
instantaneous frequency. Joint, so crossing sweeps are separated
by their frequency histories rather than each reading the other
as noise — the documented limit of the tracking demodulation this
replaced (2026-09-03). Solved one chunk of the record at a time
(_read_levels), so the memory is a chunk's whatever the record's
length (2026-10-01).
cycles None is automatic (suggest_cycles): the smoothing
is climbed until the predicted scatter of the readings is under
target_db, measured from the recording itself. The smoothing
used rides the result as SineLevelSet.cycles.
The magnitude is debiased: a noisy envelope's magnitude reads
high, so the noise power left in the residual around each
reading, scaled by the filter's equivalent averaging length, is
subtracted from the squared magnitude before the square root — a
planted amplitude under 4x its own RMS of noise reads back within
a fraction of a dB. Where the tone is under that noise the reading
is reported at the floor and flagged (SineLevel.below_floor)
rather than at zero. The controller's own live tracker carries the
raw bias, which is worth remembering when the two are compared.
With refine, the sweep clock is checked against the recording:
each tone's envelope phase slope is fitted chunk by chunk
(_clock_correction) and, where the implied frequency offset
reaches REFINE_FRACTION of the filter's bandwidth at either end
of the sweep, the argument is moved by it and the solve repeated,
up to three times. The offset applied at the end of each tone's
sweep rides the result as SineLevel.drift_hz, zero when none was
needed.
workers is how many processes the pieces are spread over
(Brandon, 2026-10-01: use the cores): None picks the cores up to
MAX_WORKERS, and spreads only when the work is more than
PARALLEL_FLOOR samples times channels, since the workers take a
second each to start; 1 does everything here. The numbers are the
same either way — each piece and channel is one task, and a task
is the serial code.
The envelope is read every window/points_per_window along the
sweep, one (almost) independent reading each. Returns a
SineLevelSet — one object, one SineLevel per tone inside,
frequencies ascending whichever way the tone swept, each line
stamped with the second it was measured. A recording that ends
before a tone does yields the lines it reached — the coverage the
comparison reports.
Source code in src/visualdynamics/core/sine.py
1860 1861 1862 1863 1864 1865 1866 1867 1868 1869 1870 1871 1872 1873 1874 1875 1876 1877 1878 1879 1880 1881 1882 1883 1884 1885 1886 1887 1888 1889 1890 1891 1892 1893 1894 1895 1896 1897 1898 1899 1900 1901 1902 1903 1904 1905 1906 1907 1908 1909 1910 1911 1912 1913 1914 1915 1916 1917 1918 1919 1920 1921 1922 1923 1924 1925 1926 1927 1928 1929 1930 1931 1932 1933 1934 1935 1936 1937 1938 1939 1940 1941 1942 1943 1944 1945 1946 1947 1948 1949 1950 1951 1952 1953 1954 1955 1956 1957 1958 1959 1960 1961 1962 1963 1964 1965 1966 1967 1968 1969 1970 1971 1972 1973 1974 1975 1976 1977 1978 | |