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 unlabelled 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.

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
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)
    super().__init__(len(plan.rows), len(plan.columns), 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))
    # 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 unlabelled column has no header worth a strip of pixels
    self.horizontalHeader().setVisible(plan.columns != [''])
    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):
        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)
    # 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 labelled 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:
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 metre 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 artefact 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 artefact 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 metre 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
    artefact 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 artefact 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 neighbours, 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 neighbours, 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 unlabelled 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 unlabelled 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