Skip to content

visualdynamics.core.validate

validate

What an object will accept, in one place.

A type annotation is a claim — it tells an editor, a type checker and the API reference what a thing is, and it does nothing at all at run time. node_id: NDArray[np.int64] will not stop a string arriving. So each claim needs an enforcement beside it, and the two belong in one place or they drift: the helper here is what makes the annotation true, and the annotation is what tells a reader the helper exists.

The failures worth catching are not the absurd ones. 'x' for a node id already fails, loudly, inside numpy. What silently succeeds is 1.7 becoming node 1 — a mode shape drawn on the wrong node three weeks later, with nothing in the file to say why.

Functions:

Name Description
ids

values as ids, or a refusal saying which one was wrong.

damping

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

frequency

Modal frequencies in Hz: zero or more.

modal_coordinate

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

dofs

DOF strings: a node id, then one of DIRECTIONS. '101RX+'.

Functions:

ids

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

values as ids, or a refusal saying which one was wrong.

Whole numbers only. 1.7 is not node 1 with a rounding error — it is a caller who has computed an id, and truncating it quietly is how that becomes somebody else's afternoon. Numeric strings are accepted: every file format in this field writes ids as text, and a reader that has to convert them before calling is a reader that will convert them wrongly.

Not negative. An id is a label, and there is no meaning to give a negative one; zero is allowed, because files in the wild do number from zero and refusing them would be refusing real data.

unique where something refers to these by id — nodes, coordinate systems and blocks. Element and traceline ids are labels nothing refers to, and one traceline id legitimately names several polylines (a UNV trace line that lifts the pen), so they are not held to it.

Source code in src/visualdynamics/core/validate.py
def ids(values: ArrayLike, label: str, unique: bool = False) -> IdArray:
    """`values` as ids, or a refusal saying which one was wrong.

    Whole numbers only. `1.7` is not node 1 with a rounding error — it
    is a caller who has computed an id, and truncating it quietly is how
    that becomes somebody else's afternoon. Numeric strings *are*
    accepted: every file format in this field writes ids as text, and a
    reader that has to convert them before calling is a reader that will
    convert them wrongly.

    **Not negative.** An id is a label, and there is no meaning to give
    a negative one; zero is allowed, because files in the wild do number
    from zero and refusing them would be refusing real data.

    `unique` where something refers to these by id — nodes, coordinate
    systems and blocks. Element and traceline ids are labels nothing
    refers to, and one traceline id legitimately names several polylines
    (a UNV trace line that lifts the pen), so they are not held to it.
    """
    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: zero or more, per mode.

No upper bound. Above 1.0 is overdamped — a real thing to measure even if it is not a mode anyone will animate — so the rule is a floor and not a range. Below zero is a fit that has gone wrong or a sign convention nobody meant, 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: zero or more, per mode.

    No upper bound. Above 1.0 is overdamped — a real thing to measure
    even if it is not a mode anyone will animate — so the rule is a
    floor and not a range. Below zero is a fit that has gone wrong or a
    sign convention nobody meant, 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: zero or more.

Exactly zero is a rigid-body mode and is load-bearing — the FRF synthesis cancels its 0/0 by testing for it — so zero is not merely allowed, it is meaningful. Negative 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: zero or more.

    Exactly zero is a rigid-body mode and is load-bearing — the FRF
    synthesis cancels its 0/0 by testing for it — so zero is not merely
    allowed, it is meaningful. Negative 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 — the one reader of 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 — the one reader of 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: a node id, then one of DIRECTIONS. '101RX+'. Or a modal coordinate, 'M3' (modal_coordinate).

A DOF is the whole vocabulary of this toolset — it is how a record finds its node on a geometry, how a shape finds its column, how a channel finds what it measures. An unparseable one does not fail where it is written; it fails as a record that quietly measures nothing, on a geometry that quietly has no such node, which is a much worse afternoon than a refusal at the door.

An unsigned direction is taken as the positive one — '101Z' is '101Z+' — because that is how people write them by hand, and the string is returned normalized so that everything downstream compares like with like.

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, where an imported record is visibly incompatible with any geometry and the channel table is right there to correct it in. What is still refused either way is a direction that is not a direction: 101Q+ is a typo, not an omission, and no amount of fixing the channel table will make Q 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: a node id, then one of `DIRECTIONS`. `'101RX+'`.
    Or a modal coordinate, `'M3'` (`modal_coordinate`).

    A DOF is the whole vocabulary of this toolset — it is how a record
    finds its node on a geometry, how a shape finds its column, how a
    channel finds what it measures. An unparseable one does not fail
    where it is written; it fails as a record that quietly measures
    nothing, on a geometry that quietly has no such node, which is a
    much worse afternoon than a refusal at the door.

    An unsigned direction is taken as the positive one — `'101Z'` is
    `'101Z+'` — because that is how people write them by hand, and the
    string is returned normalized so that everything downstream compares
    like with like.

    `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, where an imported record is visibly incompatible with any
    geometry and the channel table is right there to correct it in. What
    is still refused either way is a direction that is not a direction:
    `101Q+` is a typo, not an omission, and no amount of fixing the
    channel table will make Q an axis.
    """
    out = []
    for value in ([values] if isinstance(values, str) else values):
        text = '' if value is None else str(value).strip()
        if not text:
            if allow_unknown:
                out.append('')
                continue
            raise ValueError(f'{label} is empty')
        index = modal_coordinate(text)
        if index is not None:
            out.append(f'{MODAL_PREFIX}{index}')
            continue
        digits = 0
        while digits < len(text) and text[digits].isdigit():
            digits += 1
        node, direction = text[:digits], text[digits:].upper()
        if not digits and not allow_unknown:
            raise ValueError(
                f'{label} {value!r} does not start with a node id')
        if direction and direction not in DIRECTIONS:
            if direction + '+' in DIRECTIONS:
                direction += '+'
            else:
                raise ValueError(
                    f'{label} {value!r} names no direction: expected one of '
                    + ', '.join(DIRECTIONS))
        if not direction and not allow_unknown:
            raise ValueError(
                f'{label} {value!r} names no direction: expected one of '
                + ', '.join(DIRECTIONS))
        out.append(f'{int(node)}{direction}' if node else direction)
    return out