Skip to content

visualdynamics.gui.record_grid

record_grid

Sub-items shown as a grid, inside the object's own expansion in the tree.

Every object that expands into records, channels, modes or other sub-items expands into a grid, even one column wide. Rows are DOFs (modes for a shape set, names for photos, the two mode numbers for a matched pair), and columns are whatever tells records apart besides their row: reference DOFs for a matrix of measurements, the capture for repeated averages, or a single unlabeled column when the row alone identifies the record. With one format for every object, selecting, deleting and the icons work the same way everywhere.

A set that does not fill its rectangle (a record deleted out of a full matrix, or an FRF measured for some pairs only) stays a grid, with disabled cells where the missing records would be. The disabled cells show exactly what is absent.

The rows of time data channels have a Role column first: reference, response or monitor, changed by clicking it. Row labels that are coordinates or photo names, and the reference columns of a matrix, are edited by double-clicking them.

Geometry is the exception: its categories stay a list.

Classes:

Name Description
RowKey

What identifies a row: a DOF, what it measures, and, only when

GridPlan

Everything a grid needs, whatever kind of object it maps.

RecordGrid

Sub-items laid out as rows against columns, whatever their kind.

Functions:

Name Description
row_keys

One RowKey per record.

row_labels

Headers for row_keys: the DOF, with a marker only where one is

short_labels

Column headers narrow enough that a 20-average grid is usable.

grid_axes

(rows, columns) for an object shown as a grid, or None for an

grid_plan

The GridPlan for an object: a data array, a channel table, a

sine_dofs

The control DOFs a sine specification or a level set covers, in

Classes

RowKey

Bases: NamedTuple

What identifies a row: a DOF, what it measures, and, only when those two are not enough, which of the channels sharing them it is.

GridPlan

Bases: NamedTuple

Everything a grid needs, whatever kind of object it maps.

kind says what a cell is: 'record', 'channel', 'mode', 'photo', 'match' (a matched pair of modes) or 'dof' (a control DOF of a sine specification or level set), the same words the tree uses for a selection. cells maps (row, column) to the item index, and positions absent from it are holes.

RecordGrid

RecordGrid(data: Any, parent: QWidget | None = None)

Bases: QTableWidget

Sub-items laid out as rows against columns, whatever their kind.

Selecting cells is how sub-items are chosen. The selection is the only state, so nothing can disagree with it. kind says what a cell is, in the same words the tree uses ('record', 'channel', 'mode', 'photo', 'match' or 'dof'), so code that reads a selection works the same for every kind.

selection_changed is emitted when the selection changes, row_renamed and column_renamed when a row or column label is typed over, and role_changed when a row's role is changed.

Methods:

Name Description
row_roles

Each row's role, as the Role column shows it; empty for a

show_roles

Show every row's role without emitting role_changed, for a

set_row_role

Give a row a role, as picking it from the menu does, and emit

role_menu

The three roles for a row, the current one checked.

row_records

The record indices in a row, in column order.

edit_row_label

Open an editor over the row's header, in place, and return it.

edit_column_label

Open an editor over a reference column's header, in place, and

refresh_icons

Redraw every cell icon in place, without rebuilding the grid and

selected_records

Record indices for the selected cells, in row-major order.

select_records

Show records as selected, without emitting

sizeHint

The preferred_size.

minimumSizeHint

The preferred_size.

preferred_size

The size the tree should make room for.

Source code in src/visualdynamics/gui/record_grid.py
def __init__(self, data: Any, parent: QWidget | None = None) -> None:
    plan = grid_plan(data)
    # The Role column, where the rows are channels of time data. It
    # is the *last* logical column, moved to the front of the
    # header's visual order: the record cells keep the column
    # numbers everything else counts on (`item(row, 0)` is the
    # first record, in the window and in every test), and the roles
    # still read first, where a person looks for them.
    self.role_column: int | None = (len(plan.columns)
                                    if plan.roles is not None else None)
    super().__init__(len(plan.rows),
                     len(plan.columns) + (self.role_column is not None),
                     parent)
    self.kind: str = plan.kind
    self.row_keys: list[Any] = plan.rows
    # The header says the DOF and nothing else. What a row *measures* is
    # on the icon in every one of its cells — a force reads as a force at
    # a glance, where '[force]' spelled out in the label cost twice the
    # width and had to be read.
    self.responses: list[str] = plan.row_labels
    self.references: list[str] = plan.columns
    self.column_keys: list = plan.column_keys or [None] * len(plan.columns)
    self._icon_for = _icon_source(data, plan.kind)
    self.setHorizontalHeaderLabels(
        short_labels(plan.columns)
        + (['Role'] if self.role_column is not None else []))
    if self.role_column is not None:
        self.column_keys.append(None)
        self.horizontalHeaderItem(self.role_column).setToolTip(
            'What each channel is to an FRF: FRFs run from the '
            'references to the responses, and a monitor is in none. '
            'Click a role to change it.')
    # a marked column wears its quantity as the icon the cells
    # already use — '101Z+ (force)' spelled out is the width lesson
    # the row headers learned long ago
    marks = plan.column_marks or []
    for column, mark in enumerate(marks):
        if not mark:
            continue
        from .icons import quantity_icon

        item = self.horizontalHeaderItem(column)
        try:
            item.setIcon(quantity_icon(mark))
            item.setToolTip(f'{plan.columns[column]} — {mark}')
        except KeyError:     # a quantity with no icon: say the word
            item.setText(f'{plan.columns[column]} ({mark})')
    # a single unlabeled column has no header worth a strip of pixels
    # — unless the Ref column is there to be named
    self.horizontalHeader().setVisible(plan.columns != ['']
                                       or self.role_column is not None)
    self.setVerticalHeaderLabels(self.responses)
    # A qualified row label — '101Z+ [acceleration]' — is twice the width
    # of a DOF, and left to size the header it took 130 px of a 250 px
    # dock and left room for two columns. Cap it at a comfortable DOF and
    # let the long ones elide; the full text stays on the tooltip.
    header_rows = self.verticalHeader()
    # the cap is sized for the kind of label: a DOF for records and
    # channels, a 'Mode 12 — 45.6 Hz' for modes, which a DOF-wide cap
    # truncated to 'Mode 1 —'
    widest = ('Mode 00 \u2014 000.0 Hz' if plan.kind in ('mode', 'photo')
              else '000RX+')
    header_rows.setMaximumWidth(
        self.fontMetrics().horizontalAdvance(widest) + 16)
    for row, key in enumerate(plan.rows):
        self.verticalHeaderItem(row).setToolTip(
            f'{key.dof} \u2014 {shown_dimension(key.quantity)}')
    self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectItems)
    self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
    self.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
    self.verticalHeader().setDefaultSectionSize(ROW_HEIGHT)
    header = self.horizontalHeader()
    # sized to content either way, so a reference DOF header still fits
    # and a bare average number takes only what it needs — the minimum is
    # lowered because the style's default is wider than a 16 px icon
    header.setMinimumSectionSize(COLUMN_WIDTH)
    header.setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents)
    self.setIconSize(QSize(16, 16))

    self._records = {}
    for row, key in enumerate(plan.rows):
        if self.role_column is not None:
            cell = QTableWidgetItem()
            # enabled, never selectable: a role is a setting, not a
            # pick of the row's records
            cell.setFlags(Qt.ItemFlag.ItemIsEnabled)
            cell.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
            self.setItem(row, self.role_column, cell)
            self._show_role(row, plan.roles[row])
        for column, reference in enumerate(plan.columns):
            cell = QTableWidgetItem()
            cell.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
            index = plan.cells.get((row, column))
            if index is None:
                # a hole: this pair was never measured, or its record
                # was deleted. Disabled says "nothing here" precisely,
                # where a flat list said nothing at all.
                cell.setFlags(Qt.ItemFlag.NoItemFlags)
            else:
                mark = marks[column] if column < len(marks) else None
                cell.setIcon(self._icon_for(index))
                cell.setToolTip(
                    f'{key.dof} \u2014 '
                    f'{shown_dimension(key.quantity)}'
                    + (f' / {reference}' if reference else '')
                    + (f' ({mark})' if reference and mark else ''))
                self._records[(row, column)] = index
            self.setItem(row, column, cell)
    self.itemSelectionChanged.connect(self.selection_changed)
    if self.role_column is not None:
        self.cellClicked.connect(self._role_clicked)
        header.moveSection(self.role_column, 0)
    # A row label is typed over where it is the user's to say: a
    # photograph's name, and the coordinate of a record or a
    # channel — where the wrong assignment was made at the
    # instrument and is corrected here. A mode, a matched pair and a
    # tone are labeled by what they are and stay so. The columns of
    # a matrix are coordinates too, and edit the same way.
    # Double-clicking a label to change it is what the tree already
    # does for objects; this is the same gesture on a grid's edges.
    self.editable_rows: bool = plan.kind in ('photo', 'record', 'channel')
    self.editable_columns: bool = bool(plan.column_dofs)
    if self.editable_rows:
        self.verticalHeader().setSectionsClickable(True)
        self.verticalHeader().sectionDoubleClicked.connect(
            self.edit_row_label)
    if self.editable_columns:
        self.horizontalHeader().setSectionsClickable(True)
        self.horizontalHeader().sectionDoubleClicked.connect(
            self.edit_column_label)
Methods:
row_roles
row_roles() -> list[str]

Each row's role, as the Role column shows it; empty for a grid without one.

Source code in src/visualdynamics/gui/record_grid.py
def row_roles(self) -> list[str]:
    """Each row's role, as the Role column shows it; empty for a
    grid without one."""
    if self.role_column is None:
        return []
    return [self.item(row, self.role_column).data(Qt.ItemDataRole.UserRole)
            for row in range(self.rowCount())]
show_roles
show_roles(roles: Sequence[str]) -> None

Show every row's role without emitting role_changed, for a role set elsewhere (the linked channel table, a link).

Source code in src/visualdynamics/gui/record_grid.py
def show_roles(self, roles: Sequence[str]) -> None:
    """Show every row's role without emitting `role_changed`, for a
    role set elsewhere (the linked channel table, a link)."""
    if self.role_column is None:
        return
    for row, role in enumerate(roles):
        self._show_role(row, role)
set_row_role
set_row_role(row: int, role: str) -> None

Give a row a role, as picking it from the menu does, and emit role_changed when it changed. Raises ValueError for anything other than 'reference', 'response' or 'monitor'.

Source code in src/visualdynamics/gui/record_grid.py
def set_row_role(self, row: int, role: str) -> None:
    """Give a row a role, as picking it from the menu does, and emit
    `role_changed` when it changed. Raises ValueError for anything
    other than 'reference', 'response' or 'monitor'."""
    if role not in ROLE_TEXT:
        raise ValueError(f'{role!r} is not a role: '
                         + ', '.join(ROLE_TEXT))
    if self.row_roles()[row] == role:
        return
    self._show_role(row, role)
    self.role_changed.emit(row, role)
role_menu
role_menu(row: int) -> QMenu

The three roles for a row, the current one checked.

Source code in src/visualdynamics/gui/record_grid.py
def role_menu(self, row: int) -> QMenu:
    """The three roles for a row, the current one checked."""
    menu = QMenu(self)
    current = self.row_roles()[row]
    for role, reading in ROLE_READING.items():
        action = menu.addAction(f'{role.capitalize()} \u2014 '
                                + reading.split(' \u2014 ')[1])
        action.setCheckable(True)
        action.setChecked(role == current)
        action.triggered.connect(
            lambda _checked=False, role=role: self.set_row_role(row, role))
    return menu
row_records
row_records(row: int) -> list[int]

The record indices in a row, in column order.

Source code in src/visualdynamics/gui/record_grid.py
def row_records(self, row: int) -> list[int]:
    """The record indices in a row, in column order."""
    return [index for (r, _c), index in sorted(self._records.items())
            if r == row]
edit_row_label
edit_row_label(row: int) -> None

Open an editor over the row's header, in place, and return it.

The editor holds the row's coordinate (or a photo's name), not the label, so a '#2' marker is not part of it. A header is not a cell and Qt will not edit one, so the editor is a line edit laid over the section. It commits on Enter or on losing focus and abandons on Escape, as editing a name does everywhere else in the window, and a commit emits row_renamed.

The commit is queued, because acting on it rebuilds this grid, and rebuilding the widget an editor is in, from inside that editor's own signal, can crash the application.

Source code in src/visualdynamics/gui/record_grid.py
def edit_row_label(self, row: int) -> None:
    """Open an editor over the row's header, in place, and return it.

    The editor holds the row's coordinate (or a photo's name), not
    the label, so a '#2' marker is not part of it. A header is not a
    cell and Qt will not edit one, so the editor is a line edit laid
    over the section. It commits on Enter or on losing focus and
    abandons on Escape, as editing a name does everywhere else in the
    window, and a commit emits `row_renamed`.

    The commit is queued, because acting on it rebuilds this grid,
    and rebuilding the widget an editor is in, from inside that
    editor's own signal, can crash the application.
    """
    # A crash of exactly that kind once took the docks out.
    header = self.verticalHeader()
    # the coordinate itself, not the label: a row told apart only
    # by its position reads '101Z+ #2', and the '#2' is the grid's
    current = (self.row_keys[row].dof if self.kind != 'photo'
               else self.responses[row])
    return self._edit_label(
        header, current,
        QRect(0, header.sectionViewportPosition(row),
              header.width(), header.sectionSize(row)),
        lambda text: self.row_renamed.emit(row, text))
edit_column_label
edit_column_label(column: int) -> None

Open an editor over a reference column's header, in place, and return it. It works as edit_row_label does, and a commit emits column_renamed.

Source code in src/visualdynamics/gui/record_grid.py
def edit_column_label(self, column: int) -> None:
    """Open an editor over a reference column's header, in place, and
    return it. It works as `edit_row_label` does, and a commit emits
    `column_renamed`."""
    header = self.horizontalHeader()
    return self._edit_label(
        header, self.references[column],
        QRect(header.sectionViewportPosition(column), 0,
              header.sectionSize(column), header.height()),
        lambda text: self.column_renamed.emit(column, text))
refresh_icons
refresh_icons() -> None

Redraw every cell icon in place, without rebuilding the grid and losing the selection. This is for after units are declared, when the badge goes and a quantity may appear.

Source code in src/visualdynamics/gui/record_grid.py
def refresh_icons(self) -> None:
    """Redraw every cell icon in place, without rebuilding the grid and
    losing the selection. This is for after units are declared, when
    the badge goes and a quantity may appear."""
    for (row, column), index in self._records.items():
        self.item(row, column).setIcon(self._icon_for(index))
selected_records
selected_records() -> list[int]

Record indices for the selected cells, in row-major order.

Source code in src/visualdynamics/gui/record_grid.py
def selected_records(self) -> list[int]:
    """Record indices for the selected cells, in row-major order."""
    return sorted(self._records[(index.row(), index.column())]
                  for index in self.selectedIndexes())
select_records
select_records(records: Sequence[int]) -> None

Show records as selected, without emitting selection_changed.

Source code in src/visualdynamics/gui/record_grid.py
def select_records(self, records: Sequence[int]) -> None:
    """Show `records` as selected, without emitting
    `selection_changed`."""
    wanted = set(records)
    cells = [cell for cell, record in self._records.items()
             if record in wanted]
    blocked = self.blockSignals(True)
    self.clearSelection()
    for row, column in cells:
        self.item(row, column).setSelected(True)
    self.blockSignals(blocked)
sizeHint
sizeHint() -> QSize

The preferred_size.

Source code in src/visualdynamics/gui/record_grid.py
def sizeHint(self) -> QSize:
    """The `preferred_size`."""
    # QTableWidget's stock hint is 256x192 whatever the content;
    # the tree row takes the widget's word for it, so a one-photo
    # grid stood 192 px tall
    return self.preferred_size()
minimumSizeHint
minimumSizeHint() -> QSize

The preferred_size.

Source code in src/visualdynamics/gui/record_grid.py
def minimumSizeHint(self) -> QSize:
    """The `preferred_size`."""
    return self.preferred_size()
preferred_size
preferred_size() -> QSize

The size the tree should make room for.

It is capped at MAX_WIDTH by MAX_HEIGHT (460 by 320 pixels), because a 100-channel CPSD would otherwise push everything else in the tree off the bottom. Past the cap the grid scrolls itself.

Source code in src/visualdynamics/gui/record_grid.py
def preferred_size(self) -> QSize:
    """The size the tree should make room for.

    It is capped at `MAX_WIDTH` by `MAX_HEIGHT` (460 by 320 pixels),
    because a 100-channel CPSD would otherwise push everything else in
    the tree off the bottom. Past the cap the grid scrolls itself.
    """
    # the header's own width is not settled until the widget is laid out,
    # and this is asked for before that; its hint capped is what it will be
    rows = self.verticalHeader()
    width = (min(rows.sizeHint().width(), rows.maximumWidth())
             + sum(self.columnWidth(c) for c in range(self.columnCount()))
             + 4)
    height = (ROW_HEIGHT * self.rowCount()
              + self.horizontalHeader().height() + 4)
    if width > MAX_WIDTH:      # room for the scrollbar that will appear,
        height += self.horizontalScrollBar().sizeHint().height()
    return QSize(min(width, MAX_WIDTH), min(height, MAX_HEIGHT))

Functions:

row_keys

row_keys(data: DataArray) -> list[RowKey]

One RowKey per record.

A row is a DOF and a quantity. Two channels can be at one point (a shaker's load cell and the accelerometer beside it share a node and a direction), so a DOF may repeat, but never within a quantity. A volt, a newton and a meter per second squared never belong on one row, whatever their DOF.

The quantity comes from known_dim, not ordinate_dim: a source can name a quantity without giving its unit, and that is enough to tell channels apart. It is the response factor of the dimension (channel_quantities), because the row is the response channel. Keyed on the whole compound dimension, a CPSD would split every accelerometer into a row for each channel it was measured against. The record's icon reads the same quantity, and the two must agree or the axis and the cells describe different rows.

When even that is not enough (an import that says nothing about units, so every channel at a DOF reads 'unknown'), occurrence breaks the tie by channel order. This is the last resort. A channel index comes from how a file was written and is not a property of a measurement, and reordering the table would rename the row, but the alternative is refusing the grid and losing an arrangement that is known. Once the units are declared and the quantities differ, every occurrence is 0 and the channel order is no longer used.

Source code in src/visualdynamics/gui/record_grid.py
def row_keys(data: DataArray) -> list[RowKey]:
    """One RowKey per record.

    A row is a DOF and a quantity. Two channels can be at one point (a
    shaker's load cell and the accelerometer beside it share a node and a
    direction), so a DOF may repeat, but never within a quantity. A volt,
    a newton and a meter per second squared never belong on one row,
    whatever their DOF.

    The quantity comes from `known_dim`, not `ordinate_dim`: a source can
    name a quantity without giving its unit, and that is enough to tell
    channels apart. It is the response factor of the dimension
    (`channel_quantities`), because the row is the response channel.
    Keyed on the whole compound dimension, a CPSD would split every
    accelerometer into a row for each channel it was measured against.
    The record's icon reads the same quantity, and the two must agree or
    the axis and the cells describe different rows.

    When even that is not enough (an import that says nothing about
    units, so every channel at a DOF reads 'unknown'), `occurrence`
    breaks the tie by channel order. This is the last resort. A channel
    index comes from how a file was written and is not a property of a
    measurement, and reordering the table would rename the row, but the
    alternative is refusing the grid and losing an arrangement that is
    known. Once the units are declared and the quantities differ, every
    occurrence is 0 and the channel order is no longer used.
    """
    factors = [channel_quantities(data.known_dim(i))
               for i in range(data.num_records)]
    quantities = [(dof, factors[i][0])
                  for i, dof in enumerate(data.response_dof)]
    if data.reference_dof is not None:
        # the occurrence tiebreaker must see the reference as a
        # *channel* too: keyed on the DOF alone, a CPSD's records
        # against a drive point's accelerometer and its load cell
        # looked like one column measured twice, and every response
        # grew a phantom second row
        columns = [(dof, factors[i][1])
                   for i, dof in enumerate(data.reference_dof)]
    else:
        columns = data.column_keys() or [''] * data.num_records
    seen = Counter()
    keys = []
    for pair, column in zip(quantities, columns):
        keys.append(RowKey(*pair, seen[(pair, column)]))
        seen[(pair, column)] += 1
    return keys

row_labels

row_labels(keys: Sequence[RowKey]) -> list[str]

Headers for row_keys: the DOF, with a marker only where one is needed.

Normally the header is the DOF alone, because what a row measures is shown by the icon in each of its cells, which is read faster than a word. Two channels told apart only by occurrence have the same icon, because neither says what it measures, so there the header adds the channel's position ('101Z+ #2'). The marker goes away once units are declared.

Source code in src/visualdynamics/gui/record_grid.py
def row_labels(keys: Sequence[RowKey]) -> list[str]:
    """Headers for `row_keys`: the DOF, with a marker only where one is
    needed.

    Normally the header is the DOF alone, because what a row measures is
    shown by the icon in each of its cells, which is read faster than a
    word. Two channels told apart only by `occurrence` have the same
    icon, because neither says what it measures, so there the header
    adds the channel's position ('101Z+ #2'). The marker goes away once
    units are declared.
    """
    ambiguous = {key.dof for key in keys if key.occurrence}
    return [f'{key.dof} #{key.occurrence + 1}' if key.dof in ambiguous
            else key.dof for key in keys]

short_labels

short_labels(columns: Sequence[str]) -> list[str]

Column headers narrow enough that a 20-average grid is usable.

An average reads as 'avg 7' in a record label. As a column header it only has to be told from its neighbors, and the word is the same on all twenty, so when every column shares one prefix the prefix is dropped. Reference DOFs have no common prefix and are left as they are.

Source code in src/visualdynamics/gui/record_grid.py
def short_labels(columns: Sequence[str]) -> list[str]:
    """Column headers narrow enough that a 20-average grid is usable.

    An average reads as 'avg 7' in a record label. As a column header it
    only has to be told from its neighbors, and the word is the same on
    all twenty, so when every column shares one prefix the prefix is
    dropped. Reference DOFs have no common prefix and are left as they
    are.
    """
    if len(columns) < 2:
        return list(columns)
    heads = {column.rsplit(' ', 1)[0] for column in columns if ' ' in column}
    if len(heads) != 1 or any(' ' not in column for column in columns):
        return list(columns)
    return [column.rsplit(' ', 1)[1] for column in columns]

grid_axes

grid_axes(data: DataArray) -> tuple[list[RowKey], list[str]] | None

(rows, columns) for an object shown as a grid, or None for an object that is not.

The columns are whatever tells records apart besides their row: the reference DOF for a matrix of measurements, the capture for repeated averages, and a single unlabeled column when the row alone identifies the record (multiple coherence, a plain time history, a channel table, a shape set). A specification whose every record is a channel against itself drops its reference column too, because a diagonal spelled out across six columns says nothing the rows do not.

Source code in src/visualdynamics/gui/record_grid.py
def grid_axes(data: DataArray
              ) -> tuple[list[RowKey], list[str]] | None:
    """(rows, columns) for an object shown as a grid, or None for an
    object that is not.

    The columns are whatever tells records apart besides their row: the
    reference DOF for a matrix of measurements, the capture for repeated
    averages, and a single unlabeled column when the row alone identifies
    the record (multiple coherence, a plain time history, a channel
    table, a shape set). A specification whose every record is a channel
    against itself drops its reference column too, because a diagonal
    spelled out across six columns says nothing the rows do not.
    """
    plan = grid_plan(data)
    if plan is None:
        return None
    return plan.rows, plan.columns

grid_plan

grid_plan(obj: Any) -> GridPlan | None

The GridPlan for an object: a data array, a channel table, a shape set, matched modes, photos, or a sine specification or level set. None for any other object.

Source code in src/visualdynamics/gui/record_grid.py
def grid_plan(obj: Any) -> GridPlan | None:
    """The `GridPlan` for an object: a data array, a channel table, a
    shape set, matched modes, photos, or a sine specification or level
    set. None for any other object."""
    from ..core.channel_table import ChannelTable
    from ..core.matches import MatchedModes
    from ..core.photos import Photos
    from ..core.shapes import ShapeSet

    if hasattr(obj, 'column_keys'):          # a DataArray
        return _data_plan(obj)
    if isinstance(obj, ChannelTable):
        return _channel_plan(obj)
    if isinstance(obj, ShapeSet):
        return _mode_plan(obj)
    if isinstance(obj, MatchedModes):
        return _match_plan(obj)
    if isinstance(obj, Photos):
        return _photo_plan(obj)
    from ..core.sine import SineLevelSet, SineSweepSpecification
    if isinstance(obj, (SineSweepSpecification, SineLevelSet)):
        return _dof_plan(obj)
    return None

sine_dofs

sine_dofs(grouped) -> list[str]

The control DOFs a sine specification or a level set covers, in order: the specification's own, or the union of its levels', since a tone the instructions windowed may have been read at fewer DOFs.

Source code in src/visualdynamics/gui/record_grid.py
def sine_dofs(grouped) -> list[str]:
    """The control DOFs a sine specification or a level set covers, in
    order: the specification's own, or the union of its levels', since a
    tone the instructions windowed may have been read at fewer DOFs."""
    if hasattr(grouped, 'tones'):
        return [str(dof) for dof in grouped.response_dof]
    out: list[str] = []
    for level in grouped.levels:
        for dof in level.response_dof:
            if str(dof) not in out:
                out.append(str(dof))
    return out