Skip to content

visualdynamics.gui.filter_panel

filter_panel

The filter settings, as a table beside the time history.

The pass band is set here (its kind, its edges and the order), and the response of the filter they make is drawn under the table, magnitude against frequency, so the shape is seen where it is set. Beside the settings are the values that follow from them, all read off that same curve: Nyquist, the level an octave beyond a single corner, and where the filter is at -3 dB.

One kind is shown at a time: a low-pass shows one corner, a high-pass one, and a band-pass its two edges, so only what applies is on screen (principle 3). The kind cannot disagree with the numbers, because core.filters.Filtering derives it from which edges are set.

The panel shows the settings and reports when they change. Its Apply Filter button asks for a filtered copy of the history with the current settings, which the window makes with the same project verb a script calls. The averaging panel works the same way, because both hold settings that are stored with the record.

Classes:

Name Description
FilterResponsePlot

The filter's response: a small plot of magnitude in dB against log

FilterPanel

The filter settings table.

Classes

FilterResponsePlot

FilterResponsePlot(parent: QWidget | None = None)

Bases: PreviewPlot

The filter's response: a small plot of magnitude in dB against log frequency, whose dashed corner lines can be dragged to set the corners.

It draws core.filters.response, the squared magnitude, because the filter is run forward and backward and that is the response the data gets. The curve is computed there and only drawn here, so the report can draw the same curve from the same numbers.

The plot has no legend and no axis titles, only decade grid lines and a dashed line at each corner (one for a low- or high-pass, two for a band-pass). It is in a 210-pixel column and shows only the filter's shape, so that it does not compete with the record beside it. edge_moved reports each move of a drag and edge_settled the release, each with the edge ('low' or 'high') and its position in Hz.

Methods:

Name Description
apply_theme

Redraw the axes, the curve and the corner lines in a new

show_response

Draw one response.

Source code in src/visualdynamics/gui/filter_panel.py
def __init__(self, parent: QWidget | None = None) -> None:
    super().__init__(parent)
    import pyqtgraph as pg

    self.plot.setYRange(self.FLOOR, 5.0, padding=0)
    self.curve: Any = self.plot.plot(
        [], [], pen=pg.mkPen(self.colors['filter_preview'], width=2))
    # The lines *are* the corner controls (Brandon, 2026-08-25) —
    # better than sliders beside them, which would be a second
    # thing to keep in step with the first. `sigDragged` fires
    # only for a real drag, never for the `setValue` that restates
    # a line, so the two cannot chase each other round.
    self.lines: dict[str, Any] = {}
    for which in ('low', 'high'):
        line = pg.InfiniteLine(
            angle=90, movable=True,
            pen=pg.mkPen(self.colors['plot_foreground'],
                         width=1, style=Qt.PenStyle.DashLine),
            hoverPen=pg.mkPen(self.colors['filter_preview'], width=2))
        line.setCursor(Qt.CursorShape.SizeHorCursor)
        line.sigDragged.connect(
            lambda item, which=which: self._dragged(which, item))
        line.sigPositionChangeFinished.connect(
            lambda item, which=which: self._drag_finished(which, item))
        self.plot.addItem(line)
        self.lines[which] = line
Methods:
apply_theme
apply_theme(colors: Mapping[str, str]) -> None

Redraw the axes, the curve and the corner lines in a new theme's colors.

Source code in src/visualdynamics/gui/filter_panel.py
def apply_theme(self, colors: Mapping[str, str]) -> None:
    """Redraw the axes, the curve and the corner lines in a new
    theme's colors."""
    import pyqtgraph as pg

    super().apply_theme(colors)
    self.curve.setPen(pg.mkPen(colors['filter_preview'], width=2))
    for line in self.lines.values():
        line.setPen(pg.mkPen(colors['plot_foreground'], width=1,
                             style=Qt.PenStyle.DashLine))
        line.setHoverPen(pg.mkPen(colors['filter_preview'], width=2))
show_response
show_response(frequencies: Any, magnitude: Any, low: float | None, high: float | None) -> None

Draw one response.

frequencies is in Hz and magnitude in dB. Frequencies at or below zero are left out, and the curve is clipped at FLOOR (-80 dB). low and high are the pass-band edges in Hz, each drawn as a dashed line when it is set and hidden when it is None. A dragged line stays on the axis, stops short of Nyquist, and keeps a band-pass's edges at least EDGE_GAP (0.1 Hz) apart.

The curve is given in Hz and everything else in log10. In log mode pyqtgraph transforms its data items itself, but an InfiniteLine and setXRange are placed in view coordinates, which on that axis are already the logarithms.

Source code in src/visualdynamics/gui/filter_panel.py
def show_response(self, frequencies: Any, magnitude: Any,
                  low: float | None, high: float | None) -> None:
    """Draw one response.

    `frequencies` is in Hz and `magnitude` in dB. Frequencies at or
    below zero are left out, and the curve is clipped at `FLOOR`
    (-80 dB). `low` and `high` are the pass-band edges in Hz, each
    drawn as a dashed line when it is set and hidden when it is None.
    A dragged line stays on the axis, stops short of Nyquist, and
    keeps a band-pass's edges at least `EDGE_GAP` (0.1 Hz) apart.

    The curve is given in Hz and everything else in log10. In log
    mode pyqtgraph transforms its data items itself, but an
    `InfiniteLine` and `setXRange` are placed in view coordinates,
    which on that axis are already the logarithms.
    """
    # Handing the curve log10 too drew the decades twice over: the
    # axis ran 0.01 to 3 Hz for a record whose Nyquist is 1024
    # (Brandon, 2026-08-25, found by pinning the passband).
    import numpy as np

    frequencies = np.asarray(frequencies, dtype=float)
    magnitude = np.asarray(magnitude, dtype=float)
    keep = frequencies > 0
    # clipped at the floor rather than dropped: a curve that ends
    # mid-air reads as missing data, where a filter that has run
    # out of dB has simply stopped
    self.curve.setData(frequencies[keep],
                       np.maximum(magnitude[keep], self.FLOOR))
    left = float(np.log10(frequencies[keep][0]))
    right = float(np.log10(frequencies[keep][-1]))
    self.plot.setXRange(left, right, padding=0)
    # a drag cannot leave the axis, cannot reach Nyquist — where
    # `filtered` refuses — and a band-pass's edges cannot cross:
    # each line stops one box-step short of the other. Clamped
    # here rather than caught later, which is this interface's
    # rule: refuse invalid state at entry rather than reporting
    # it afterwards
    floor = 10.0 ** left
    self.lines['low'].setVisible(low is not None)
    self.lines['high'].setVisible(high is not None)
    if low is not None:
        top = right - 1e-3 if high is None else float(
            np.log10(max(high - EDGE_GAP, floor)))
        self.lines['low'].setBounds([left, top])
        self.lines['low'].setValue(float(np.log10(max(low, 1e-9))))
    if high is not None:
        bottom = left if low is None else float(
            np.log10(max(low + EDGE_GAP, floor)))
        self.lines['high'].setBounds([bottom, right - 1e-3])
        self.lines['high'].setValue(float(np.log10(max(high, 1e-9))))

FilterPanel

FilterPanel(parent: QWidget | None = None)

Bases: QWidget

The filter settings table.

Each edit is reported on changed as a whole Filtering, and the Apply Filter button on apply_asked. A drag of a corner line reports once, when the mouse is released.

Methods:

Name Description
show_history

Show a time history and the filtering set on it.

set_filtering

Show a filtering in the editors, without emitting changed.

filtering

The filtering the editors show now.

Source code in src/visualdynamics/gui/filter_panel.py
def __init__(self, parent: QWidget | None = None) -> None:
    super().__init__(parent)
    self.sample_rate: float = 1.0
    #: the record the parameters describe, for suggesting again
    self._history = None
    #: set while the panel is writing to its own editors, so that
    #: restating a clamped value does not read as a fresh edit
    self._loading = False
    #: set while a corner line is under the mouse. A drag emits
    #: `changed` once, at release: every listener refilters every
    #: visible curve, and doing that per pixel of drag froze the
    #: whole panel — response plot included — on a 260-record
    #: history (Brandon, 2026-08-28). The cheap local restate
    #: still runs per move, so the box and the curve track the
    #: hand; only the expensive work waits.
    self._dragging = False
    #: the kind on show before the last Type switch, so a switch
    #: can carry the right number across — leaving a band-pass
    #: toward a low-pass takes the top edge, toward a high-pass
    #: the bottom one
    self._was = 'low-pass'

    self.title: QLabel = QLabel('Filter')
    grid = panel_grid(self, self.title)

    self.kind_box: QComboBox = QComboBox()
    self.kind_box.addItems(KIND_LABELS)
    self.kind_box.setToolTip(
        'Which band passes: everything below the corner, everything '
        'above it, or the stretch between two corners. Zero phase '
        'in every case')

    self.corner_box: DoubleSpinBox = DoubleSpinBox()
    self.corner_box.setToolTip(
        'The corner. Zero phase (applied forward and backward), so '
        'the peaks stay at their measured instants — and so the '
        'filter is −6 dB here, not −3. Drag the dashed line on the '
        'response below to set it by hand')

    self.from_box: DoubleSpinBox = DoubleSpinBox()
    self.from_box.setToolTip(
        'The pass band’s lower edge — the filter is −6 dB here. '
        'Drag the left dashed line on the response to set it by '
        'hand')

    self.to_box: DoubleSpinBox = DoubleSpinBox()
    self.to_box.setToolTip(
        'The pass band’s upper edge — the filter is −6 dB here. '
        'Drag the right dashed line on the response to set it by '
        'hand')

    for box in (self.corner_box, self.from_box, self.to_box):
        box.setDecimals(1)
        box.setSuffix(' Hz')

    self.order_box: SpinBox = SpinBox()
    self.order_box.setRange(1, 12)
    self.order_box.setToolTip(
        'The Butterworth design order. The forward-and-backward '
        'pass doubles the effective roll-off; a band-pass carries '
        'this many poles per edge')

    # every row is built and the kind decides which are on screen
    # — show only what applies (principle 3): a low-pass with a
    # grayed pair of band edges would be a question, not a control
    rows = (('kind', 'Type', self.kind_box),
            ('corner', 'Corner', self.corner_box),
            ('from', 'From', self.from_box),
            ('to', 'To', self.to_box),
            ('order', 'Order', self.order_box))
    self._labels, self._editors = add_keyed_settings(grid, rows)

    add_rule(grid, len(rows) + 1)

    # what follows from the settings, shown so the trade is
    # visible while it is being made rather than after the
    # filtered record comes out
    self.derived: dict[str, QLabel] = {}
    self.derived_names: dict[str, QLabel] = {}
    derived = (('nyquist', 'Nyquist'), ('octave', '@ 2× corner'),
               ('half_power', '−3 dB at'))
    for key, (name, value) in add_derived(grid, derived, len(rows) + 2).items():
        self.derived[key] = value
        self.derived_names[key] = name

    # the shape of the filter being made, drawn where it is set
    # (Brandon, 2026-08-25). Small on purpose: it answers "what
    # does this do to my data" at a glance, and the stage beside
    # it answers "what did it do to *this* record".
    self.response: FilterResponsePlot = FilterResponsePlot()
    grid.addWidget(self.response, len(rows) + 2 + len(derived),
                   0, 1, 2)

    # No Suggest button beside these (Brandon, 2026-08-30): the
    # panel already *opens* on the suggestion when the record has
    # no filtering set, so the button could only ever undo edits
    # back to the starting default — a reset button in a smarter
    # name, and the truncate panel's rule holds here too: the
    # boxes and the drag lines reach every value.

    # the act itself, where its settings are set — this used to
    # be a note apologizing that the record was made elsewhere,
    # and a note explaining that the act lives somewhere else is
    # the interface admitting the act is in the wrong place
    # (Brandon, 2026-08-28).
    self.apply_button: QPushButton = QPushButton('Apply Filter')
    self.apply_button.setToolTip(
        'Make the filtered record — exactly the filter drawn '
        'above — beside this one')
    self.apply_button.clicked.connect(self.apply_asked.emit)
    grid.addWidget(self.apply_button, len(rows) + 3 + len(derived),
                   0, 1, 2)
    grid.setRowStretch(len(rows) + 4 + len(derived), 1)

    commit_on_enter(self.corner_box, self.from_box, self.to_box,
                    self.order_box)
    self.kind_box.currentIndexChanged.connect(self._kind_switched)
    for box in (self.corner_box, self.from_box, self.to_box,
                self.order_box):
        box.valueChanged.connect(self._edited)
    # dragging a line and typing in its box are two views of one
    # number: the drag writes the box, and the box's own signal
    # carries it on from there, so there is one edit path
    self.response.edge_moved.connect(self._edge_dragged)
    self.response.edge_settled.connect(self._edge_settled)
    self._show_kind('low-pass')
Methods:
show_history
show_history(history: TimeHistory, filtering: Filtering) -> None

Show a time history and the filtering set on it.

The limit on the edges, just under Nyquist, comes from the history's sample rate and not from the values already in the boxes.

Source code in src/visualdynamics/gui/filter_panel.py
def show_history(self, history: TimeHistory,
                 filtering: Filtering) -> None:
    """Show a time history and the filtering set on it.

    The limit on the edges, just under Nyquist, comes from the
    history's sample rate and not from the values already in the
    boxes.
    """
    self._history = history
    self.sample_rate = history.sample_rate
    self.set_filtering(filtering)
set_filtering
set_filtering(filtering: Filtering) -> None

Show a filtering in the editors, without emitting changed.

Source code in src/visualdynamics/gui/filter_panel.py
def set_filtering(self, filtering: Filtering) -> None:
    """Show a filtering in the editors, without emitting `changed`."""
    kind = filtering.kind
    self._loading = True
    try:
        # the limit first, for the same reason the averaging panel
        # sets its limits first: a spin box clamps to the range it
        # has at the moment it is written
        nyquist = self.sample_rate / 2.0
        top = max(nyquist * 0.999, 0.1)
        for box in (self.corner_box, self.from_box, self.to_box):
            box.setRange(0.1, top)
            box.setSingleStep(max(nyquist / 100.0, 0.1))
        self.kind_box.setCurrentIndex(KINDS.index(kind))
        if kind == 'low-pass':
            self.corner_box.setValue(filtering.high)
        elif kind == 'high-pass':
            self.corner_box.setValue(filtering.low)
        else:
            self.corner_box.setValue(filtering.high)
        # the hidden boxes still hold something coherent, so
        # switching Type proposes a filter rather than a blank:
        # the top edge mirrors the corner, the bottom sits a
        # decade under it unless the filtering says otherwise
        self.to_box.setValue(filtering.high if filtering.high
                             is not None else top)
        self.from_box.setValue(
            filtering.low if filtering.low is not None
            else round(max(self.to_box.value() / 10.0, 0.1), 1))
        if kind == 'band-pass':
            self._constrain()
        self.order_box.setValue(filtering.order)
    finally:
        self._loading = False
    self._was = kind
    self._show_kind(kind)
    self._restate(self.filtering())
filtering
filtering() -> Filtering

The filtering the editors show now.

Source code in src/visualdynamics/gui/filter_panel.py
def filtering(self) -> Filtering:
    """The filtering the editors show now."""
    kind = KINDS[self.kind_box.currentIndex()]
    order = self.order_box.value()
    if kind == 'low-pass':
        return Filtering(high=self.corner_box.value(), order=order)
    if kind == 'high-pass':
        return Filtering(low=self.corner_box.value(), order=order)
    return Filtering(low=self.from_box.value(),
                     high=self.to_box.value(), order=order)

Functions: