Skip to content

visualdynamics.core.validate

validate

The checks on what an object accepts: ids, DOFs, frequencies and damping.

A type annotation tells an editor, a type checker and the API reference what a value is, and does nothing at run time: node_id: NDArray[np.int64] does not stop a string being passed. Each annotation here has a function that enforces it, kept in the same module so the two do not drift apart.

The errors these catch are the ones that would otherwise pass without notice. 'x' as a node id already fails inside numpy. 1.7 would become node 1, and a mode shape would be drawn on the wrong node with nothing in the file to say why.

Functions:

Name Description
ids

values as an int64 id array, or a ValueError naming the values

damping

Damping as a fraction of critical, per mode: finite and zero or

frequency

Modal frequencies in Hz, per mode: finite and zero or more.

modal_coordinate

The mode index a modal coordinate names, or None for anything

dofs

DOF strings, checked and normalized: a node id, then one of

Functions:

ids

ids(values: ArrayLike, label: str, unique: bool = False) -> IdArray

values as an int64 id array, or a ValueError naming the values that are wrong.

Whole numbers only. 1.7 is not node 1 with a rounding error; it means the caller computed an id, and truncating it without notice would hide that. Numeric strings are accepted, because the file formats in this field write ids as text, and requiring every reader to convert them first would mean many places to get the conversion wrong.

Ids cannot be negative: an id is a label, and a negative one has no meaning. Zero is allowed, because real files do number from zero.

Set unique where something refers to these by id: nodes, coordinate systems and blocks. Element ids are labels nothing refers to (a UNV trace line that lifts the pen, for example), so they are not checked for duplicates. label names the values in the error message.

Source code in src/visualdynamics/core/validate.py
def ids(values: ArrayLike, label: str, unique: bool = False) -> IdArray:
    """`values` as an int64 id array, or a ValueError naming the values
    that are wrong.

    Whole numbers only. `1.7` is not node 1 with a rounding error; it
    means the caller computed an id, and truncating it without notice
    would hide that. Numeric strings are accepted, because the file
    formats in this field write ids as text, and requiring every reader
    to convert them first would mean many places to get the conversion
    wrong.

    Ids cannot be negative: an id is a label, and a negative one has no
    meaning. Zero is allowed, because real files do number from zero.

    Set `unique` where something refers to these by id: nodes,
    coordinate systems and blocks. Element ids are labels nothing refers
    to (a UNV trace line that lifts the pen, for example), so they are
    not checked for duplicates. `label` names the values in the error
    message.
    """
    array = np.asarray(values)
    if array.size == 0:
        return np.asarray([], dtype=np.int64)
    if array.dtype.kind == 'f':
        whole = np.isfinite(array) & (array == np.floor(array))
        if not whole.all():
            bad = array[~whole][:3]
            raise ValueError(
                f'{label} must be whole numbers; got '
                + ', '.join(repr(float(v)) for v in bad))
    elif array.dtype.kind not in 'iu':
        # strings and objects: let numpy say which entry is not a number
        try:
            array = array.astype(np.int64)
        except (TypeError, ValueError) as bad:
            raise ValueError(f'{label} must be whole numbers: {bad}') from None
    out = array.astype(np.int64)
    if (out < 0).any():
        bad = out[out < 0][:3]
        raise ValueError(
            f'{label} cannot be negative; got '
            + ', '.join(str(int(v)) for v in bad))
    if unique and len(np.unique(out)) != len(out):
        seen, repeated = set(), []
        for value in out.tolist():
            if value in seen and value not in repeated:
                repeated.append(value)
            seen.add(value)
        raise ValueError(
            f'duplicate {label}: ' + ', '.join(str(v) for v in repeated[:5]))
    return out

damping

damping(values: ArrayLike, label: str = 'damping') -> NDArray[float64]

Damping as a fraction of critical, per mode: finite and zero or more.

There is no upper bound. Above 1.0 is overdamped, which can be measured even if it is not a mode anyone will animate, so the check is a lower limit and not a range. A negative value comes from a failed fit or an unintended sign convention, and a negative damping synthesizes an FRF that grows without bound.

Source code in src/visualdynamics/core/validate.py
def damping(values: ArrayLike,
            label: str = 'damping') -> NDArray[np.float64]:
    """Damping as a fraction of critical, per mode: finite and zero or
    more.

    There is no upper bound. Above 1.0 is overdamped, which can be
    measured even if it is not a mode anyone will animate, so the check
    is a lower limit and not a range. A negative value comes from a
    failed fit or an unintended sign convention, and a negative damping
    synthesizes an FRF that grows without bound.
    """
    return _nonnegative(values, f'{label} is a fraction of critical and '
                        'cannot be negative', label)

frequency

frequency(values: ArrayLike, label: str = 'frequency') -> NDArray[float64]

Modal frequencies in Hz, per mode: finite and zero or more.

Exactly zero is a rigid-body mode, and the FRF synthesis tests for it to cancel its 0/0, so zero is allowed and has a meaning. A negative value is not a frequency.

Source code in src/visualdynamics/core/validate.py
def frequency(values: ArrayLike,
              label: str = 'frequency') -> NDArray[np.float64]:
    """Modal frequencies in Hz, per mode: finite and zero or more.

    Exactly zero is a rigid-body mode, and the FRF synthesis tests for
    it to cancel its 0/0, so zero is allowed and has a meaning. A
    negative value is not a frequency.
    """
    return _nonnegative(values, f'{label} cannot be negative', label)

modal_coordinate

modal_coordinate(text: str) -> int | None

The mode index a modal coordinate names, or None for anything else. This is the one function that reads the M<index> spelling.

Parameters:

Name Type Description Default
text str

A DOF string.

required

Returns:

Type Description
int or None

The 1-based mode index, or None when the string is not a modal coordinate.

Source code in src/visualdynamics/core/validate.py
def modal_coordinate(text: str) -> int | None:
    """The mode index a modal coordinate names, or None for anything
    else. This is the one function that reads the `M<index>` spelling.

    Parameters
    ----------
    text : str
        A DOF string.

    Returns
    -------
    int or None
        The 1-based mode index, or None when the string is not a
        modal coordinate.
    """
    text = str(text).strip().upper()
    if len(text) > 1 and text[0] == MODAL_PREFIX and text[1:].isdigit():
        index = int(text[1:])
        return index if index > 0 else None
    return None

dofs

dofs(values: Sequence[str], label: str = 'DOF', allow_unknown: bool = False) -> list[str]

DOF strings, checked and normalized: a node id, then one of DIRECTIONS ('101RX+'), or a modal coordinate ('M3', see modal_coordinate).

A record finds its node on a geometry by its DOF, a shape finds its column by it, and a channel says what it measures with it. A DOF that cannot be parsed would not fail where it was written; it would show up later as a record matched to no node on the geometry. So it is refused here, with a ValueError naming the string.

An unsigned direction is taken as the positive one ('101Z' is '101Z+'), because that is how people write them by hand. The direction is upper-cased and the node id written without leading zeros, so that later comparisons are between identical strings. A single string is accepted as a list of one.

allow_unknown lets an incomplete DOF through, because a channel table is written by people and people leave fields out. A DOF string is the node and the direction concatenated, so whatever was recorded is what comes out:

node direction DOF
101 Z+ 101Z+
101 — 101
— Z+ Z+
— — ''

All four import. Refusing the file would leave the user nothing to fix, while an imported record is shown as matching no geometry and can be corrected in the channel table. A direction that is not a direction is still refused either way: 101Q+ is a typo, not an omission, and Q is not an axis.

Source code in src/visualdynamics/core/validate.py
def dofs(values: Sequence[str], label: str = 'DOF',
         allow_unknown: bool = False) -> list[str]:
    """DOF strings, checked and normalized: a node id, then one of
    `DIRECTIONS` (`'101RX+'`), or a modal coordinate (`'M3'`, see
    `modal_coordinate`).

    A record finds its node on a geometry by its DOF, a shape finds its
    column by it, and a channel says what it measures with it. A DOF
    that cannot be parsed would not fail where it was written; it would
    show up later as a record matched to no node on the geometry. So it
    is refused here, with a ValueError naming the string.

    An unsigned direction is taken as the positive one (`'101Z'` is
    `'101Z+'`), because that is how people write them by hand. The
    direction is upper-cased and the node id written without leading
    zeros, so that later comparisons are between identical strings.
    A single string is accepted as a list of one.

    `allow_unknown` lets an incomplete DOF through, because a channel
    table is written by people and people leave fields out. A DOF
    string is the node and the direction concatenated, so whatever was
    recorded is what comes out:

    | node | direction | DOF     |
    |------|-----------|---------|
    | 101  | Z+        | `101Z+` |
    | 101  | —         | `101`   |
    | —    | Z+        | `Z+`    |
    | —    | —         | `''`    |

    All four import. Refusing the file would leave the user nothing to
    fix, while an imported record is shown as matching no geometry and
    can be corrected in the channel table. A direction that is not a
    direction is still refused either way: `101Q+` is a typo, not an
    omission, and Q is not an axis.
    """
    items = [values] if isinstance(values, str) else list(values)
    normalized = _dofs_normalized(items)
    if normalized is not None:
        return normalized
    return _dofs_each(items, label, allow_unknown)