Skip to content

visualdynamics.core.channel_table

channel_table

Channel table: per-channel test metadata (sensor, DOF, engineering units).

One fixed set of columns, each with a type and a rule, rather than whatever a source happened to carry. The table used to be a pandas frame that took anything — a Rattlesnake import arrived with thirteen columns of controller settings riding behind the ones visualdynamics reads — and "anything" is exactly what a column cannot have logic about. Now every column says what it holds, so a direction can be a drop-down, a unit can be narrowed by the channel's type, and an expiration can be a date.

Two strictnesses, on purpose, and they are not the same:

  • A file is read leniently. A value that will not coerce is blanked rather than refused, because refusing the import leaves the user nothing to fix, and the whole point of this table is that it is the place to fix it. (validate.dofs makes the same trade for DOFs.)
  • An edit is refused. Once a person is typing, invalid state is rejected at entry with the reason, never written and complained about afterwards.

Classes:

Name Description
Spec

What one column holds, and what it will accept.

ChannelTable

Per-channel metadata, one fixed column set, each column typed.

Functions:

Name Description
title_of

The header a column wears, wherever it is shown — the table in

COLUMNS_KIND

What kind of value a column holds.

CHOICES_FOR

The values a column limits itself to, empty when it does not.

canonical_name

A source's column name as this schema spells it.

Classes

Spec

Bases: NamedTuple

What one column holds, and what it will accept.

ChannelTable

ChannelTable(columns: Mapping[str, Any] | DataFrame)

Per-channel metadata, one fixed column set, each column typed.

Attributes: frame: The table, one row per channel, columns in SCHEMA order. Every cell is text except channel and node, which are integers — a serial number that happens to be all digits is not a number, and a column read back as int64 would lose its leading zero and come out of a round trip a different string.

Methods:

Name Description
set_cell

Write one cell; a value the column cannot hold is refused.

units_for

The units this channel could be in, given its declared type.

delete_channels

Remove the given rows in place; the last one is refused.

dof_strings

Each channel's degree of freedom, as '101Z+' strings.

rename_dof

Give the channel at coordinate old the coordinate new,

roles

Each channel's declared role, '' where undeclared.

controls

Which channels are control channels, as booleans.

types

What each channel measures, '' where undeclared.

sensitivities

mV per engineering unit, NaN where undeclared.

ranges

The instrumentation voltage limit, per channel.

save

Write the table to a file of its own.

Attributes:

Name Type Description
num_channels int

How many channels the table describes — one per row.

column_names list[str]

The table's column headings, in order.

Source code in src/visualdynamics/core/channel_table.py
def __init__(self, columns: Mapping[str, Any] | pd.DataFrame) -> None:
    import pandas as pd

    if isinstance(columns, pd.DataFrame):
        frame = columns.copy()
    else:
        lengths = {name: len(values) for name, values in columns.items()}
        if len(set(lengths.values())) > 1:
            first = next(iter(lengths.values()))
            bad = next(name for name, n in lengths.items() if n != first)
            raise ValueError(
                f'column {bad!r} has {lengths[bad]} entries, '
                f'expected {first}')
        frame = pd.DataFrame(dict(columns))
    frame = frame.rename(columns={name: canonical_name(name)
                                  for name in frame.columns})
    # a source naming one column two ways keeps the first
    frame = frame.loc[:, ~frame.columns.duplicated()]
    for name in self.CORE:
        if name not in frame.columns:
            raise ValueError(f'missing core column {name!r}')
    for name, spec in self.COLUMNS.items():
        if name not in frame.columns:
            frame[name] = spec.default
    # the schema and nothing else: an older file's extras, and a
    # controller's run settings, stop here
    self.frame: pd.DataFrame = self._typed(frame[list(self.SCHEMA)])
Attributes
num_channels property
num_channels: int

How many channels the table describes — one per row.

column_names property
column_names: list[str]

The table's column headings, in order.

Methods:
set_cell
set_cell(name: str, row: int, value: object) -> None

Write one cell; a value the column cannot hold is refused.

Strict where _typed is lenient: a person typing gets the reason, where a file gets the benefit of the doubt.

Parameters:

Name Type Description Default
name str

The column to write.

required
row int

Which channel.

required
value object

The value, refused if the column cannot hold it.

required

Returns:

Type Description
None
Source code in src/visualdynamics/core/channel_table.py
def set_cell(self, name: str, row: int, value: object) -> None:
    """Write one cell; a value the column cannot hold is refused.

    Strict where `_typed` is lenient: a person typing gets the
    reason, where a file gets the benefit of the doubt.

    Parameters
    ----------
    name : str
        The column to write.
    row : int
        Which channel.
    value : object
        The value, refused if the column cannot hold it.

    Returns
    -------
    None
    """
    if name not in self.COLUMNS:
        raise KeyError(f'no column {name!r}')
    spec = self.COLUMNS[name]
    coerced = self._coerce(name, value)
    if spec.unique and coerced != '':
        clash = np.flatnonzero(self[name] == coerced)
        if len(clash) and clash[0] != row:
            raise ValueError(
                f'{name} {coerced} already exists — it is the join key '
                'and must be unique')
    if name == 'role' and coerced != 'response' and self.controls()[row]:
        raise ValueError('this channel is marked Control, and a control '
                         'channel is a response — uncheck Control first')
    if name == 'control' and _parse_flag(str(coerced)) and str(
            self.frame.loc[row, 'role']) != 'response':
        raise ValueError('a control channel is a response — set the '
                         'role first')
    if name == 'unit' and coerced:
        self._agrees_with_type(row, str(coerced))
    self.frame.loc[row, name] = coerced
    if name == 'channel_type':
        # a unit of some other kind is no longer an answer here —
        # the same withdrawal the Imported Units pane makes
        unit = str(self.frame.loc[row, 'unit'])
        if unit and coerced:
            from ..units import dimension_of
            if dimension_of(unit) != coerced:
                self.frame.loc[row, 'unit'] = ''
units_for
units_for(row: int) -> list[str]

The units this channel could be in, given its declared type.

Parameters:

Name Type Description Default
row int

Which channel.

required

Returns:

Type Description
list of str

The units this channel could be in, given its declared type.

Source code in src/visualdynamics/core/channel_table.py
def units_for(self, row: int) -> list[str]:
    """The units this channel could be in, given its declared type.

    Parameters
    ----------
    row : int
        Which channel.

    Returns
    -------
    list of str
        The units this channel could be in, given its
        declared type.
    """
    from .unit_choices import ALL_ORDINATE_UNITS, ORDINATE_UNITS

    declared = str(self.frame.loc[row, 'channel_type'])
    return list(ORDINATE_UNITS.get(declared, ALL_ORDINATE_UNITS))
delete_channels
delete_channels(indices: Sequence[int]) -> None

Remove the given rows in place; the last one is refused.

Parameters:

Name Type Description Default
indices sequence of int

Which rows to remove.

required

Returns:

Type Description
None
Source code in src/visualdynamics/core/channel_table.py
def delete_channels(self, indices: Sequence[int]) -> None:
    """Remove the given rows in place; the last one is refused.

    Parameters
    ----------
    indices : sequence of int
        Which rows to remove.

    Returns
    -------
    None
    """
    doomed = {int(i) for i in indices}
    bad = [i for i in doomed if not 0 <= i < self.num_channels]
    if bad:
        raise IndexError(f'no such channels: {sorted(bad)}')
    keep = [i for i in range(self.num_channels) if i not in doomed]
    if not keep:
        raise ValueError('cannot delete every channel; delete the '
                         'object instead')
    self.frame = self.frame.iloc[keep].reset_index(drop=True)
dof_strings
dof_strings() -> list[str]

Each channel's degree of freedom, as '101Z+' strings.

Source code in src/visualdynamics/core/channel_table.py
def dof_strings(self) -> list[str]:
    """Each channel's degree of freedom, as '101Z+' strings."""
    return [f'{node}{direction}' for node, direction
            in zip(self.frame['node'], self.frame['direction'])]
rename_dof
rename_dof(
    old: str, new: str, quantity: str | None = None
) -> int

Give the channel at coordinate old the coordinate new, in place — its node and direction cells rewritten, since a DOF string is those two concatenated. The same correction a data array's rename_dof makes, for the table that names the channels (Brandon, 2026-09-06), and the same rule: the channel moves, not every channel at the point.

Parameters:

Name Type Description Default
old str

The coordinate as the table has it, '101Z+'.

required
new str

The coordinate to give it: a node number then a direction, normalised the way every DOF is, and refused when it is not one — a table row may lack a node or a direction, but a correction typed by a person is whole.

required
quantity str

Which channel at old, by the quantity its unit names ('acceleration', 'force' …; 'unknown' where the unit names none). Every channel at the coordinate when omitted.

None

Returns:

Type Description
int

How many channels changed.

Source code in src/visualdynamics/core/channel_table.py
def rename_dof(self, old: str, new: str,
               quantity: str | None = None) -> int:
    """Give the channel at coordinate `old` the coordinate `new`,
    in place — its node and direction cells rewritten, since a DOF
    string is those two concatenated. The same correction a data
    array's `rename_dof` makes, for the table that names the
    channels (Brandon, 2026-09-06), and the same rule: the channel
    moves, not every channel at the point.

    Parameters
    ----------
    old : str
        The coordinate as the table has it, '101Z+'.
    new : str
        The coordinate to give it: a node number then a direction,
        normalised the way every DOF is, and refused when it is not
        one — a table row may lack a node or a direction, but a
        correction typed by a person is whole.
    quantity : str, optional
        Which channel at `old`, by the quantity its unit names
        ('acceleration', 'force' …; 'unknown' where the unit names
        none). Every channel at the coordinate when omitted.

    Returns
    -------
    int
        How many channels changed.
    """
    from ..units import dimension_of
    from .data import parse_dof
    from .validate import dofs

    old = str(old).strip()
    (new,) = dofs([str(new)], 'DOF')
    node, direction = parse_dof(new)
    if node is None:
        raise ValueError(f'{new!r} has no node number')
    units = [str(u).strip() for u in self.frame['unit']]
    named = [(dimension_of(u) if u else None) or 'unknown' for u in units]
    rows = [i for i, dof in enumerate(self.dof_strings())
            if dof == old and quantity in (None, named[i])]
    if not rows:
        raise ValueError(f'no {quantity + " " if quantity else ""}channel '
                         f'at {old!r}')
    if new == old:
        return 0
    for i in rows:
        self.set_cell('node', i, node)
        self.set_cell('direction', i, direction)
    return len(rows)
roles
roles() -> list[str]

Each channel's declared role, '' where undeclared.

Source code in src/visualdynamics/core/channel_table.py
def roles(self) -> list[str]:
    """Each channel's declared role, '' where undeclared."""
    return [str(v) for v in self.frame['role']]
controls
controls() -> ndarray

Which channels are control channels, as booleans.

Source code in src/visualdynamics/core/channel_table.py
def controls(self) -> np.ndarray:
    """Which channels are control channels, as booleans."""
    return np.array([_parse_flag(str(v)) for v in self.frame['control']])
types
types() -> list[str]

What each channel measures, '' where undeclared.

Source code in src/visualdynamics/core/channel_table.py
def types(self) -> list[str]:
    """What each channel measures, '' where undeclared."""
    return [str(v) for v in self.frame['channel_type']]
sensitivities
sensitivities() -> ndarray

mV per engineering unit, NaN where undeclared.

Source code in src/visualdynamics/core/channel_table.py
def sensitivities(self) -> np.ndarray:
    """mV per engineering unit, NaN where undeclared."""
    return np.array([float(v) if str(v).strip() else np.nan
                     for v in self.frame['sensitivity']])
ranges
ranges() -> ndarray

The instrumentation voltage limit, per channel.

Source code in src/visualdynamics/core/channel_table.py
def ranges(self) -> np.ndarray:
    """The instrumentation voltage limit, per channel."""
    return np.array([int(v) for v in self.frame['range']])
save
save(path: str | PathLike) -> None

Write the table to a file of its own.

Parameters:

Name Type Description Default
path str or PathLike

Where to write it.

required

Returns:

Type Description
None
Source code in src/visualdynamics/core/channel_table.py
def save(self, path: str | os.PathLike) -> None:
    """Write the table to a file of its own.

    Parameters
    ----------
    path : str or os.PathLike
        Where to write it.

    Returns
    -------
    None
    """
    from ..io import native
    native.save(self, path)

Functions:

title_of

title_of(name: str) -> str

The header a column wears, wherever it is shown — the table in the window and the table in the report are the same table.

Source code in src/visualdynamics/core/channel_table.py
def title_of(name: str) -> str:
    """The header a column wears, wherever it is shown — the table in
    the window and the table in the report are the same table."""
    return TITLES.get(name) or name.replace('_', ' ').title()

COLUMNS_KIND

COLUMNS_KIND(name: str) -> str

What kind of value a column holds.

Source code in src/visualdynamics/core/channel_table.py
def COLUMNS_KIND(name: str) -> str:
    """What kind of value a column holds."""
    return ChannelTable.COLUMNS[name].kind

CHOICES_FOR

CHOICES_FOR(name: str) -> tuple[str, ...]

The values a column limits itself to, empty when it does not.

'control' is a choice in every sense a spreadsheet cares about, though the schema calls it a flag.

Source code in src/visualdynamics/core/channel_table.py
def CHOICES_FOR(name: str) -> tuple[str, ...]:
    """The values a column limits itself to, empty when it does not.

    'control' is a choice in every sense a spreadsheet cares about,
    though the schema calls it a flag.
    """
    spec = ChannelTable.COLUMNS[name]
    if spec.kind == 'flag':
        return ('True', 'False')
    return spec.choices

canonical_name

canonical_name(name: str) -> str

A source's column name as this schema spells it.

Tidy first — case, spaces, hyphens, a trailing colon someone typed, and a parenthetical unit — then the alias table for words that genuinely differ. The parenthetical matters twice over: it is how this schema writes its own headers ('Sensitivity (mV/Unit)'), so a spreadsheet exported from here has to read back in; and it is how a calibration lab writes theirs ('Sensitivity (mV/g)'), which lands in the same column for the same reason.

Source code in src/visualdynamics/core/channel_table.py
def canonical_name(name: str) -> str:
    """A source's column name as this schema spells it.

    Tidy first — case, spaces, hyphens, a trailing colon someone typed,
    and a parenthetical unit — then the alias table for words that
    genuinely differ. The parenthetical matters twice over: it is how
    this schema writes its own headers ('Sensitivity (mV/Unit)'), so a
    spreadsheet exported from here has to read back in; and it is how a
    calibration lab writes theirs ('Sensitivity (mV/g)'), which lands in
    the same column for the same reason.
    """
    text = str(name).strip().rstrip(':')
    if '(' in text:
        text = text.split('(', 1)[0]
    key = '_'.join(text.lower().replace('-', ' ').split())
    return _ALIAS_OF.get(key, key)