Skip to content

visualdynamics.core.channel_table

channel_table

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

The table has one fixed set of columns, each with a type and a rule for what it accepts, and a source's other columns are dropped. Because every column says what it holds, a direction can be offered as a drop-down, a unit can be limited by the channel's type, and an expiration is read as a date.

A file and an edit are checked differently:

  • A file is read leniently. A value that cannot be read takes the column's default (blank for most columns) and the import goes on, because refusing the import would leave the user nothing to correct, and this table is where they correct it. validate.dofs does the same for DOFs.
  • An edit is refused. When a person types a value the column cannot hold, set_cell raises with the reason and writes nothing.

Classes:

Name Description
Spec

What one column holds, and what it accepts.

Environment

One control loop of a run: the channels the controller read as

ChannelTable

Per-channel metadata, in one fixed set of typed columns.

Functions:

Name Description
title_of

The header shown for a column, in the window and in the report.

COLUMNS_KIND

What kind of value a column holds, its Spec.kind.

CHOICES_FOR

The values a column is limited to, empty when it is not limited.

canonical_name

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

Classes

Spec

Bases: NamedTuple

What one column holds, and what it accepts.

kind is how a value is read ('int', 'index', 'float', 'choice', 'flag', 'date', 'unit' or 'text'), default fills a missing or unreadable cell, choices limits a 'choice' column, minimum bounds a numeric one, and unique makes the column a key.

Environment dataclass

Environment(controls: list[int] = list(), drives: list[int] = list(), response_transformation: ndarray | None = None, reference_transformation: ndarray | None = None)

One control loop of a run: the channels the controller read as its control channels, the ones it drove, and the transformations it put over them, if any.

Channels are named by number (the channel column, the table's join key), so the record survives a row deleted elsewhere in the table. The controller's order is kept, because a transformation's columns are the controls, or the drives, in that order.

Attributes:

Name Type Description
controls list of int

The control channels' numbers, in the controller's order.

drives list of int

The drive channels' numbers, in the controller's order.

response_transformation ndarray or None

(rows, len(controls)): what the controller applied to every frame of its control channels, controlling the rows in their place. None when it controlled the channels themselves.

reference_transformation ndarray or None

(rows, len(drives)): what it applied to its drive signals. None when it drove the channels themselves.

Methods:

Name Description
matrix

The 'response' or the 'reference' transformation.

Methods:
matrix
matrix(form: str) -> ndarray | None

The 'response' or the 'reference' transformation.

Source code in src/visualdynamics/core/channel_table.py
def matrix(self, form: str) -> np.ndarray | None:
    """The 'response' or the 'reference' transformation."""
    if form not in TRANSFORMATION_FORMS:
        raise ValueError(f'{form!r} is not a transformation; use one '
                         f'of {TRANSFORMATION_FORMS}')
    return getattr(self, f'{form}_transformation')

ChannelTable

ChannelTable(columns: Mapping[str, Any] | DataFrame, environments: Mapping[str, Environment] | None = None)

Per-channel metadata, in one fixed set of typed columns.

Parameters:

Name Type Description Default
columns mapping or DataFrame

The columns, by name, each with one entry per channel. Names are read through canonical_name, columns outside COLUMNS are dropped, missing ones take their default, and the values are read leniently (see the module description). A channel type left blank is filled in from the unit when the unit names one.

required
environments mapping of str to Environment

The run's control loops by name, each checked as set_environment checks it.

None

Raises:

Type Description
ValueError

If a column in CORE is missing, or the columns differ in length.

Attributes:

Name Type Description
frame DataFrame

The table, one row per channel, columns in SCHEMA order. Every cell is text except channel, which is an integer. node is text too, blank where no node was recorded.

environments dict of str to Environment

The control loops, in the order they were set. Empty for a table that describes no controller run. While a table has any, its Control column is the union of their control channels and is set through them (set_control), not directly.

Methods:

Name Description
set_cell

Write one cell, or refuse a value the column cannot hold.

units_for

The units to offer for this channel, given its declared type.

add_channels

Add blank channels at the end of the table, numbered on from

chosen

The rows of the channels choice picks, in table order.

choices

The CHANNEL_CHOICES that pick a channel, in that order:

delete_channels

Remove the given rows in place. At least one row must remain.

orientation

One channel's measured direction on a geometry.

derived_cells

The derived columns' cells for one row, as text.

dof_strings

Each channel's degree of freedom, as '101Z+' strings: the node

rename_dof

Give the channel at coordinate old the coordinate new, in

identities

Each row as the (DOF, quantity) channel of history it names.

roles

Each channel's declared role ('reference', 'response' or

controls

Which channels are control channels, as a boolean per row.

controls_for

The control channels a reference over dofs was held at, as

drives

Which channels the environment drove, as a boolean per row.

rows_of

The rows of the given channel numbers, in the given order.

set_environment

Record a control loop, or replace the one of that name.

remove_environment

Forget a control loop. The Control column keeps what it

set_control

Check or clear Control on one channel for one environment.

transformation

A control loop's transformation as a labeled matrix.

types

Each channel's type (what it measures), '' where undeclared.

sensitivities

Each channel's sensitivity in mV per engineering unit, NaN where

daq_ranges

Each channel's DAQ input range in volts, NaN where unstated.

sensor_ranges

Each sensor's full-scale output in volts, NaN where unstated.

clip_ranges

The level each channel clips at, in volts: the smaller of its

save

Write the table to a file of its own, in the .vdyn format.

Source code in src/visualdynamics/core/channel_table.py
def __init__(self, columns: Mapping[str, Any] | pd.DataFrame,
             environments: Mapping[str, Environment] | None = None
             ) -> 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)])
    self.environments: dict[str, Environment] = {}
    for name, environment in (environments or {}).items():
        self.set_environment(name, environment)
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 names, in order (title_of gives the headers shown).

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

Write one cell, or refuse a value the column cannot hold.

A unit that is not of the channel's declared type is refused; change the type first. Changing the type clears a unit of another kind. A model in the sensor catalog (core.sensor_catalog) fills a blank Make with its maker; a make already written is kept. A triaxial accelerometer's model or serial number is written once: the other acceleration rows at its node, its other legs, get its Make, Model and Serial Number where theirs are blank (and their model is blank or the same). A row of another type at that node, a force gauge, is never filled.

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

Raises:

Type Description
KeyError

If there is no column name.

ValueError

If the value cannot be read for that column, repeats a channel number, or disagrees with the channel's type.

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

    A unit that is not of the channel's declared type is refused;
    change the type first. Changing the type clears a unit of another
    kind. A model in the sensor catalog (`core.sensor_catalog`) fills
    a blank Make with its maker; a make already written is kept. A
    triaxial accelerometer's model or serial number is written once:
    the other acceleration rows at its node, its other legs, get its
    Make, Model and Serial Number where theirs are blank (and their
    model is blank or the same). A row of another type at that node,
    a force gauge, is never filled.

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

    Returns
    -------
    None

    Raises
    ------
    KeyError
        If there is no column `name`.
    ValueError
        If the value cannot be read for that column, repeats a
        channel number, or disagrees with the channel's type.
    """
    # Strict where `_typed` is lenient: a person typing gets the
    # reason, where a file gets the benefit of the doubt.
    if name not in self.COLUMNS:
        raise KeyError(f'no column {name!r}')
    if name == 'control' and self.environments:
        raise ValueError(
            'this table\'s control channels are per environment ('
            + ', '.join(self.environments)
            + '); set them with set_control')
    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')
    # Control and role are two questions, answered independently
    # (Brandon, 2026-09-26): what the controller drove the test to
    # match, and what the channel is to an FRF. A controlled force
    # can be an FRF's reference, a controlled voltage too, and
    # either can be a monitor. The schema once refused every
    # control channel that was not a response, as "the one nonsense
    # pair"; force and voltage control are why it was not one.
    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'] = ''
    if name == 'model' and coerced:
        # picking a model names its maker too (Brandon, 2026-10-26)
        from .sensor_catalog import lookup

        entry = lookup(str(coerced))
        if entry is not None and not str(self.frame.loc[row, 'make']).strip():
            self.frame.loc[row, 'make'] = entry.make
    if name in ('model', 'serial_number') and coerced:
        self._fill_triax(row)
units_for
units_for(row: int) -> list[str]

The units to offer for this channel, given its declared type.

Parameters:

Name Type Description Default
row int

Which channel.

required

Returns:

Type Description
list of str

The shortlist for the declared type, or every unit in every shortlist when the type is blank.

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

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

    Returns
    -------
    list of str
        The shortlist for the declared type, or every unit in every
        shortlist when the type is blank.
    """
    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))
add_channels
add_channels(count: int = 1) -> list[int]

Add blank channels at the end of the table, numbered on from the highest channel in it (from 1 in an empty table).

Every other cell takes its column's default, so the node, direction and unit are blank until they are typed in.

Parameters:

Name Type Description Default
count int

How many channels to add.

1

Returns:

Type Description
list of int

The new channels' numbers.

Raises:

Type Description
ValueError

If count is less than 1.

Source code in src/visualdynamics/core/channel_table.py
def add_channels(self, count: int = 1) -> list[int]:
    """Add blank channels at the end of the table, numbered on from
    the highest channel in it (from 1 in an empty table).

    Every other cell takes its column's default, so the node,
    direction and unit are blank until they are typed in.

    Parameters
    ----------
    count : int, default 1
        How many channels to add.

    Returns
    -------
    list of int
        The new channels' numbers.

    Raises
    ------
    ValueError
        If `count` is less than 1.
    """
    import pandas as pd

    count = int(count)
    if count < 1:
        raise ValueError(f'cannot add {count} channels; add at least one')
    start = int(self.frame['channel'].max()) + 1 if self.num_channels else 1
    numbers = list(range(start, start + count))
    added = self._typed(pd.DataFrame(
        {name: numbers if name == 'channel' else [spec.default] * count
         for name, spec in self.COLUMNS.items()}))
    self.frame = pd.concat([self.frame, added], ignore_index=True)
    return numbers
chosen
chosen(choice: str = 'all') -> list[int]

The rows of the channels choice picks, in table order.

Parameters:

Name Type Description Default
choice str

One of CHANNEL_CHOICES: 'control' for the channels with Control checked, whatever their role, or a role.

'all'

Returns:

Type Description
list of int

The rows.

Raises:

Type Description
ValueError

If choice is not one of CHANNEL_CHOICES.

Source code in src/visualdynamics/core/channel_table.py
def chosen(self, choice: str = 'all') -> list[int]:
    """The rows of the channels `choice` picks, in table order.

    Parameters
    ----------
    choice : str, default 'all'
        One of `CHANNEL_CHOICES`: 'control' for the channels with
        Control checked, whatever their role, or a role.

    Returns
    -------
    list of int
        The rows.

    Raises
    ------
    ValueError
        If `choice` is not one of `CHANNEL_CHOICES`.
    """
    # One rule for every place channels are picked this way: the
    # arrows on a geometry and the shape values at a table's DOFs
    # (Brandon, 2026-10-22 and 2026-10-23).
    if choice not in CHANNEL_CHOICES:
        raise ValueError(f'{choice!r} is not a choice; use one of '
                         f'{CHANNEL_CHOICES}')
    if choice == 'all':
        return list(range(self.num_channels))
    if choice == 'control':
        return [int(i) for i in np.flatnonzero(self.controls())]
    return [i for i, role in enumerate(self.roles()) if role == choice]
choices
choices() -> list[str]

The CHANNEL_CHOICES that pick a channel, in that order: 'all' always, then 'control' when a channel has Control checked, then each role a channel holds.

Source code in src/visualdynamics/core/channel_table.py
def choices(self) -> list[str]:
    """The `CHANNEL_CHOICES` that pick a channel, in that order:
    'all' always, then 'control' when a channel has Control checked,
    then each role a channel holds."""
    held = set(self.roles())
    return [choice for choice in CHANNEL_CHOICES
            if choice == 'all' or (choice == 'control' and self.controls().any())
            or choice in held]
delete_channels
delete_channels(indices: Sequence[int]) -> None

Remove the given rows in place. At least one row must remain.

Parameters:

Name Type Description Default
indices sequence of int

Which rows to remove.

required

Returns:

Type Description
None

Raises:

Type Description
IndexError

If an index is out of range.

ValueError

If every row would be removed.

Source code in src/visualdynamics/core/channel_table.py
def delete_channels(self, indices: Sequence[int]) -> None:
    """Remove the given rows in place. At least one row must remain.

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

    Returns
    -------
    None

    Raises
    ------
    IndexError
        If an index is out of range.
    ValueError
        If every row would be removed.
    """
    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')
    gone = {int(self.frame['channel'].iloc[i]) for i in doomed}
    self.frame = self.frame.iloc[keep].reset_index(drop=True)
    # a deleted control or drive leaves its loop, and its column
    # leaves the transformation with it: the matrix stays over the
    # channels that are still there, rather than over a channel
    # count the table no longer has
    for environment in self.environments.values():
        for form, listed in (('response', 'controls'),
                             ('reference', 'drives')):
            numbers = list(getattr(environment, listed))
            stay = [k for k, n in enumerate(numbers) if n not in gone]
            setattr(environment, listed, [numbers[k] for k in stay])
            matrix = environment.matrix(form)
            if matrix is not None and len(stay) != len(numbers):
                setattr(environment, f'{form}_transformation',
                        matrix[:, stay])
    self._sync_controls()
orientation
orientation(row: int, geometry: Any) -> tuple[ndarray | None, str | None, float | None]

One channel's measured direction on a geometry.

The row's DOF (node and direction) is read through the coordinate system its node is measured in (Geometry.dof_direction). The result is that direction as a unit vector in the geometry's global system, the signed global axis nearest it ('X+', 'Z-'), and the angle between the two in degrees. These are the values the derived columns show.

Parameters:

Name Type Description Default
row int

The channel's row.

required
geometry Geometry

The geometry the channel is measured on.

required

Returns:

Type Description
tuple

(vector, axis, angle), each None when the geometry is None or cannot place the channel: the node is not on it, the direction is missing, or the node is measured in a coordinate system that turns with position.

Source code in src/visualdynamics/core/channel_table.py
def orientation(self, row: int, geometry: Any
                ) -> tuple[np.ndarray | None, str | None, float | None]:
    """One channel's measured direction on a geometry.

    The row's DOF (node and direction) is read through the coordinate
    system its node is measured in (`Geometry.dof_direction`). The
    result is that direction as a unit vector in the geometry's
    global system, the signed global axis nearest it ('X+', 'Z-'),
    and the angle between the two in degrees. These are the values
    the derived columns show.

    Parameters
    ----------
    row : int
        The channel's row.
    geometry : Geometry
        The geometry the channel is measured on.

    Returns
    -------
    tuple
        (vector, axis, angle), each None when the geometry is None
        or cannot place the channel: the node is not on it, the
        direction is missing, or the node is measured in a
        coordinate system that turns with position.
    """
    if geometry is None:
        return None, None, None
    vector = geometry.dof_direction(self.dof_strings()[row])
    if vector is None:
        return None, None, None
    k = int(np.argmax(np.abs(vector)))
    axis = f'{AXES[k]}{"+" if vector[k] >= 0 else "-"}'
    angle = float(np.degrees(np.arccos(min(1.0, abs(float(vector[k]))))))
    return vector, axis, angle
derived_cells
derived_cells(row: int, geometry: Any) -> list[str]

The derived columns' cells for one row, as text.

The three components of the direction to three decimals, the nearest axis, and the angle to a tenth of a degree; all empty when the geometry cannot place the channel.

Parameters:

Name Type Description Default
row int

The channel's row.

required
geometry Geometry

The geometry the channel is measured on.

required

Returns:

Type Description
list of str

One cell per DERIVED_COLUMNS entry.

Source code in src/visualdynamics/core/channel_table.py
def derived_cells(self, row: int, geometry: Any) -> list[str]:
    """The derived columns' cells for one row, as text.

    The three components of the direction to three decimals, the
    nearest axis, and the angle to a tenth of a degree; all empty
    when the geometry cannot place the channel.

    Parameters
    ----------
    row : int
        The channel's row.
    geometry : Geometry
        The geometry the channel is measured on.

    Returns
    -------
    list of str
        One cell per `DERIVED_COLUMNS` entry.
    """
    vector, axis, angle = self.orientation(row, geometry)
    if vector is None:
        return [''] * len(DERIVED_COLUMNS)
    return [f'{float(v):+.3f}' for v in vector] + [axis, f'{angle:.1f}']
dof_strings
dof_strings() -> list[str]

Each channel's degree of freedom, as '101Z+' strings: the node and direction cells joined, so a missing one is left out.

Source code in src/visualdynamics/core/channel_table.py
def dof_strings(self) -> list[str]:
    """Each channel's degree of freedom, as '101Z+' strings: the node
    and direction cells joined, so a missing one is left out."""
    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, by rewriting its node and direction cells.

This is the same correction as DataArray.rename_dof, made in the table that names the channels. Only the channels at old that match quantity are changed, not every channel at the node.

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, normalized as every DOF is. It is refused if either part is missing, although a table row may lack one.

required
quantity str

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

None

Returns:

Type Description
int

How many channels changed; 0 when new equals old.

Raises:

Type Description
ValueError

If new is not a whole DOF, or no channel matches.

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, by rewriting its node and direction cells.

    This is the same correction as `DataArray.rename_dof`, made in the
    table that names the channels. Only the channels at `old` that
    match `quantity` are changed, not every channel at the node.

    Parameters
    ----------
    old : str
        The coordinate as the table has it, '101Z+'.
    new : str
        The coordinate to give it: a node number then a direction,
        normalized as every DOF is. It is refused if either part is
        missing, although a table row may lack one.
    quantity : str, optional
        Which channel at `old`, by the quantity its unit names
        ('acceleration', 'force' and so on; 'unknown' where the unit
        names none). Every channel at the coordinate when omitted.

    Returns
    -------
    int
        How many channels changed; 0 when `new` equals `old`.

    Raises
    ------
    ValueError
        If `new` is not a whole DOF, or no channel matches.
    """
    # The table's version of a data array's `rename_dof` (Brandon,
    # 2026-09-06), with the same rule: the channel moves, not every
    # channel at the point.
    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)
identities
identities(history: Any = None) -> list[Any]

Each row as the (DOF, quantity) channel of history it names.

The row's channel type is the quantity, the same word a time history's ordinate_dim uses. A row with no channel type names the history's channel at its DOF when there is exactly one there. A row that names none of the history's channels is None.

Parameters:

Name Type Description Default
history TimeHistory

The recording the table describes. Without one, each row's own (DOF, type) is returned.

None

Returns:

Type Description
list of (str, str) or None

One entry per row.

Source code in src/visualdynamics/core/channel_table.py
def identities(self, history: Any = None) -> list[Any]:
    """Each row as the (DOF, quantity) channel of `history` it names.

    The row's channel type is the quantity, the same word a time
    history's `ordinate_dim` uses. A row with no channel type names
    the history's channel at its DOF when there is exactly one
    there. A row that names none of the history's channels is None.

    Parameters
    ----------
    history : TimeHistory, optional
        The recording the table describes. Without one, each row's
        own (DOF, type) is returned.

    Returns
    -------
    list of (str, str) or None
        One entry per row.
    """
    # The rule the project's role pairing used privately, made the
    # table's so the overload check reads ranges by the same match.
    dofs, types = self.dof_strings(), self.types()
    if history is None:
        return list(zip(dofs, types))
    identities = history.channel_identities()
    known = set(identities)
    out: list[Any] = []
    for dof, kind in zip(dofs, types):
        if kind:
            out.append((dof, kind) if (dof, kind) in known else None)
        else:
            at = [identity for identity in identities
                  if identity[0] == dof]
            out.append(at[0] if len(at) == 1 else None)
    return out
roles
roles() -> list[str]

Each channel's declared role ('reference', 'response' or 'monitor'), '' where undeclared.

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

Which channels are control channels, as a boolean per row.

Parameters:

Name Type Description Default
environment str

One of environments: that loop's control channels. Left out, the Control column, which is the union of every environment's when the table has any.

None

Returns:

Type Description
ndarray

True where the channel is a control channel, one per row.

Raises:

Type Description
ValueError

If there is no environment of that name.

Source code in src/visualdynamics/core/channel_table.py
def controls(self, environment: str | None = None) -> np.ndarray:
    """Which channels are control channels, as a boolean per row.

    Parameters
    ----------
    environment : str, optional
        One of `environments`: that loop's control channels. Left
        out, the Control column, which is the union of every
        environment's when the table has any.

    Returns
    -------
    numpy.ndarray
        True where the channel is a control channel, one per row.

    Raises
    ------
    ValueError
        If there is no environment of that name.
    """
    if environment is None:
        return np.array([_parse_flag(str(v)) for v in self.frame['control']])
    return self._flags(self._environment(environment).controls)
controls_for
controls_for(dofs: Sequence[Any]) -> ndarray

The control channels a reference over dofs was held at, as a boolean per row.

A reference belongs to the loop that controlled what it is over: a transformed loop's rows ('1', '2', ...), or another loop's control channels. With no environments, or one, that is the Control column (controls). With several, it is the one loop the DOFs name; where none or more than one is named, no channel is flagged, so a reader falls back to the reference's own pairing rather than taking every loop's controls.

Parameters:

Name Type Description Default
dofs sequence

The reference's DOFs, compared as text.

required

Returns:

Type Description
ndarray

True where the channel is a control channel of that loop.

Source code in src/visualdynamics/core/channel_table.py
def controls_for(self, dofs: Sequence[Any]) -> np.ndarray:
    """The control channels a reference over `dofs` was held at, as
    a boolean per row.

    A reference belongs to the loop that controlled what it is
    over: a transformed loop's rows ('1', '2', ...), or another
    loop's control channels. With no environments, or one, that is
    the Control column (`controls`). With several, it is the one
    loop the DOFs name; where none or more than one is named, no
    channel is flagged, so a reader falls back to the reference's
    own pairing rather than taking every loop's controls.

    Parameters
    ----------
    dofs : sequence
        The reference's DOFs, compared as text.

    Returns
    -------
    numpy.ndarray
        True where the channel is a control channel of that loop.
    """
    # The union of every loop's controls read a sine's channels into
    # a random reading of a combined run (found in Kevin Cross's
    # #199 on 2026-10-28; Brandon asked for the fix 2026-10-29).
    if len(self.environments) <= 1:
        return self.controls()
    asked = {str(d) for d in dofs}
    strings = self.dof_strings()
    named = []
    for name, environment in self.environments.items():
        matrix = environment.response_transformation
        if matrix is not None:
            held = {str(k + 1) for k in range(matrix.shape[0])}
        else:
            held = {strings[r] for r in self.rows_of(environment.controls)}
        if held & asked:
            named.append(name)
    if len(named) != 1:
        return np.zeros(len(self.frame), dtype=bool)
    return self.controls(named[0])
drives
drives(environment: str) -> ndarray

Which channels the environment drove, as a boolean per row.

Parameters:

Name Type Description Default
environment str

One of environments.

required

Returns:

Type Description
ndarray

True where the channel is one of that loop's drives.

Raises:

Type Description
ValueError

If there is no environment of that name.

Source code in src/visualdynamics/core/channel_table.py
def drives(self, environment: str) -> np.ndarray:
    """Which channels the environment drove, as a boolean per row.

    Parameters
    ----------
    environment : str
        One of `environments`.

    Returns
    -------
    numpy.ndarray
        True where the channel is one of that loop's drives.

    Raises
    ------
    ValueError
        If there is no environment of that name.
    """
    return self._flags(self._environment(environment).drives)
rows_of
rows_of(numbers: Sequence[int]) -> list[int]

The rows of the given channel numbers, in the given order.

Parameters:

Name Type Description Default
numbers sequence of int

Channel numbers (the channel column).

required

Returns:

Type Description
list of int

Their rows.

Raises:

Type Description
ValueError

If a number is no channel of the table.

Source code in src/visualdynamics/core/channel_table.py
def rows_of(self, numbers: Sequence[int]) -> list[int]:
    """The rows of the given channel numbers, in the given order.

    Parameters
    ----------
    numbers : sequence of int
        Channel numbers (the `channel` column).

    Returns
    -------
    list of int
        Their rows.

    Raises
    ------
    ValueError
        If a number is no channel of the table.
    """
    held = {int(n): r for r, n in enumerate(self.frame['channel'])}
    missing = [int(n) for n in numbers if int(n) not in held]
    if missing:
        raise ValueError(f'no such channels: {missing}')
    return [held[int(n)] for n in numbers]
set_environment
set_environment(name: str, environment: Environment) -> None

Record a control loop, or replace the one of that name.

Parameters:

Name Type Description Default
name str

The environment's name, as the controller names it.

required
environment Environment

Its channels and matrices. Every channel number must be a channel of the table, listed once; a response transformation must be two-dimensional with one column per control channel, and a reference transformation one per drive.

required

Returns:

Type Description
None

Raises:

Type Description
ValueError

If the name is blank or any of that fails. Nothing is recorded then.

Source code in src/visualdynamics/core/channel_table.py
def set_environment(self, name: str, environment: Environment) -> None:
    """Record a control loop, or replace the one of that name.

    Parameters
    ----------
    name : str
        The environment's name, as the controller names it.
    environment : Environment
        Its channels and matrices. Every channel number must be a
        channel of the table, listed once; a response transformation
        must be two-dimensional with one column per control channel,
        and a reference transformation one per drive.

    Returns
    -------
    None

    Raises
    ------
    ValueError
        If the name is blank or any of that fails. Nothing is
        recorded then.
    """
    name = str(name).strip()
    if not name:
        raise ValueError('an environment needs a name')
    checked = Environment(
        controls=[int(n) for n in environment.controls],
        drives=[int(n) for n in environment.drives])
    for listed in ('controls', 'drives'):
        numbers = getattr(checked, listed)
        if len(set(numbers)) != len(numbers):
            raise ValueError(f'{name}: a channel is listed twice among '
                             f'its {listed}')
        try:
            self.rows_of(numbers)
        except ValueError as refusal:
            raise ValueError(f'{name} {listed}: {refusal}') from None
    for form, listed in (('response', 'controls'),
                         ('reference', 'drives')):
        matrix = environment.matrix(form)
        if matrix is None:
            continue
        matrix = np.asarray(matrix, dtype=float)
        count = len(getattr(checked, listed))
        if matrix.ndim != 2 or matrix.shape[1] != count:
            raise ValueError(
                f'{name}: the {form} transformation is '
                f'{matrix.shape} but the environment has {count} '
                f'{listed}')
        setattr(checked, f'{form}_transformation', matrix.copy())
    self.environments[name] = checked
    self._sync_controls()
remove_environment
remove_environment(name: str) -> None

Forget a control loop. The Control column keeps what it showed, which a table without environments may then edit.

Parameters:

Name Type Description Default
name str

The environment to forget.

required

Returns:

Type Description
None

Raises:

Type Description
ValueError

If there is no environment of that name.

Source code in src/visualdynamics/core/channel_table.py
def remove_environment(self, name: str) -> None:
    """Forget a control loop. The Control column keeps what it
    showed, which a table without environments may then edit.

    Parameters
    ----------
    name : str
        The environment to forget.

    Returns
    -------
    None

    Raises
    ------
    ValueError
        If there is no environment of that name.
    """
    self._environment(name)
    del self.environments[name]
    if self.environments:
        self._sync_controls()
set_control
set_control(environment: str, row: int, value: object) -> None

Check or clear Control on one channel for one environment.

A channel checked joins the loop's control channels, after the ones it has; cleared, it leaves them. The Control column follows as the union.

Parameters:

Name Type Description Default
environment str

Which loop.

required
row int

Which channel.

required
value object

True or False, in any spelling Control accepts.

required

Returns:

Type Description
None

Raises:

Type Description
ValueError

If there is no such environment, the flag cannot be read, or the environment transforms its control channels: the matrix's columns are those channels, so it is set with them (set_environment), not one at a time.

Source code in src/visualdynamics/core/channel_table.py
def set_control(self, environment: str, row: int, value: object) -> None:
    """Check or clear Control on one channel for one environment.

    A channel checked joins the loop's control channels, after the
    ones it has; cleared, it leaves them. The Control column follows
    as the union.

    Parameters
    ----------
    environment : str
        Which loop.
    row : int
        Which channel.
    value : object
        True or False, in any spelling `Control` accepts.

    Returns
    -------
    None

    Raises
    ------
    ValueError
        If there is no such environment, the flag cannot be read, or
        the environment transforms its control channels: the
        matrix's columns are those channels, so it is set with them
        (`set_environment`), not one at a time.
    """
    held = self._environment(environment)
    flag = _parse_flag(str(value))
    if held.response_transformation is not None:
        raise ValueError(
            f'{environment} transforms its control channels; the '
            'transformation\'s columns are those channels, so set them '
            'together with set_environment')
    number = int(self.frame['channel'].iloc[int(row)])
    if flag and number not in held.controls:
        held.controls.append(number)
    elif not flag and number in held.controls:
        held.controls.remove(number)
    self._sync_controls()
transformation
transformation(environment: str, form: str = 'response')

A control loop's transformation as a labeled matrix.

Parameters:

Name Type Description Default
environment str

Which loop.

required
form str

'response' for the matrix over its control channels, applied to what they measure, or 'reference' for the one over its drives, applied to the drive signals.

'response'

Returns:

Type Description
MatrixAt

The rows numbered from 1, as the controller's records of them are named, and a column per control (or drive) channel headed by its DOF, in the controller's order.

Raises:

Type Description
ValueError

If there is no such environment, or it holds no such transformation.

Source code in src/visualdynamics/core/channel_table.py
def transformation(self, environment: str, form: str = 'response'):
    """A control loop's transformation as a labeled matrix.

    Parameters
    ----------
    environment : str
        Which loop.
    form : str, default 'response'
        'response' for the matrix over its control channels, applied
        to what they measure, or 'reference' for the one over its
        drives, applied to the drive signals.

    Returns
    -------
    core.transform.MatrixAt
        The rows numbered from 1, as the controller's records of
        them are named, and a column per control (or drive) channel
        headed by its DOF, in the controller's order.

    Raises
    ------
    ValueError
        If there is no such environment, or it holds no such
        transformation.
    """
    from .transform import MatrixAt

    held = self._environment(environment)
    matrix = held.matrix(form)
    if matrix is None:
        raise ValueError(f'{environment} holds no {form} transformation')
    numbers = held.controls if form == 'response' else held.drives
    dofs = self.dof_strings()
    return MatrixAt(form=form,
                    rows=[str(k + 1) for k in range(matrix.shape[0])],
                    columns=[dofs[r] for r in self.rows_of(numbers)],
                    values=matrix.copy())
types
types() -> list[str]

Each channel's type (what it measures), '' where undeclared.

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

Each channel's sensitivity in mV per engineering unit, NaN where undeclared.

Source code in src/visualdynamics/core/channel_table.py
def sensitivities(self) -> np.ndarray:
    """Each channel's sensitivity in 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']])
daq_ranges
daq_ranges() -> ndarray

Each channel's DAQ input range in volts, NaN where unstated.

Source code in src/visualdynamics/core/channel_table.py
def daq_ranges(self) -> np.ndarray:
    """Each channel's DAQ input range in volts, NaN where unstated."""
    return self._volts('daq_range')
sensor_ranges
sensor_ranges() -> ndarray

Each sensor's full-scale output in volts, NaN where unstated.

Source code in src/visualdynamics/core/channel_table.py
def sensor_ranges(self) -> np.ndarray:
    """Each sensor's full-scale output in volts, NaN where unstated."""
    return self._volts('sensor_range')
clip_ranges
clip_ranges() -> ndarray

The level each channel clips at, in volts: the smaller of its DAQ range and its sensor range, whichever is stated, NaN where neither is.

Source code in src/visualdynamics/core/channel_table.py
def clip_ranges(self) -> np.ndarray:
    """The level each channel clips at, in volts: the smaller of its
    DAQ range and its sensor range, whichever is stated, NaN where
    neither is."""
    with np.errstate(invalid='ignore'):
        return np.fmin(self.daq_ranges(), self.sensor_ranges())
save
save(path: str | PathLike) -> None

Write the table to a file of its own, in the .vdyn format.

Parameters:

Name Type Description Default
path str or PathLike

Where to write it; .vdyn is added when the suffix is not there.

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, in the `.vdyn` format.

    Parameters
    ----------
    path : str or os.PathLike
        Where to write it; `.vdyn` is added when the suffix is not
        there.

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

Functions:

title_of

title_of(name: str) -> str

The header shown for a column, in the window and in the report.

Parameters:

Name Type Description Default
name str

The column name, such as 'serial_number'.

required

Returns:

Type Description
str

The header, from TITLES or the name title-cased ('Serial Number').

Source code in src/visualdynamics/core/channel_table.py
def title_of(name: str) -> str:
    """The header shown for a column, in the window and in the report.

    Parameters
    ----------
    name : str
        The column name, such as 'serial_number'.

    Returns
    -------
    str
        The header, from `TITLES` or the name title-cased
        ('Serial Number').
    """
    return TITLES.get(name) or name.replace('_', ' ').title()

COLUMNS_KIND

COLUMNS_KIND(name: str) -> str

What kind of value a column holds, its Spec.kind.

Parameters:

Name Type Description Default
name str

The column name.

required

Returns:

Type Description
str

The kind, such as 'int', 'choice' or 'text'.

Source code in src/visualdynamics/core/channel_table.py
def COLUMNS_KIND(name: str) -> str:
    """What kind of value a column holds, its `Spec.kind`.

    Parameters
    ----------
    name : str
        The column name.

    Returns
    -------
    str
        The kind, such as 'int', 'choice' or 'text'.
    """
    return ChannelTable.COLUMNS[name].kind

CHOICES_FOR

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

The values a column is limited to, empty when it is not limited.

A flag column such as 'control' returns ('True', 'False'), since a spreadsheet treats it as a choice of two.

Parameters:

Name Type Description Default
name str

The column name.

required

Returns:

Type Description
tuple of str

The allowed values.

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

    A flag column such as 'control' returns ('True', 'False'), since a
    spreadsheet treats it as a choice of two.

    Parameters
    ----------
    name : str
        The column name.

    Returns
    -------
    tuple of str
        The allowed values.
    """
    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.

The name is lower-cased, spaces and hyphens become underscores, and a trailing colon and anything from an opening parenthesis on are dropped; then ChannelTable.ALIASES maps the words that differ. Dropping the parenthetical unit reads this table's own headers back ('Sensitivity (mV/Unit)'), and a calibration sheet's ('Sensitivity (mV/g)') into the same column.

Parameters:

Name Type Description Default
name str

The column name as the source wrote it.

required

Returns:

Type Description
str

The schema's name for it, or the tidied name if it has none.

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.

    The name is lower-cased, spaces and hyphens become underscores, and
    a trailing colon and anything from an opening parenthesis on are
    dropped; then `ChannelTable.ALIASES` maps the words that differ.
    Dropping the parenthetical unit reads this table's own headers back
    ('Sensitivity (mV/Unit)'), and a calibration sheet's
    ('Sensitivity (mV/g)') into the same column.

    Parameters
    ----------
    name : str
        The column name as the source wrote it.

    Returns
    -------
    str
        The schema's name for it, or the tidied name if it has none.
    """
    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)