Skip to content

visualdynamics.gui.record_grid

record_grid

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

Every object that expands into records, channels or modes expands into a grid — even one column wide. Rows are DOFs (modes for a shape set), 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 is the identity. One format for every object means one set of habits: the same selection, the same deletion, the same icons.

A set that does not fill its rectangle — a record deleted out of a full matrix, an FRF measured for some pairs only — stays a grid with disabled holes where the missing records would be. The holes say precisely what is absent, which neither a flat list nor a refusal did.

Geometry is the exception, on request: its categories keep the list.

Classes:

Name Description
RowKey

What identifies a row: a DOF, what it measures, and — only if those

GridPlan

Everything a grid needs, independent of what 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, and a marker only where it must have one.

short_labels

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

grid_axes

(rows, columns) for any grid-able object; None only when it has none.

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 if those two are not enough — which of the channels sharing them it is.

GridPlan

Bases: NamedTuple

Everything a grid needs, independent of what kind of object it maps.

kind is the reference vocabulary — 'record', 'channel' or 'mode' — so a selection in the grid speaks the same language as the tree always has. cells maps (row, column) to the item index; 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 — 'record', 'channel' or 'mode' — in the same vocabulary the tree has always used, so everything downstream of a selection is unchanged.

Methods:

Name Description
row_roles

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

show_roles

Restate every row's role without announcing a change — a

set_row_role

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

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.

edit_column_label

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

refresh_icons

Restate every cell icon in place — after units are declared, the

selected_records

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

select_records

Show records as selected, without echoing back a change.

preferred_size

How big 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

Restate every row's role without announcing a change — 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:
    """Restate every row's role without announcing a change — 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 announce it when it changed.

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
    announce it when it changed."""
    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.

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, which is what editing a name anywhere else in the window does.

The commit is queued: acting on it rebuilds this grid, and rebuilding the widget an editor is sitting in — from inside that editor's own signal — is the shape of crash that took the docks out.

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.

    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, which is what editing a
    name anywhere else in the window does.

    The commit is *queued*: acting on it rebuilds this grid, and
    rebuilding the widget an editor is sitting in — from inside that
    editor's own signal — is the shape of crash that 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 — the row gesture on the other edge of the grid.

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 —
    the row gesture on the other edge of the grid."""
    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

Restate every cell icon in place — after units are declared, the badge goes and a quantity may appear — without rebuilding the grid and losing the selection.

Source code in src/visualdynamics/gui/record_grid.py
def refresh_icons(self) -> None:
    """Restate every cell icon in place — after units are declared, the
    badge goes and a quantity may appear — without rebuilding the grid
    and losing the selection."""
    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 echoing back a change.

Source code in src/visualdynamics/gui/record_grid.py
def select_records(self, records: Sequence[int]) -> None:
    """Show `records` as selected, without echoing back a change."""
    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)
preferred_size
preferred_size() -> QSize

How big the tree should make room for.

Capped: a 100-channel CPSD would otherwise push everything else in the tree off the bottom, so past the cap the grid scrolls itself.

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

    Capped: a 100-channel CPSD would otherwise push everything else in
    the tree off the bottom, so 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 data type. Two channels can sit 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, and a volt, a newton and a meter per second squared never belong on one row whatever their DOF says.

The quantity comes from known_dim, not ordinate_dim: a source can name a quantity without sizing it, and that claim 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 compound, a CPSD split every accelerometer into a row per thing it was measured against. The record's icon reads the hint too, 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. That is deliberately the last resort. A channel index is an artifact of how a file was written rather than a property of a measurement, and reordering the table would rename the row; but the alternative is refusing the grid and losing an arrangement we do know, which is worse. Declare the units and the quantities separate, every occurrence falls back to 0, and the artifact stops being 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 data type. Two channels can sit 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, and a volt,
    a newton and a meter per second squared never belong on one row whatever
    their DOF says.

    The quantity comes from `known_dim`, not `ordinate_dim`: a source can name
    a quantity without sizing it, and that claim 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 compound, a CPSD split every accelerometer into a row per thing
    it was measured against. The record's icon reads the hint too, 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. That is deliberately the last resort. A channel index is an
    artifact of how a file was written rather than a property of a
    measurement, and reordering the table would rename the row; but the
    alternative is refusing the grid and losing an arrangement we *do* know,
    which is worse. Declare the units and the quantities separate, every
    occurrence falls back to 0, and the artifact stops being 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, and a marker only where it must have one.

Normally the DOF alone — what a row measures is on the icon in each of its cells, which is read faster than a word. But two channels telling apart only by occurrence have the same icon, because neither says what it measures, so there the DOF is not enough and the channel's position has to show. It disappears again the moment 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, and a marker only where it must have one.

    Normally the DOF alone — what a row measures is on the icon in each of its
    cells, which is read faster than a word. But two channels telling apart
    only by `occurrence` have the *same* icon, because neither says what it
    measures, so there the DOF is not enough and the channel's position has to
    show. It disappears again the moment 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.

A block reads as 'avg 7' in a record label, where it is prose. As a column header it only has to be told from its neighbors, and the word is the same on all twenty — so where every column shares one prefix, the prefix goes. Reference DOFs have no common prefix and are left alone.

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.

    A block reads as 'avg 7' in a record label, where it is prose. As a
    column header it only has to be told from its neighbors, and the word
    is the same on all twenty — so where every column shares one prefix, the
    prefix goes. Reference DOFs have no common prefix and are left alone.
    """
    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 any grid-able object; None only when it has none.

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 is the identity: multiple coherence, a plain time history, a channel table, a shape set. A specification whose every record is a channel against itself collapses 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 any grid-able object; None only when it has none.

    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 is the
    identity: multiple coherence, a plain time history, a channel table, a
    shape set. A specification whose every record is a channel against
    itself collapses 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

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' — a tone the instructions windowed may have been read at fewer.

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' — a
    tone the instructions windowed may have been read at fewer."""
    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