Skip to content

visualdynamics.gui.object_tables

object_tables

Table models for Visual Dynamics objects.

Each model is a list of columns over an existing object. It reads its cells from the object and, where editing makes sense, writes an edit straight back to it. In a mode table, the frequency and damping a fit produced are read-only, while the description beside them is free text to fill in.

Classes:

Name Description
ComplianceRows

The comparison of a specification with a measurement, one row

ReplicationRows

Waveform error as a grid: one control channel per row, one

ComplianceGrid

How far each control channel is from its specification, one

Functions:

Name Description
shape_table_model

A mode set as a table: each mode's frequency, damping and modal

shape_values_model

A mode set's values as a table, read-only: at every DOF of the

transformation_model

A control loop's transformation as a table, read-only

matched_modes_model

A MatchedModes object as a table, in the mode table's format.

channel_table_model

A channel table, with an editor of the right type for each

modal_fit_table_model

The modes fitted so far, plus the one the cursor is placing.

units_table_model

A table with one row per item of obj that needs a unit.

frf_units_models

Two unit tables for an FRF: its response channels and its

id_runs_text

Ids as sorted runs, without repeats: [1,2,3,7,9,10] ->

parse_id_runs

The inverse of id_runs_text: '1-3, 7 9-10' ->

group_label

An element group's label in a list: its name, or

element_group_table_model

The element groups, which divide the mesh into parts.

specification_model

A specification's channels, one row each: the channel's name

replication_model

A table of a ReplicationRows: channels down, events across.

compliance_grid_model

A table of a ComplianceGrid: each channel, its RMS error in

sweep_rate_rows

The sweep-rate table's rows: one per pass of the tone through a

sweep_rate_model

A table of sweep_rate_rows: each pass of a sine tone through a

Classes

ComplianceRows

ComplianceRows(rows: Sequence[tuple[str, Any]], unit_label: str = '', plotted: int | None = None)

The comparison of a specification with a measurement, one row per channel.

rows is a list of (channel, result), computed once when the selection changes rather than once per cell. unit_label is the level's unit for the column heading. plotted is the index of the row the plot is drawing, so the table can mark the channel on screen.

Methods:

Name Description
row_of

The row of the channel named label, or None.

Source code in src/visualdynamics/gui/object_tables.py
def __init__(self, rows: Sequence[tuple[str, Any]], unit_label: str = '',
             plotted: int | None = None) -> None:
    self.rows: list[tuple[str, Any]] = list(rows)
    self.unit_label: str = unit_label
    self.plotted: int | None = plotted
Methods:
row_of
row_of(label: str) -> int | None

The row of the channel named label, or None.

Source code in src/visualdynamics/gui/object_tables.py
def row_of(self, label: str) -> int | None:
    """The row of the channel named `label`, or None."""
    for row, (name, _result) in enumerate(self.rows):
        if name == label:
            return row
    return None

ReplicationRows

ReplicationRows(channels: Sequence[str], events: Sequence[int], values: Mapping[tuple[str, int], float | str], plotted: Sequence[tuple[str, int]] | None = None)

Waveform error as a grid: one control channel per row, one playing of the waveform (an event) per column.

A transient record holds many attempts at one target, so its errors have two dimensions. The plot draws only what is picked and the bar chart reduces them to one value, so this grid is the one place that shows both. In it, a channel that is poor in every event and an event that was poor on every channel are easy to tell apart.

values maps (channel, event) to the error, or to a sentence saying why that pair has none. plotted is the set of (channel, event) pairs on screen, because picking a cell picks both at once.

Methods:

Name Description
value

The value at a row and an event's column (counted from zero,

pairs_at

The (channel, event) pairs one selected cell picks.

Source code in src/visualdynamics/gui/object_tables.py
def __init__(self, channels: Sequence[str], events: Sequence[int],
             values: Mapping[tuple[str, int], float | str],
             plotted: Sequence[tuple[str, int]] | None = None) -> None:
    self.channels: list[str] = list(channels)
    self.events: list[int] = list(events)
    self.values: dict[tuple[str, int], float | str] = dict(values)
    self.plotted: set[tuple[str, int]] = set(plotted or ())
Methods:
value
value(row: int, column: int) -> float | str | None

The value at a row and an event's column (counted from zero, without the channel column), the words for why it was not scored, or None where there is no value for that channel and event.

Source code in src/visualdynamics/gui/object_tables.py
def value(self, row: int, column: int) -> float | str | None:
    """The value at a row and an event's column (counted from zero,
    without the channel column), the words for why it was not
    scored, or None where there is no value for that channel and
    event."""
    if not 0 <= row < len(self.channels):
        return None
    if not 0 <= column < len(self.events):
        return None
    return self.values.get((self.channels[row], self.events[column]))
pairs_at
pairs_at(row: int, column: int) -> list[tuple[str, int]]

The (channel, event) pairs one selected cell picks.

Column zero holds the channel's name, so picking it picks that channel in every event, the whole row.

Source code in src/visualdynamics/gui/object_tables.py
def pairs_at(self, row: int,
             column: int) -> list[tuple[str, int]]:
    """The `(channel, event)` pairs one selected cell picks.

    Column zero holds the channel's name, so picking it picks that
    channel in every event, the whole row.
    """
    if not 0 <= row < len(self.channels):
        return []
    channel = self.channels[row]
    if column <= 0:
        return [(channel, event) for event in self.events]
    if column - 1 >= len(self.events):
        return []
    return [(channel, self.events[column - 1])]

ComplianceGrid

ComplianceGrid(rows: Sequence[tuple[str, Any]], plotted: Sequence[str] | None = None)

How far each control channel is from its specification, one row per channel.

It does for a PSD comparison what ReplicationRows does for a transient, with one set of numbers per channel instead of a grid, because a PSD is already an average over the repeats.

rows is a list of (DOF pair, RMS error in dB, percent of the band outside the abort limits). plotted is the set of DOF pairs on screen.

Methods:

Name Description
pair_at

The DOF pair a row names, or None.

Source code in src/visualdynamics/gui/object_tables.py
def __init__(self, rows: Sequence[tuple[str, Any]],
             plotted: Sequence[str] | None = None) -> None:
    self.rows: list[tuple[str, Any]] = list(rows)
    self.plotted: set[str] = set(plotted or ())
Methods:
pair_at
pair_at(row: int) -> str | None

The DOF pair a row names, or None.

Source code in src/visualdynamics/gui/object_tables.py
def pair_at(self, row: int) -> str | None:
    """The DOF pair a row names, or None."""
    if 0 <= row < len(self.rows):
        return self.rows[row][0]
    return None

Functions:

shape_table_model

shape_table_model(shapes: ShapeSet, parent: QObject | None = None) -> TableModel

A mode set as a table: each mode's frequency, damping and modal mass, read-only, and a description to fill in.

Source code in src/visualdynamics/gui/object_tables.py
def shape_table_model(shapes: ShapeSet,
                      parent: QObject | None = None) -> TableModel:
    """A mode set as a table: each mode's frequency, damping and modal
    mass, read-only, and a description to fill in."""
    columns = [
        Column('Mode', lambda s, r: r + 1),
        Column('Frequency [Hz]', lambda s, r: float(s.frequency[r]),
               format=lambda v: f'{v:.4f}'),
        Column('Damping [%]', lambda s, r: float(s.damping[r]) * 100.0,
               format=lambda v: f'{v:.3f}'),
        Column('Modal mass (unscaled)'
               if getattr(shapes, 'unscaled', False) else 'Modal mass',
               lambda s, r: float(s.modal_mass[r]),
               format=lambda v: f'{v:.4g}'),
        Column('Description', lambda s, r: s.description[r],
               set=_set_description,
               journal=lambda s, r, t: f'.description[{r}] = {t.strip()!r}',
               alignment=LEFT),
    ]
    return TableModel(shapes, columns, lambda s: s.num_shapes, parent)

shape_values_model

shape_values_model(shapes: ShapeSet, parent: QObject | None = None, dofs: Sequence[str] | None = None, form: str = 'shapes') -> TableModel

A mode set's values as a table, read-only: at every DOF of the set or at dofs, as the shapes (a row per DOF, a column per mode, M1, M2, ...) or one of the transformations through them (a row per mode, a column per DOF). The matrix is transform.matrix_at's and the text core.tables', which a script's Project.table(..., values=True) reads too; a complex mode's value shows its real and imaginary parts. Raises ValueError where matrix_at does.

Source code in src/visualdynamics/gui/object_tables.py
def shape_values_model(shapes: ShapeSet, parent: QObject | None = None,
                       dofs: Sequence[str] | None = None,
                       form: str = 'shapes') -> TableModel:
    """A mode set's values as a table, read-only: at every DOF of the
    set or at `dofs`, as the shapes (a row per DOF, a column per mode,
    M1, M2, ...) or one of the transformations through them (a row per
    mode, a column per DOF). The matrix is `transform.matrix_at`'s and
    the text `core.tables`', which a script's `Project.table(...,
    values=True)` reads too; a complex mode's value shows its real and
    imaginary parts. Raises ValueError where `matrix_at` does."""
    # Brandon, 2026-10-22: the numbers themselves, beside the mode list
    # and the MAC; at a channel table's DOFs and in three forms,
    # 2026-10-23.
    from ..core.tables import cell_value_text
    from ..core.transform import matrix_at, modal_dofs

    view = matrix_at(shapes, dofs, form)
    columns = [Column(str(label),
                      lambda v, r, c=c: v.values[r, c],
                      format=cell_value_text)
               for c, label in enumerate(view.columns)]
    return _ShapeValuesModel(shapes, view, columns, modal_dofs(shapes), parent)

transformation_model

transformation_model(table: ChannelTable, environment: str, form: str = 'response', parent: QObject | None = None) -> TableModel

A control loop's transformation as a table, read-only (ChannelTable.transformation): a row per row of the matrix, numbered from 1, and a column per control (or drive) channel headed by its DOF, in the controller's order. The text is core.tables', which a script's Project.table(..., environment=, form=) reads too. Raises ValueError where transformation does.

Source code in src/visualdynamics/gui/object_tables.py
def transformation_model(table: ChannelTable, environment: str,
                         form: str = 'response',
                         parent: QObject | None = None) -> TableModel:
    """A control loop's transformation as a table, read-only
    (`ChannelTable.transformation`): a row per row of the matrix,
    numbered from 1, and a column per control (or drive) channel headed
    by its DOF, in the controller's order. The text is `core.tables`',
    which a script's `Project.table(..., environment=, form=)` reads
    too. Raises ValueError where `transformation` does."""
    from ..core.tables import cell_value_text

    view = table.transformation(environment, form)
    columns = [Column(str(label),
                      lambda v, r, c=c: v.values[r, c],
                      format=cell_value_text)
               for c, label in enumerate(view.columns)]
    return _MatrixModel(view, columns, parent)

matched_modes_model

matched_modes_model(matched: MatchedModes, objects: Mapping[str, Any], parent: QObject | None = None) -> TableModel

A MatchedModes object as a table, in the mode table's format.

The frequencies and damping are read by name from the mode sets in objects when the model is built, so they show the sets as they are now. The MAC is the value stored on the object, as the comparison computed it (possibly from projected shapes). A set missing from the project, or a mode index past its end, shows dashes.

Source code in src/visualdynamics/gui/object_tables.py
def matched_modes_model(matched: MatchedModes, objects: Mapping[str, Any],
                        parent: QObject | None = None) -> TableModel:
    """A MatchedModes object as a table, in the mode table's format.

    The frequencies and damping are read by name from the mode sets in
    `objects` when the model is built, so they show the sets as they
    are now. The MAC is the value stored on the object, as the
    comparison computed it (possibly from projected shapes). A set
    missing from the project, or a mode index past its end, shows
    dashes.
    """
    from ..core.tables import MATCHED_DECIMALS, cell_text, matched_rows

    headers, rows = matched_rows(matched, objects)
    columns = [Column(title, lambda s, r, i=i: s[r][i],
                      format=partial(cell_text, decimals=decimals))
               for i, (title, decimals)
               in enumerate(zip(headers, MATCHED_DECIMALS))]
    return TableModel(rows, columns, len, parent)

channel_table_model

channel_table_model(table: ChannelTable, parent: QObject | None = None, rows: Sequence[int] | None = None, geometry: Geometry | None = None, set_role: Callable | None = None, environment: str | None = None) -> TableModel

A channel table, with an editor of the right type for each column.

The columns come from the table's own COLUMNS spec, so a schema change reaches the interface without a second edit. A choice column gets a drop-down, a date a calendar, a flag a check box, and the unit column offers only the units that fit what the channel says it measures.

With a geometry, derived columns are added after the table's own: each channel's measured direction as a unit vector in the geometry's global system, the nearest global axis, and the angle to it. They are computed from the geometry each time they are shown, and again whenever the node or the direction in that row is edited, so they always agree with it. They are not editable, because the table does not store them.

rows, when given, shows only those channels, so the table shows the rows picked in the tree's grid and nothing else.

set_role(row, role), when given, receives the Role column's edits in place of the table. A table linked with the time data it describes shows that data's roles, and a change made here goes to the data (Project.set_channel_role), which journals it.

environment, when given, is one of the table's control loops: the Control column shows that loop's control channels and an edit sets them (ChannelTable.set_control). A table with environments is always shown with one picked, since its column is theirs.

Source code in src/visualdynamics/gui/object_tables.py
def channel_table_model(table: ChannelTable, parent: QObject | None = None,
                        rows: Sequence[int] | None = None,
                        geometry: Geometry | None = None,
                        set_role: Callable | None = None,
                        environment: str | None = None) -> TableModel:
    """A channel table, with an editor of the right type for each
    column.

    The columns come from the table's own `COLUMNS` spec, so a schema
    change reaches the interface without a second edit. A choice column
    gets a drop-down, a date a calendar, a flag a check box, and the
    unit column offers only the units that fit what the channel says it
    measures.

    With a `geometry`, derived columns are added after the table's own:
    each channel's measured direction as a unit vector in the
    geometry's global system, the nearest global axis, and the angle to
    it. They are computed from the geometry each time they are shown,
    and again whenever the node or the direction in that row is edited,
    so they always agree with it. They are not editable, because the
    table does not store them.

    `rows`, when given, shows only those channels, so the table shows
    the rows picked in the tree's grid and nothing else.

    `set_role(row, role)`, when given, receives the Role column's edits
    in place of the table. A table linked with the time data it
    describes shows that data's roles, and a change made here goes to
    the data (`Project.set_channel_role`), which journals it.

    `environment`, when given, is one of the table's control loops: the
    Control column shows that loop's control channels and an edit sets
    them (`ChannelTable.set_control`). A table with environments is
    always shown with one picked, since its column is theirs.
    """
    # The derived direction columns: Brandon, 2026-09-20.
    rows = None if rows is None else [int(r) for r in rows]
    index = (lambda row: row) if rows is None else rows.__getitem__
    columns = []
    for name, spec in table.COLUMNS.items():
        title = title_of(name)
        if spec.kind == 'flag':
            # the one flag is Control, read through `controls` so the
            # text spellings a paste leaves in the column all show as
            # the check they mean
            columns.append(Column(
                title,
                lambda t, r, i=index: bool(t.controls(environment)[i(r)]),
                set=(_control_setter(environment, index)
                     if environment is not None
                     else _column_setter(name, index)),
                journal=(_control_journal(environment, index)
                         if environment is not None
                         else _column_journal(name, index)),
                checkbox=True))
            continue
        if spec.kind == 'choice':
            shown = ([shown_dimension(c) for c in spec.choices]
                     if name == 'channel_type' else list(spec.choices))
            routed = name == 'role' and set_role is not None
            columns.append(Column(
                title, _shown_getter(name, index),
                set=((lambda _t, r, text, i=index: set_role(i(r), text))
                     if routed else _column_setter(name, index)),
                # the verb journals itself
                journal=((lambda _t, _r, _text: None) if routed
                         else _column_journal(name, index)),
                choices=shown,
                # a direction moves the derived columns beside it
                affects_row=name in ('channel_type', 'direction'),
                alignment=LEFT))
            continue
        if spec.kind == 'unit':
            # what this channel could be in, given what it says it is
            columns.append(Column(
                title, _column_getter(name, index),
                set=_column_setter(name, index),
                journal=_column_journal(name, index),
                choices=ALL_ORDINATE_UNITS,
                row_choices=lambda t, r, i=index: t.units_for(i(r)),
                alignment=LEFT))
            continue
        if name in ('make', 'model'):
            # the catalog's sensors for what the row measures, offered and
            # not imposed: any make or model can still be typed, and a
            # model picked fills its make, and a triax's other legs
            # (`ChannelTable.set_cell`)
            columns.append(Column(
                title, _column_getter(name, index),
                set=_column_setter(name, index),
                journal=_column_journal(name, index),
                row_choices=lambda t, r, n=name, i=index: _catalog_choices(
                    t, i(r), n),
                choices_editable=True, affects_table=name == 'model',
                alignment=LEFT))
            continue
        columns.append(Column(
            title, _column_getter(name, index),
            set=_column_setter(name, index),
            journal=_column_journal(name, index),
            date=spec.kind == 'date',
            # a node moves the derived columns beside it, and a triax's
            # serial number fills its other legs
            affects_row=name == 'node',
            affects_table=name == 'serial_number', alignment=LEFT))
    if geometry is not None:
        from ..core.channel_table import DERIVED_COLUMNS

        for k, name in enumerate(DERIVED_COLUMNS):
            columns.append(Column(
                title_of(name),
                lambda t, r, k=k, i=index, g=geometry: t.derived_cells(i(r), g)[k],
                alignment=LEFT))
    count = ((lambda t: t.num_channels) if rows is None
             else (lambda t: len(rows)))
    return TableModel(table, columns, count, parent)

modal_fit_table_model

modal_fit_table_model(session: ModalFitSession, parent: QObject | None = None) -> TableModel

The modes fitted so far, plus the one the cursor is placing.

The last row, labeled "new", is the pending mode. Its frequency is the cursor's on the CMIF, its damping is the half-power estimate until a value is typed, and Confirm Mode makes it a fitted row. A fitted mode's damping stays editable, in percent between 0 and 100 (exclusive), and the synthesis uses the new value. The description is free text.

Source code in src/visualdynamics/gui/object_tables.py
def modal_fit_table_model(session: ModalFitSession,
                          parent: QObject | None = None) -> TableModel:
    """The modes fitted so far, plus the one the cursor is placing.

    The last row, labeled "new", is the pending mode. Its frequency is
    the cursor's on the CMIF, its damping is the half-power estimate
    until a value is typed, and Confirm Mode makes it a fitted row. A
    fitted mode's damping stays editable, in percent between 0 and 100
    (exclusive), and the synthesis uses the new value.
    The description is free text.
    """
    def pending(row: int) -> bool:
        return row == len(session.modes)

    def entry(row: int, key: str) -> Any:
        return (session.pending[key] if pending(row)
                else session.modes[row][key])

    def set_damping(session: ModalFitSession, row: int, text: str) -> None:
        value = float(text.rstrip('%')) / 100.0
        if not 0.0 < value < 1.0:
            raise ValueError('damping is a fraction of critical, 0-100%')
        if pending(row):
            session.pending['damping'] = value
            session.pending['overridden'] = True
        else:
            session.modes[row]['damping'] = value

    def set_description(session: Any, row: int, text: str) -> None:
        if pending(row):
            session.pending['description'] = text.strip()
        else:
            session.modes[row]['description'] = text.strip()

    columns = [
        Column('Mode', lambda s, r: 'new' if pending(r) else r + 1),
        Column('Frequency [Hz]', lambda s, r: float(entry(r, 'frequency')),
               format=lambda v: f'{v:.4f}'),
        Column('Damping [%]', lambda s, r: float(entry(r, 'damping')) * 100.0,
               format=lambda v: f'{v:.3f}', set=set_damping,
               journal=_elsewhere),
        Column('Description', lambda s, r: entry(r, 'description'),
               set=set_description, journal=_elsewhere,
               alignment=LEFT),
    ]
    return TableModel(session, columns,
                          lambda s: len(s.modes) + 1, parent)

units_table_model

units_table_model(obj: Any, records: Sequence[int] | None = None, parent: QObject | None = None) -> TableModel

A table with one row per item of obj that needs a unit.

A geometry has one row, for its coordinates, and a mode set one, for its shapes. A data object has one row per channel among the records in records (all of them when None); for a PSD with cross records, a cross term's unit follows from its two channels'.

It is a table rather than a dialog so the plot or the model stays on screen beside it, and units copy, paste and clear like any other column.

Source code in src/visualdynamics/gui/object_tables.py
def units_table_model(obj: Any, records: Sequence[int] | None = None,
                      parent: QObject | None = None) -> TableModel:
    """A table with one row per item of `obj` that needs a unit.

    A geometry has one row, for its coordinates, and a mode set one,
    for its shapes. A data object has one row per channel among the
    records in `records` (all of them when None); for a PSD with cross
    records, a cross term's unit follows from its two channels'.

    It is a table rather than a dialog so the plot or the model stays on
    screen beside it, and units copy, paste and clear like any other
    column.
    """
    from ..core.data import Psd
    from ..core.geometry import Geometry
    from ..core.shapes import ShapeSet
    from ..core.unit_choices import LENGTH_UNITS, MASS_UNITS

    if isinstance(obj, Psd) and _has_cross_records(obj):
        return _matrix_units_model(obj, records, parent)
    if isinstance(obj, Geometry):
        if getattr(obj, 'from_deck', False):
            return _deck_units_model(obj, parent)
        return _one_unit_model(obj, 'Coordinates', 'length', LENGTH_UNITS,
                               'length_unit', parent)
    if isinstance(obj, ShapeSet):
        # What a mass-normalized shape is *in* is 1/sqrt(mass), and a cell
        # reading 'kg' does not say that — the same reasoning that shows a
        # PSD's unit as g**2/Hz. The mass unit is what is stored; the
        # radical is what is shown and offered.
        return _one_unit_model(obj, 'Mode shapes', 'mass',
                               [f'1/\u221a{unit}' for unit in MASS_UNITS],
                               'mass_unit', parent,
                               shown=lambda unit: f'1/\u221a{unit}',
                               given=_mass_unit_given,
                               type_label='modal mass')
    return _channel_units_model(obj, records, parent)

frf_units_models

frf_units_models(data: DataArray, records: Sequence[int] | None = None, parent: QObject | None = None) -> tuple[TableModel, TableModel]

Two unit tables for an FRF: its response channels and its reference channels.

An FRF matrix of N x M records has only N + M physical channels, and a unit belongs to a channel, so each channel's unit is declared once and every record converts as soon as both of its channels have one. The two sides stay separate even where the DOF labels are the same: a drive point's accelerometer and its force gauge share a label and are two different channels.

Source code in src/visualdynamics/gui/object_tables.py
def frf_units_models(data: DataArray, records: Sequence[int] | None = None,
                     parent: QObject | None = None
                     ) -> tuple[TableModel, TableModel]:
    """Two unit tables for an FRF: its response channels and its
    reference channels.

    An FRF matrix of N x M records has only N + M physical channels,
    and a unit belongs to a channel, so each channel's unit is declared
    once and every record converts as soon as both of its channels have
    one. The two sides stay separate even where the DOF labels are the
    same: a drive point's accelerometer and its force gauge share a
    label and are two different channels.
    """
    from ..core.unit_choices import (
        ALL_ORDINATE_UNITS,
        ORDINATE_UNITS,
        REFERENCE_UNITS,
        units_for,
    )

    wanted = (list(range(data.num_records)) if records is None
              else [int(i) for i in records])
    sides = {
        'response': (data.response_dof, data.ordinate_unit),
        'reference': (data.reference_dof, data.reference_unit),
    }

    channels = {side: [] for side in sides}
    for i in wanted:
        for side, (dofs, _) in sides.items():
            if dofs[i] not in channels[side]:
                channels[side].append(dofs[i])

    def involving(side: str, dof: str) -> list[int]:
        dofs = sides[side][0]
        return [i for i in range(data.num_records) if dofs[i] == dof]

    def stored_unit(side: str, dof: str) -> str | None:
        """The unit a channel's records already carry, if any do."""
        dofs, units = sides[side]
        for i in range(data.num_records):
            if dofs[i] == dof and units[i]:
                return units[i]
        return None

    declared = {(side, dof): stored_unit(side, dof)
                for side in sides for dof in channels[side]}

    def unit_for(side: str, dof: str) -> str | None:
        """What a channel is declared in — including channels off the pane,
        whose records may carry units from an earlier declaration."""
        key = (side, dof)
        return declared[key] if key in declared else stored_unit(side, dof)

    def apply(side: str, dof: str) -> None:
        for i in involving(side, dof):
            unit = unit_for('response', data.response_dof[i])
            reference = unit_for('reference', data.reference_dof[i])
            if unit and reference:
                data.define_units({i: unit}, {i: reference})

    def withdraw(side: str, dof: str) -> None:
        declared[(side, dof)] = None
        data.undefine_units(involving(side, dof))

    def hinted_type(side: str, dof: str) -> str:
        """The quantity a channel was taken to hold: an FRF's dimension is
        response over reference, and the side says which half is ours."""
        for i in involving(side, dof):
            response, _, reference = data.known_dim(i).partition('/')
            part = response if side == 'response' else reference
            if part and part != UNKNOWN:
                return part
        return ''

    def side_model(side: str,
                   unit_choices: Sequence[str]) -> TableModel:
        dofs = channels[side]
        types = [hinted_type(side, dof) for dof in dofs]

        def set_type(data: Any, row: int, text: str) -> None:
            text = stored_dimension(text)
            if text and text not in ORDINATE_UNITS:
                raise ValueError('expected one of ' + str(sorted(
                    shown_dimension(d) for d in ORDINATE_UNITS)))
            types[row] = text
            unit = unit_for(side, dofs[row])
            # a unit of some other kind is no longer an answer here
            if unit and (not text or dimension_of(unit) != text):
                withdraw(side, dofs[row])

        def set_unit(data: Any, row: int, text: str) -> None:
            text = text.strip().replace('^', '**')
            if not text:
                withdraw(side, dofs[row])
                return
            si_transform(text)    # raises before anything is changed
            declared[(side, dofs[row])] = text
            apply(side, dofs[row])
            # a chosen unit IS a statement of type (Brandon, 2026-08-30)
            types[row] = dimension_of(text) or types[row]

        columns = [
            Column('Channel', lambda d, r: dofs[r], alignment=LEFT),
            Column('Type', lambda d, r: shown_dimension(types[r]),
                   set=set_type, journal=_elsewhere,
                   choices=[shown_dimension(d) for d in ORDINATE_UNITS],
                   affects_row=True, alignment=LEFT),
            Column('Unit', lambda d, r: unit_for(side, dofs[r]) or '',
                   set=set_unit, journal=_elsewhere,
                   choices=unit_choices,
                   row_choices=lambda d, r: units_for(types[r], unit_choices),
                   choices_editable=True, carries=('Type',),
                   affects_row=True, alignment=LEFT),
        ]
        return TableModel(data, columns, lambda d: len(dofs), parent)

    return (side_model('response', ALL_ORDINATE_UNITS),
            side_model('reference', REFERENCE_UNITS))

id_runs_text

id_runs_text(ids) -> str

Ids as sorted runs, without repeats: [1,2,3,7,9,10] -> '1-3 7 9-10'.

An element group can hold hundreds of elements (478 in one group of the demonstration drone), and they come in runs because a mesh is built one part at a time. Written out one by one, the drone's 22 element groups take 21 731 characters; as runs they take 595, which fits in a cell a person can read.

Source code in src/visualdynamics/gui/object_tables.py
def id_runs_text(ids) -> str:
    """Ids as sorted runs, without repeats: `[1,2,3,7,9,10]` ->
    `'1-3 7 9-10'`.

    An element group can hold hundreds of elements (478 in one group of
    the demonstration drone), and they come in runs because a mesh is
    built one part at a time. Written out one by one, the drone's 22
    element groups take 21 731 characters; as runs they take 595, which
    fits in a cell a person can read.
    """
    ids = np.unique(np.asarray(list(ids), dtype=np.int64))
    if not len(ids):
        return ''
    runs, start, previous = [], int(ids[0]), int(ids[0])
    for value in ids[1:]:
        value = int(value)
        if value == previous + 1:
            previous = value
            continue
        runs.append((start, previous))
        start = previous = value
    runs.append((start, previous))
    return ' '.join(str(a) if a == z else f'{a}-{z}' for a, z in runs)

parse_id_runs

parse_id_runs(text: str) -> list[int]

The inverse of id_runs_text: '1-3, 7 9-10' -> [1, 2, 3, 7, 9, 10].

Runs are separated by commas or spaces, and a run can be written either way round: 10-7 is the same four elements as 7-10.

Source code in src/visualdynamics/gui/object_tables.py
def parse_id_runs(text: str) -> list[int]:
    """The inverse of `id_runs_text`: `'1-3, 7 9-10'` ->
    `[1, 2, 3, 7, 9, 10]`.

    Runs are separated by commas or spaces, and a run can be written
    either way round: `10-7` is the same four elements as `7-10`.
    """
    found: list[int] = []
    for part in str(text).replace(',', ' ').split():
        if '-' in part[1:]:
            head, _, tail = part[1:].partition('-')
            low, high = int(part[0] + head), int(tail)
            if low > high:
                low, high = high, low
            found.extend(range(low, high + 1))
        else:
            found.append(int(part))
    return found

group_label

group_label(geometry: Geometry, row: int) -> str

An element group's label in a list: its name, or 'element group N' when it has none.

Exodus files often have unnamed element groups, and "element group 3" is what a person calls one. A made-up name would claim something the file does not say.

Source code in src/visualdynamics/gui/object_tables.py
def group_label(geometry: Geometry, row: int) -> str:
    """An element group's label in a list: its name, or
    `'element group N'` when it has none.

    Exodus files often have unnamed element groups, and "element group
    3" is what a person calls one. A made-up name would claim something
    the file does not say.
    """
    name = geometry.group_name[row].strip()
    return name or f'element group {int(geometry.group_id[row])}'

element_group_table_model

element_group_table_model(geometry: Geometry, unit_system: UnitSystem | None = None, parent: QObject | None = None) -> TableModel

The element groups, which divide the mesh into parts.

Every column is editable: the group's number, its name, its elements, and the properties a model built from the geometry needs (a material, and a thickness, section or mass by element type, shown in the display units and blank until set). A property the group's elements do not use, a thickness on beams or a section on bricks, is blank there and takes no value, and says why on hover. The elements are listed as runs (1-36 156-245), and naming an element here moves it into this group. A node can be in no element at all, but an element is always in exactly one element group, so an edit can add and move elements, and one that would leave an element in no group is refused with the reason (see _set_group_elements). The Element group column of the elements table moves elements one at a time; this one moves many at once.

Source code in src/visualdynamics/gui/object_tables.py
def element_group_table_model(geometry: Geometry,
                      unit_system: UnitSystem | None = None,
                      parent: QObject | None = None) -> TableModel:
    """The element groups, which divide the mesh into parts.

    Every column is editable: the group's number, its name, its
    elements, and the properties a model built from the geometry needs
    (a material, and a thickness, section or mass by element type, shown
    in the display units and blank until set). A property the group's
    elements do not use, a thickness on beams or a section on bricks, is
    blank there and takes no value, and says why on hover. The elements are listed as runs
    (`1-36 156-245`), and naming an element here moves it into this
    group. A node can be in no element at all, but an element is always
    in exactly one element group, so an edit can add and move elements,
    and one that would leave an element in no group is refused with the
    reason (see `_set_group_elements`). The Element group column of the
    elements table moves elements one at a time; this one moves many at
    once.
    """
    def set_name(geometry: Any, row: int, text: str) -> None:
        geometry.group_name[row] = text

    families_of = _GroupFamilies(geometry)

    def set_elements(geometry: Any, row: int, text: str) -> None:
        # the elements move in place, which the families' key cannot see
        try:
            _set_group_elements(geometry, row, text)
        finally:
            families_of.forget()

    columns = [
        Column('Element group', lambda g, r: int(g.group_id[r]),
               set=_renumber('renumber_group'),
               journal=_renumber_journal('renumber_group')),
        Column('Name', lambda g, r: g.group_name[r], set=set_name,
               journal=lambda g, r, t: f'.group_name[{r}] = {t!r}',
               alignment=LEFT),
        # The elements themselves, as runs, and editable: naming an
        # element here claims it for this element group. Editing the *count*
        # would have been meaningless — a number is not a thing you
        # can change — and the count is still readable at a glance
        # from the runs.
        Column('Elements', _group_elements_text, set=set_elements,
               # membership moves elements between element groups; the honest
               # replay is the post-state of the whole assignment array
               journal=lambda g, _r, _t: '.elem_group = np.array('
               f'{[int(b) for b in g.elem_group]!r})',
               alignment=LEFT),
        # What the element group is made of, for a model built from the
        # geometry (Brandon, 2026-09-25): a material for any element group, a
        # thickness for an element group of plates, a section for an element group of
        # beams, a mass for an element group of point masses. SI throughout, as the model is inside; blank until
        # set. Every cell's journal line restates the whole property
        # set, so a replay lands on the same object whichever cell was
        # edited last.
        # A property that the group's elements do not use is blank and
        # takes no value there (principle 3), while the same row stays
        # live for the groups it applies to.
        # a deck whose units are not declared holds its own numbers, not
        # SI (2026-10-26: converted as SI, a board read E = 463 psi and
        # 2.4 in thick), so they are shown as they are until its units are
        *(_property_columns(None, families_of, deck=True)
          if getattr(geometry, 'from_deck', False)
          and geometry.length_unit is None
          else _property_columns(unit_system, families_of)),
    ]
    # the other way up (Brandon, 2026-10-20): a group's properties read
    # as a column of entries, and several groups side by side are
    # edited at once by selecting across them
    return TransposedTableModel(geometry, columns, lambda g: len(g.group_id), parent,
                                item_label=group_label)

specification_model

specification_model(rows: Any, parent: QObject | None = None) -> TableModel

A specification's channels, one row each: the channel's name and its RMS level.

The plot draws one channel at a time, because six targets and their limits drawn together cannot be read, so this table lists every channel, and picking a row plots that one.

The level is the specification's own, integrated from its own breakpoints over its own band, so it does not change with the measurement compared against it.

Source code in src/visualdynamics/gui/object_tables.py
def specification_model(rows: Any,
                        parent: QObject | None = None) -> TableModel:
    """A specification's channels, one row each: the channel's name
    and its RMS level.

    The plot draws one channel at a time, because six targets and their
    limits drawn together cannot be read, so this table lists every
    channel, and picking a row plots that one.

    The level is the specification's own, integrated from its own
    breakpoints over its own band, so it does not change with the
    measurement compared against it.
    """
    level = f' [{rows.unit_label}]' if rows.unit_label else ''
    return TableModel(rows, [
        Column('Channel', lambda h, r: h.rows[r][0], alignment=LEFT),
        Column(f'Specification RMS{level}',
               lambda h, r: h.rows[r][1].get('specification_rms'),
               format=_number),
    ], len, parent)

replication_model

replication_model(rows: Any, parent: QObject | None = None, units: str = '%') -> TableModel

A table of a ReplicationRows: channels down, events across.

Two comparisons have this shape and share it: the waveform error against a target time history, in percent, and the RMS deviation in dB of a shock spectrum from the one it had to meet. Only units, in each column heading, differs.

A cell with no value is blank, not zero, because a zero would read as a channel that matched its target exactly.

Source code in src/visualdynamics/gui/object_tables.py
def replication_model(rows: Any, parent: QObject | None = None,
                      units: str = '%') -> TableModel:
    """A table of a `ReplicationRows`: channels down, events across.

    Two comparisons have this shape and share it: the waveform error
    against a target time history, in percent, and the RMS deviation in
    dB of a shock spectrum from the one it had to meet. Only `units`, in
    each column heading, differs.

    A cell with no value is blank, not zero, because a zero would read
    as a channel that matched its target exactly.
    """
    columns = [Column('Channel', lambda h, r: h.channels[r], alignment=LEFT)]
    for index, event in enumerate(rows.events):
        columns.append(Column(
            f'Event {event + 1} [{units}]',
            lambda h, r, c=index: h.value(r, c),
            format=_number))
    return TableModel(rows, columns, len, parent)

compliance_grid_model

compliance_grid_model(rows: Any, parent: QObject | None = None) -> TableModel

A table of a ComplianceGrid: each channel, its RMS error in dB, and the percent of its band outside the abort limits.

Both values are shown because they often disagree: a channel can have exactly the right level and still be outside the limits across half its band, and one that is 2 dB low everywhere may never cross an abort limit.

Source code in src/visualdynamics/gui/object_tables.py
def compliance_grid_model(rows: Any,
                          parent: QObject | None = None) -> TableModel:
    """A table of a `ComplianceGrid`: each channel, its RMS error in
    dB, and the percent of its band outside the abort limits.

    Both values are shown because they often disagree: a channel can
    have exactly the right level and still be outside the limits across
    half its band, and one that is 2 dB low everywhere may never cross
    an abort limit.
    """
    return TableModel(rows, [
        Column('Channel', lambda h, r: _pair_text(h.rows[r][0]),
               alignment=LEFT),
        Column('RMS error [dB]', lambda h, r: h.rows[r][1], format=_number),
        Column('Outside abort [%]', lambda h, r: h.rows[r][2],
               format=_number),
    ], len, parent)

sweep_rate_rows

sweep_rate_rows(tone: str, checks: Sequence[Any], swept: Sequence[tuple[str, float, float, Any]] = ()) -> list[dict[str, Any]]

The sweep-rate table's rows: one per pass of the tone through a mode (core.sweep_rate.SweepRateCheck), then one per Q read off a tracked level at a mode, swept holding (DOF, frequency, Q, SweptQ).

The two kinds share the columns: a level's row names the level's DOF where a pass names its direction, gives the rate at the level's peak and the rule's rate for the Q the reading implies, and leaves the time in the band empty, since a level is a reading of the sweep rather than a pass of it.

Source code in src/visualdynamics/gui/object_tables.py
def sweep_rate_rows(tone: str, checks: Sequence[Any],
                    swept: Sequence[tuple[str, float, float, Any]] = ()
                    ) -> list[dict[str, Any]]:
    """The sweep-rate table's rows: one per pass of the tone through a
    mode (`core.sweep_rate.SweepRateCheck`), then one per Q read off a
    tracked level at a mode, `swept` holding (DOF, frequency, Q,
    `SweptQ`).

    The two kinds share the columns: a level's row names the level's
    DOF where a pass names its direction, gives the rate at the level's
    peak and the rule's rate for the Q the reading implies, and leaves
    the time in the band empty, since a level is a reading of the sweep
    rather than a pass of it."""
    rows = []
    for check in checks:
        rows.append({
            'tone': tone, 'mode': check.frequency, 'q': check.q,
            'pass': check.direction + ('' if check.crossed else ', partial'),
            'rate': f'{abs(check.rate):.3g} {check.unit}',
            'limit': f'{check.limit:.3g} {check.unit}',
            'ratio': check.ratio, 'seconds': check.seconds,
            'cycles': check.cycles,
            'peak': 100.0 * check.peak_share, 'q_read': check.q_read,
            'verdict': check.verdict})
    for dof, frequency, q, reading in swept:
        if reading.q_true is None:
            verdict = 'too fast to read any Q'
        elif reading.flagged:
            verdict = f'reads low; Q about {reading.q_true:.3g}'
        else:
            verdict = 'slow enough'
        rows.append({
            'tone': tone, 'mode': frequency, 'q': q,
            'pass': f'level at {dof}',
            'rate': f'{abs(reading.rate):.3g} {reading.unit}',
            'limit': f'{reading.limit:.3g} {reading.unit}',
            'ratio': reading.ratio, 'seconds': None, 'cycles': None,
            'peak': None, 'q_read': reading.q_read, 'verdict': verdict})
    return rows

sweep_rate_model

sweep_rate_model(rows: Sequence[dict[str, Any]], parent: QObject | None = None) -> TableModel

A table of sweep_rate_rows: each pass of a sine tone through a mode, with the time and the cycles it spends in the mode's half-power band, its rate against the rule's, what a mode of that Q reads at that rate, and the verdict; then each Q read off a tracked level, judged against its sweep. Read-only: the modes are set in the panel beside the plot.

Source code in src/visualdynamics/gui/object_tables.py
def sweep_rate_model(rows: Sequence[dict[str, Any]],
                     parent: QObject | None = None) -> TableModel:
    """A table of `sweep_rate_rows`: each pass of a sine tone through a
    mode, with the time and the cycles it spends in the mode's
    half-power band, its rate against the rule's, what a mode of that Q
    reads at that rate, and the verdict; then each Q read off a tracked
    level, judged against its sweep. Read-only: the modes are set in the
    panel beside the plot."""
    def field(key, places=None):
        def text(value):
            if value is None:
                return ''
            if isinstance(value, float):
                if not np.isfinite(value):
                    return ''
                return f'{value:.{places}f}' if places is not None \
                    else f'{value:.4g}'
            return str(value)
        return Column(key, lambda h, r: h[r][key], format=text)

    columns = [
        ('Tone', 'tone', None, LEFT), ('Mode [Hz]', 'mode', None, None),
        ('Q', 'q', None, None), ('Pass', 'pass', None, LEFT),
        ('Rate', 'rate', None, None), ("Rule's rate", 'limit', None, None),
        ('× Rule', 'ratio', 2, None), ('In band [s]', 'seconds', None, None),
        ('Cycles', 'cycles', 1, None), ('Peak read [%]', 'peak', 1, None),
        ('Q read', 'q_read', 1, None), ('Verdict', 'verdict', None, LEFT)]
    built = []
    for title, key, places, alignment in columns:
        column = field(key, places)
        column.title = title
        if alignment is not None:
            column.alignment = alignment
        built.append(column)
    return TableModel(list(rows), built, len, parent)