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 |
|
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 |
|
minimumSizeHint |
|
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
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(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
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
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
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
(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
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
|