Skip to content

visualdynamics.core.averaging

averaging

How a time history is cut into frames before it is averaged.

A spectral average is not one number but a recipe: where to start, how long each frame is, how far they overlap, what window shapes them, and how many of them there are. Those five are independent; everything else — the hop between frames, where the analysis ends, how long it runs — follows from them, and is derived here rather than stored, so nothing can disagree.

The frame count is what is kept, not the stop time. Dragging the end of the analysis in the GUI adds or removes whole frames, because half a frame is not an average.

Classes:

Name Description
Averaging

The six independent numbers, and everything else derived.

Functions:

Name Description
normalise_detrend

The key for a detrend named any of the ways it might be.

normalise_window

The key for a window named any of the ways a file might name it.

from_span

The averaging a dragged span means, in whole frames.

window_shape

The window itself, as the values each frame is multiplied by.

Classes

Averaging dataclass

Averaging(
    frame_length: int,
    overlap: float = 0.0,
    window: str = "hann",
    frames: int = 1,
    start: float = 0.0,
    detrend: str = "none",
    window_parameter: float | None = None,
)

The six independent numbers, and everything else derived.

start is in seconds from the beginning of the record; overlap is a fraction of a frame, so 0.5 is the usual half-overlap; frames is how many are averaged per record. A history that already holds one frame per record — a burst-random run saved as 20 captures — is described by a frame the length of a record with frames=1, which is what for_records builds and what averaging with no parameters has always done.

There is no zero-padding field, and there was for two days (2026-08-27/28, pad). It was removed once the mode fitter was shown not to need it — the apparent accuracy it bought was the fitter's own search railing on a leash, fixed in the search — and what remained was presentation: interpolated lines that resolve nothing new. The demonstration projects were regenerated rather than kept compatible with a two-day experiment.

Methods:

Name Description
for_records

Each record its own frame, which is what a stored capture is.

shape

This averaging's window, as the values each frame is

stop

When the analysis ends, in seconds — derived, never stored.

frame_bounds

[(start, stop)] in seconds, one per frame, overlaps included —

fits

Is there record enough for every frame asked for?

most_frames

How many whole frames the record can carry from start.

levels

(level per frame, how many levels).

rail

Where the flat rail's slots sit, in fractions of the

filled

This averaging with as many frames as actually fit.

Attributes:

Name Type Description
hop int

Samples from the start of one frame to the start of the next.

span int

Samples the whole analysis covers, first frame to last.

Attributes
hop property
hop: int

Samples from the start of one frame to the start of the next.

span property
span: int

Samples the whole analysis covers, first frame to last.

Methods:
for_records classmethod
for_records(
    samples: int, window: str = "boxcar"
) -> Averaging

Each record its own frame, which is what a stored capture is.

Everything but the window follows from the record: it starts where the record starts, runs its whole length, and there is no second frame inside it to overlap with. The window is still the user's, so a saved capture can be re-averaged under Hann without the frames themselves moving.

Source code in src/visualdynamics/core/averaging.py
@classmethod
def for_records(cls, samples: int,
                window: str = 'boxcar') -> Averaging:
    """Each record its own frame, which is what a stored capture is.

    Everything but the window follows from the record: it starts
    where the record starts, runs its whole length, and there is no
    second frame inside it to overlap with. The window is still the
    user's, so a saved capture can be re-averaged under Hann without
    the frames themselves moving.
    """
    return cls(frame_length=samples, overlap=0.0, window=window,
               frames=1, start=0.0)
shape
shape() -> ndarray

This averaging's window, as the values each frame is multiplied by — window_shape with this frame length and this parameter, so no caller can pair them wrongly.

Returns:

Type Description
ndarray

One value per sample of a frame.

Source code in src/visualdynamics/core/averaging.py
def shape(self) -> np.ndarray:
    """This averaging's window, as the values each frame is
    multiplied by — `window_shape` with this frame length and
    this parameter, so no caller can pair them wrongly.

    Returns
    -------
    numpy.ndarray
        One value per sample of a frame.
    """
    return window_shape(self.window, self.frame_length,
                        self.window_parameter)
stop
stop(sample_rate: float) -> float

When the analysis ends, in seconds — derived, never stored.

Source code in src/visualdynamics/core/averaging.py
def stop(self, sample_rate: float) -> float:
    """When the analysis ends, in seconds — derived, never stored."""
    return (self.start_sample(sample_rate) + self.span) / sample_rate
frame_bounds
frame_bounds(
    sample_rate: float,
) -> list[tuple[float, float]]

[(start, stop)] in seconds, one per frame, overlaps included — what the time history plot shades.

Source code in src/visualdynamics/core/averaging.py
def frame_bounds(self, sample_rate: float) -> list[tuple[float, float]]:
    """[(start, stop)] in seconds, one per frame, overlaps included —
    what the time history plot shades."""
    first = self.start_sample(sample_rate)
    return [((first + i * self.hop) / sample_rate,
             (first + i * self.hop + self.frame_length) / sample_rate)
            for i in range(self.frames)]
fits
fits(samples: int, sample_rate: float) -> bool

Is there record enough for every frame asked for?

Source code in src/visualdynamics/core/averaging.py
def fits(self, samples: int, sample_rate: float) -> bool:
    """Is there record enough for every frame asked for?"""
    return self.start_sample(sample_rate) + self.span <= samples
most_frames
most_frames(samples: int, sample_rate: float) -> int

How many whole frames the record can carry from start.

What the drag handle snaps to: a frame is added only when there is room for all of it.

Source code in src/visualdynamics/core/averaging.py
def most_frames(self, samples: int, sample_rate: float) -> int:
    """How many whole frames the record can carry from `start`.

    What the drag handle snaps to: a frame is added only when there
    is room for all of it.
    """
    room = samples - self.start_sample(sample_rate) - self.frame_length
    return 0 if room < 0 else 1 + room // self.hop
levels
levels(sample_rate: float) -> tuple[list[int], int]

(level per frame, how many levels).

Frames that overlap cannot share a row without their windows running through each other, so each takes the lowest row whose last frame has already ended. With no overlap they all sit on one row; at half overlap they alternate between two.

Here rather than in the overlay because the report draws the same marks and the two must not drift — a figure that stacked them differently from the app would be describing a different analysis.

Source code in src/visualdynamics/core/averaging.py
def levels(self, sample_rate: float) -> tuple[list[int], int]:
    """(level per frame, how many levels).

    Frames that overlap cannot share a row without their windows
    running through each other, so each takes the lowest row whose
    last frame has already ended. With no overlap they all sit on
    one row; at half overlap they alternate between two.

    Here rather than in the overlay because the report draws the
    same marks and the two must not drift — a figure that stacked
    them differently from the app would be describing a different
    analysis.
    """
    ends, levels = [], []
    for low, high in self.frame_bounds(sample_rate):
        for level, end in enumerate(ends):
            if low >= end:
                ends[level] = high
                levels.append(level)
                break
        else:
            ends.append(high)
            levels.append(len(ends) - 1)
    return levels, max(len(ends), 1)
rail
rail(sample_rate: float) -> dict[str, Any]

Where the flat rail's slots sit, in fractions of the visible height — the 2-D overlay's own geometry, and the report's, from one set of numbers.

Here for the same reason levels is: the app draws this rail over a live plot and the report draws it in a canvas, and a figure whose rail sat differently from the app's would be describing a different analysis. The page used to restate these constants in its JavaScript and was held to them by matching strings, which is a weaker seam than being told.

Returns the level of each frame, how many levels there are, one slot's share, the glyph's height inside its slot, the tick under each end, the baseline of every frame, and foot — the share of the height the trace keeps once the rail has its room above.

Source code in src/visualdynamics/core/averaging.py
def rail(self, sample_rate: float) -> dict[str, Any]:
    """Where the flat rail's slots sit, in fractions of the
    visible height — the 2-D overlay's own geometry, and the
    report's, from one set of numbers.

    Here for the same reason `levels` is: the app draws this rail
    over a live plot and the report draws it in a canvas, and a
    figure whose rail sat differently from the app's would be
    describing a different analysis. The page used to restate
    these constants in its JavaScript and was held to them by
    matching strings, which is a weaker seam than being told.

    Returns the level of each frame, how many levels there are,
    one slot's share, the glyph's height inside its slot, the tick
    under each end, the baseline of every frame, and `foot` — the
    share of the height the trace keeps once the rail has its
    room above.
    """
    levels, count = self.levels(sample_rate)
    slot = min(RAIL_SLOT, RAIL_BAND / count)
    glyph = slot * GLYPH_SHARE
    return {
        'levels': list(levels), 'rows': count,
        'slot': slot, 'glyph': glyph, 'cap': RAIL_CAP,
        'baselines': [RAIL_TOP - glyph - level * slot
                      for level in levels],
        'band_alpha': BAND_PEAK_ALPHA, 'glyph_alpha': GLYPH_ALPHA,
        # the trace keeps this share of the height and the rail
        # takes the rest; never less than a quarter, or four
        # levels of rail would walk down into the data
        'foot': max(RAIL_TOP - glyph - (count - 1) * slot - RAIL_GAP,
                    0.25)}
filled
filled(samples: int, sample_rate: float) -> Averaging

This averaging with as many frames as actually fit.

Source code in src/visualdynamics/core/averaging.py
def filled(self, samples: int, sample_rate: float) -> Averaging:
    """This averaging with as many frames as actually fit."""
    return replace(self, frames=max(self.most_frames(samples,
                                                     sample_rate), 1))

Functions:

normalise_detrend

normalise_detrend(name: str | None) -> str

The key for a detrend named any of the ways it might be.

Source code in src/visualdynamics/core/averaging.py
def normalise_detrend(name: str | None) -> str:
    """The key for a detrend named any of the ways it might be."""
    if name is None:
        return 'none'
    key = str(name).strip().lower()
    key = _DETREND_ALIASES.get(key, key)
    if key not in DETRENDS:
        raise ValueError(
            f'unknown detrend {name!r}; visualdynamics has '
            + ', '.join(DETRENDS))
    return key

normalise_window

normalise_window(name: str | None) -> str

The key for a window named any of the ways a file might name it.

Source code in src/visualdynamics/core/averaging.py
def normalise_window(name: str | None) -> str:
    """The key for a window named any of the ways a file might name it."""
    if name is None:
        return 'boxcar'
    key = str(name).strip().lower().replace('-', '_')
    key = _ALIASES.get(key, key)
    if key not in WINDOWS:
        raise ValueError(
            f'unknown window {name!r}; visualdynamics has ' + ', '.join(WINDOWS))
    return key

from_span

from_span(
    averaging: Averaging,
    low: float,
    high: float,
    sample_rate: float,
    samples: int,
) -> Averaging

The averaging a dragged span means, in whole frames.

The frame length and the window are the table's; a drag says only where the analysis starts and how many frames it holds. One implementation for both editors — the 2-D region and the stage's handles commit through this, so they cannot disagree about what a span means.

Source code in src/visualdynamics/core/averaging.py
def from_span(averaging: Averaging, low: float, high: float,
              sample_rate: float, samples: int) -> Averaging:
    """The averaging a dragged span means, in whole frames.

    The frame length and the window are the table's; a drag says only
    where the analysis starts and how many frames it holds. One
    implementation for both editors — the 2-D region and the stage's
    handles commit through this, so they cannot disagree about what a
    span means.
    """
    rate = sample_rate
    n = averaging.frame_length
    last_start = max(samples - n, 0) / rate
    start = min(max(float(low), 0.0), last_start)
    moved = replace(averaging, start=start)
    width = round((float(high) - start) * rate)
    wanted = 1 if width < n else 1 + (width - n) // moved.hop
    frames = max(1, min(int(wanted), moved.most_frames(samples, rate)))
    return replace(moved, frames=frames)

window_shape

window_shape(
    name: str | None,
    length: int,
    parameter: float | None = None,
) -> ndarray

The window itself, as the values each frame is multiplied by.

scipy's get_window, periodic (fftbins=True) — the convention for spectral work, where a frame is one period of an assumed-repeating signal.

These were hand-written cosine sums until 2026-08-28, because scipy was a test-only dependency and fifty lines was the better trade (principle 7). scipy became a runtime dependency that week, and the trade reversed: the tables were verified identical to scipy's to machine epsilon at every length that can hold a frame before being deleted, and the sdynpy oracle now checks the windows through the whole spectral pipeline on every run. The one behavioural change is a one-sample window, which reads 1.0 where the old taper read its own first point — and a one-sample frame cannot exist (Averaging requires two).

Source code in src/visualdynamics/core/averaging.py
def window_shape(name: str | None, length: int,
                 parameter: float | None = None) -> np.ndarray:
    """The window itself, as the values each frame is multiplied by.

    scipy's `get_window`, periodic (`fftbins=True`) — the convention
    for spectral work, where a frame is one period of an
    assumed-repeating signal.

    These were hand-written cosine sums until 2026-08-28, because scipy
    was a test-only dependency and fifty lines was the better trade
    (principle 7). scipy became a runtime dependency that week, and the
    trade reversed: the tables were verified identical to scipy's to
    machine epsilon at every length that can hold a frame before being
    deleted, and the sdynpy oracle now checks the windows through the
    whole spectral pipeline on every run. The one behavioural change is
    a one-sample window, which reads 1.0 where the old taper read its
    own first point — and a one-sample frame cannot exist
    (`Averaging` requires two).
    """
    from scipy.signal import get_window

    key = normalise_window(name)
    if length < 1:
        raise ValueError('a window needs at least one sample')
    if key in WINDOW_PARAMETERS:
        what, default, _bounds = WINDOW_PARAMETERS[key]
        del what
        return get_window((key, default if parameter is None
                           else float(parameter)), length, fftbins=True)
    return get_window(key, length, fftbins=True)