Skip to content

visualdynamics.project

project

The project: every object a test holds, and how they belong together.

The GUI's window keeps exactly what a Project keeps — the named objects, the object groups, which group is the Basis, the project type, the active geometry — so a project built by clicking and one built in a script are the same thing, and either saves to the same .vdyn file. Scripts get the GUI's own verbs (add, link, set_basis, rename, save) instead of hand-assembling dicts and lists.

random_vibration_report at the bottom is the whole of one of those workflows in a single call — a Rattlesnake run in, an HTML report out — and random_vibration_run is the project it does that from.

Classes:

Name Description
Selection

Objects reached by what they are instead of what they were named.

Project

Every object in one test, by name, plus the structure around them.

Functions:

Name Description
type_rank

Where an object sits in the canonical order.

describe

A few words about what an object holds — how big it is, not what

remap_links

Object groups translated through a {old name: new name} mapping.

retarget

Point an object's references at renamed objects.

random_vibration_run

A Rattlesnake random vibration run, worked up into a project.

work_up_system_id

An imported system identification, worked up in place.

system_id_run

A Rattlesnake system identification, worked up into a project.

mixed_run

A Rattlesnake random run with a sine sweep under it, worked up

sine_run

A Rattlesnake sine sweep run, worked up into a project.

sine_report

A Rattlesnake sine sweep run in, an HTML report out.

report_kind

Which one-call reports a Rattlesnake run gets: 'random', 'mixed',

run_report

A Rattlesnake run in, the report its type calls for out.

system_id_report

A Rattlesnake system identification in, an HTML report out.

random_vibration_report

A Rattlesnake random vibration run in, an HTML report out.

Classes

Selection

Selection(project: Project, names: Iterable[str] | None = None, label: str = 'project')

Objects reached by what they are instead of what they were named.

project.geometry            # anywhere in the project
project.basis.frf           # in the Basis group
project.basis.psds[0]       # when you mean to choose

A name is arbitrary — it came from whatever an importer or a user called it, and importing a second geometry renames nothing but makes 'Geometry' ambiguous. A type is not, so this is the path worth typing, and the only one an editor can complete.

The singular gives the one object of that kind, and says which ones it found when there are several. The plural is always a list, so code written against one FRF keeps working when a second arrives.

Methods:

Name Description
of_type

The names in scope holding objects of this class — and not

Attributes:

Name Type Description
names list[str]

The names in scope, in the order the tree shows them.

Source code in src/visualdynamics/project.py
def __init__(self, project: Project, names: Iterable[str] | None = None,
             label: str = 'project') -> None:
    self._project = project
    # None scopes to the whole project, and keeps doing so as
    # objects arrive: nothing here is a snapshot
    self._scope = None if names is None else list(names)
    self._label = label
Attributes
names property
names: list[str]

The names in scope, in the order the tree shows them.

Methods:
of_type
of_type(cls: type) -> list[str]

The names in scope holding objects of this class — and not of a class registered as its own kind beneath it (see _NARROWER: a transient specification is not the answer to time_history).

Source code in src/visualdynamics/project.py
def of_type(self, cls: type) -> list[str]:
    """The names in scope holding objects of this class — and not
    of a class registered as its own kind beneath it (see _NARROWER:
    a transient specification is not the answer to `time_history`)."""
    narrower = _NARROWER.get(cls, ())
    return [name for name in self.names
            if isinstance(self._project[name], cls)
            and not isinstance(self._project[name], narrower)]

Project

Project(name: str = 'Project', objects: dict[str, Any] | None = None, active_geometry: str | None = None, project_type: str | None = None, object_groups: Iterable[ObjectGroup] | None = None, provenance: dict[str, dict[str, Any]] | None = None)

Bases: dict

Every object in one test, by name, plus the structure around them.

A Project is the name-to-object mapping, so project['FRF'], list(project) and project.items() read the way the tree reads.

It is also what the desktop app holds: the window's objects, links, project_type and active_geometry are properties over one of these, and its buttons call the verbs below. A project built by clicking and one built by calling are the same object, and open in each other.

Objects are usually reached by type rather than by name — project.geometry, project.basis.frf, project.other.shapes. The singular gives the one there is and says so when several qualify; the plural is always a list.

Attributes: links: The groups objects have been declared to belong to, as {'members': [...], 'role': 'Basis' | None}. Association is explicit here, never inferred from names. project_type: What kind of test this is — 'Modal Test', 'Random Vibration', 'Shock', 'Transient' — which decides the report template and the skeleton of slots the tree shows. active_geometry: The name of the geometry data is drawn on when nothing says otherwise. name: What the project is called, which is what a saved .vdyn and a rendered report are titled.

Methods:

Name Description
grouped_names

[(group or None, [names])] in the order the tree shows them:

ordered_names

Every name, flat, in the order the tree shows them.

add

Add an object under a unique name; returns the name used.

duplicate

Copies of objects, added beside them (Copy, then Paste, in

import_file

Import a file into this project; returns the names added.

remove

Delete objects, pruning them out of every object group.

rename

Rename an object; every reference to it follows.

rename_dof

Correct a channel's coordinate on an object and on everything

link

Declare objects part of one group, merging any they are in.

unlink

Take objects out of their groups; a group of one dissolves.

relink

Move one object into the group holding target.

name_object_group

Name the object group an object belongs to, or unname it.

object_group_of

The members linked with name, or None.

role_of

'Basis', or None for an unroled or unlinked object.

placed

{role: [members]} — which objects are in each named group.

sides

{side: [object names]} for the typed skeleton: the Basis by

missing

The typed skeleton's empty slots — what the tree shows gray.

object_group_with_role

The object group carrying a role, or None.

place

Put one object into the named group, making it if need be.

set_channel_role

Give one channel a role — reference, response or monitor —

set_role

Name what a group is. The Basis is unique: taking the role

set_basis

Declare the Basis of comparisons: the group whose DOFs

geometry_for

(name, geometry) the object answers to: its group's, else

absorb_links

Take on the object groups of a project being imported.

verbs

The processing verbs that apply to an object, each with its

selection_verbs

The processing verbs a selection can act on, each with its

compute_spectra

Spectra from a time history's averages (the averaging

compute_psds

PSDs from a time history's averages (Compute PSDs).

compute_octave

A spectrum integrated onto proportional bands (Compute

compute_frfs

Frequency response functions from a time history (Compute

compute_multiple_coherence

Multiple coherence from a time history (Compute Multiple

compute_srs

Shock response spectra from a time history's shocks (Compute

detect_shocks

Find the events in a time history and mark them on it (the

filter_data

A time history through its low-pass (the filter view's

truncate_data

A time history cut to its truncation's span (the

integrate

One integration of a time history (Integrate): acceleration

differentiate

One differentiation of a time history (Differentiate):

compute_cpsds

The full cross-spectral matrix from a time history's averages

transform

Physical responses through a shape set to modal responses

expand

Modal responses back through a shape set to physical

author_specification

A specification written from a sheet (the Specification

generate_rigid_body_modes

The six rigid-body mode shapes of a geometry (Generate Rigid

merge_coincident_nodes

Make a geometry's coincident nodes one node (Merge Coincident

new_geometry

An empty geometry, to build a model in (the project's +):

set_view

Set the view a geometry opens on in 3-D, in the app, the report

add_plane

Add a meshed rectangle of plates to a geometry (Add Plane): a

tie_elements

Tie a patch of a geometry's elements rigidly to the part under

merge_groups

Merge a geometry's element groups into one (Merge Element Groups): the first

add_block

Add a meshed box of solid bricks to a geometry (Add Block):

solve_modes

The normal modes of a geometry whose element groups carry their

fit_modes

Fit a modal model to an FRF set (the fitting screen).

project_onto_basis

A shape set sampled at the Basis set's DOFs (Project onto

match_modes

Commit matched mode pairs (the comparison screen's +).

comparison_mac

The MAC between two shape sets as the comparison screen

plot_mac

The MAC picture the comparison screen draws: first

merge

Combine compatible objects into one (Merge).

export

Write an object to a foreign format, chosen by suffix —

generate_report

Build a report from a starter template, bound symbolically

work_up

Every missing object the project's type expects, computed

export_report

Write a report as one self-contained HTML file (Export).

table

(headers, rows) for an object that reads as a table.

plot

Plot an object the way the GUI plots it: data as curves, a

animate

A mode shape — or a complex spectrum's operating deflection —

name_of

The name an object goes by here; a name passes through.

extract_sine

Each specification tone's level, read out of a recording

stale

{derived name: why} for everything whose source's settings

refresh

Recompute a derived object in place, under its own name.

refresh_stale

Refresh everything stale, sources before their dependents,

save

Write the whole project to one file: .vdyn, .mat for

open

Read a project back, from .vdyn, .mat or an ESCDF .h5

journal_as

Record a stretch of front-end work as one replaying line.

record_setting

A settings write, journaled the way a script would make it.

record_call

A method call on an object, journaled as a script makes it.

session_script

This sitting's acts as a runnable Python script.

Attributes:

Name Type Description
basis Selection

The Basis group, reached by type: project.basis.frf.

object_group_selections list[Selection]

Every object group, the Basis first, each reached by type.

other Selection

The one object group that is not the Basis — the model side of

names list[str]

Every object's name, in the order the tree shows them.

Source code in src/visualdynamics/project.py
def __init__(self, name: str = 'Project',
             objects: dict[str, Any] | None = None,
             active_geometry: str | None = None,
             project_type: str | None = None,
             object_groups: Iterable[ObjectGroup] | None = None,
             provenance: dict[str, dict[str, Any]] | None = None
             ) -> None:
    super().__init__(objects or {})
    self.name: str = str(name)
    self.active_geometry: str | None = active_geometry
    self.project_type: str | None = project_type
    # [{'members': [...], 'role': 'Basis' | None}] — a loaded file
    # may still carry a 'side' key from before the two vocabularies
    # merged, or a 'FEM' role from before that role was retired;
    # both read as the Basis where they meant it and as nothing
    # otherwise, and are never stored again
    # ... and, since 2026-09-30, an optional 'name': what the group
    # is called, which an ESCDF activity needs and a tree can show
    self.object_groups: list[dict[str, Any]] = [
        {'members': list(group['members']),
         'role': 'Basis' if (group.get('role') == 'Basis'
                             or group.get('side') == 'experimental'
                             and not group.get('role')) else None,
         **({'name': str(group['name'])} if group.get('name') else {})}
        for group in (object_groups or [])]
    #: the roles the last link moved, (history, channel, was, now)
    #: — a front end says so rather than letting FRFs move silently
    self.last_role_changes: list = []
    #: how each derived object was computed — {name: {'verb',
    #: 'source', 'params', 'state'}} with 'state' the fingerprint
    #: of the analysis settings read at compute time. Staleness is
    #: the recorded state disagreeing with the source's current
    #: one; a mismatch is exactly the recompute the refresh badge
    #: offers (Brandon, 2026-08-23: explicit, never automatic —
    #: a report must not rewrite itself).
    self.provenance: dict[str, dict[str, Any]] = dict(provenance or {})
    #: this sitting's acts, each a runnable line of Python — what
    #: the console shows and `session_script` exports (Brandon,
    #: 2026-08-30). In memory only, and deliberately so: the
    #: journal is a record of *this session*, where the `.vdyn`
    #: file's provenance records tell the durable story. Guarded
    #: by `_journal_depth` so a verb calling other verbs — merge
    #: adds and links, refresh recomputes — records once, as the
    #: line the user could have typed.
    self.journal: list[str] = [
        f'project = visualdynamics.Project({str(name)!r})']
    self._journal_depth: int = 0
Attributes
basis property
basis: Selection

The Basis group, reached by type: project.basis.frf.

Empty (and falsy) when no group has been declared the Basis, so if project.basis: still asks the question it reads as. Its member names are project.basis.names.

object_group_selections property
object_group_selections: list[Selection]

Every object group, the Basis first, each reached by type.

other property
other: Selection

The one object group that is not the Basis — the model side of a correlation, usually. Says so when there are several.

names property
names: list[str]

Every object's name, in the order the tree shows them.

Methods:
grouped_names
grouped_names() -> list[tuple[ObjectGroup | None, list[str]]]

[(group or None, [names])] in the order the tree shows them: the Basis group first, then the other object groups, then what is unlinked — each in the canonical type order, and objects of one type in the order they arrived.

Source code in src/visualdynamics/project.py
def grouped_names(self) -> list[tuple[ObjectGroup | None, list[str]]]:
    """[(group or None, [names])] in the order the tree shows them:
    the Basis group first, then the other object groups, then what is
    unlinked — each in the canonical type order, and objects of one
    type in the order they arrived."""
    def ordered(names: Iterable[str]) -> list[str]:
        return sorted(names, key=lambda name: type_rank(self[name]))

    groups = sorted(self.object_groups, key=lambda g: g['role'] != 'Basis')
    out, linked = [], set()
    for group in groups:
        members = [name for name in group['members'] if name in self]
        if members:
            out.append((group, ordered(members)))
            linked |= set(members)
    loose = ordered(name for name in self if name not in linked)
    return out + ([(None, loose)] if loose else [])
ordered_names
ordered_names() -> list[str]

Every name, flat, in the order the tree shows them.

Source code in src/visualdynamics/project.py
def ordered_names(self) -> list[str]:
    """Every name, flat, in the order the tree shows them."""
    return [name for _group, members in self.grouped_names()
            for name in members]
add
add(name: str, obj: Any) -> str

Add an object under a unique name; returns the name used.

A clash is numbered rather than refused or overwritten, exactly as importing twice does in the GUI. The first geometry added becomes the active one.

Parameters:

Name Type Description Default
name str

What to call it. A clash gets a numbered suffix.

required
obj object

Any object the project can hold.

required

Returns:

Type Description
str

The name the result was added under, which is unique within the project — a clash gets a numbered suffix.

Source code in src/visualdynamics/project.py
def add(self, name: str, obj: Any) -> str:
    """Add an object under a unique name; returns the name used.

    A clash is numbered rather than refused or overwritten, exactly
    as importing twice does in the GUI. The first geometry added
    becomes the active one.

    Parameters
    ----------
    name : str
        What to call it. A clash gets a numbered suffix.
    obj : object
        Any object the project can hold.

    Returns
    -------
    str
        The name the result was added under, which is unique
        within the project — a clash gets a numbered suffix.
    """
    unique, n = str(name), 1
    while unique in self:
        n += 1
        unique = f'{name} ({n})'
    self[unique] = obj
    if self.active_geometry is None and isinstance(obj, Geometry):
        self.active_geometry = unique
    return unique
duplicate
duplicate(*names: Any) -> list[str]

Copies of objects, added beside them (Copy, then Paste, in the tree): each under its own name with ' copy', numbered when that is taken. Returns the names added.

Independent objects, not views: a copy's arrays are its own, so editing one leaves the other as it was. Links and provenance stay with the originals — a copy is a fresh object that happens to hold the same numbers, and what it is for is the user's to say.

Parameters:

Name Type Description Default
*names str or object

The objects to copy, by name or as the objects.

()

Returns:

Type Description
list of str

The names the copies were added under, in order.

Source code in src/visualdynamics/project.py
def duplicate(self, *names: Any) -> list[str]:
    """Copies of objects, added beside them (Copy, then Paste, in
    the tree): each under its own name with ' copy', numbered when
    that is taken. Returns the names added.

    Independent objects, not views: a copy's arrays are its own,
    so editing one leaves the other as it was. Links and
    provenance stay with the originals — a copy is a fresh object
    that happens to hold the same numbers, and what it is for is
    the user's to say.

    Parameters
    ----------
    *names : str or object
        The objects to copy, by name or as the objects.

    Returns
    -------
    list of str
        The names the copies were added under, in order.
    """
    import copy

    added = []
    for name in names:
        name = self.name_of(name)
        added.append(self.add(f'{name} copy', copy.deepcopy(self[name])))
    return added
import_file
import_file(path: str | PathLike, **options: Any) -> list[str]

Import a file into this project; returns the names added.

Anything visualdynamics reads: a geometry, a Rattlesnake run, or a whole saved project. A project brings its structure with it — its object groups follow the objects even when a name clash renamed them — and, into an empty project, its name, type and active geometry too. Foreign readers' keys become readable names ('Modal_frf' is an FRF), the way the tree spells them.

A file that knows what kind of test it was says so: a controller's own save records which environment drove the run, and adopting it here settles the project type in a script the same way importing one settles it in the window.

options pass through to the format's reader — an exodus file's steps='time' and nodes=[...], a geometry's length_unit='m' — so a script can declare what the window asks about in a dialog.

Parameters:

Name Type Description Default
path str or PathLike

The file to read. The importer is chosen by content and extension.

required
**options Any

Passed through to the importer.

{}

Returns:

Type Description
list of str

The names of every object added, in the order added.

Source code in src/visualdynamics/project.py
def import_file(self, path: str | os.PathLike,
                **options: Any) -> list[str]:
    """Import a file into this project; returns the names added.

    Anything visualdynamics reads: a geometry, a Rattlesnake run, or a whole
    saved project. A project brings its structure with it — its
    object groups follow the objects even when a name clash renamed
    them — and, into an empty project, its name, type and active
    geometry too. Foreign readers' keys become readable names
    ('Modal_frf' is an FRF), the way the tree spells them.

    A file that knows what kind of test it was says so: a
    controller's own save records which environment drove the run,
    and adopting it here settles the project type in a script the
    same way importing one settles it in the window.

    `options` pass through to the format's reader — an exodus
    file's `steps='time'` and `nodes=[...]`, a geometry's
    `length_unit='m'` — so a script can declare what the
    window asks about in a dialog.

    Parameters
    ----------
    path : str or os.PathLike
        The file to read. The importer is chosen by content
        and extension.
    **options
        Passed through to the importer.

    Returns
    -------
    list of str
        The names of every object added, in the order added.
    """
    from .io import import_file as read
    from .io import project_type_of

    was_empty = not self
    result = read(str(path), **options)
    self.project_type = project_type_of(str(path)) or self.project_type
    if isinstance(result, Project):
        mapping = {name: self.add(name, obj)
                   for name, obj in result.items()}
        if any(old != new for old, new in mapping.items()):
            # a clash renamed something: the references the
            # imported objects carry follow it
            for obj in result.values():
                retarget(obj, mapping)
        self.object_groups += remap_links(
            result.object_groups, mapping,
            {group['role'] for group in self.object_groups if group['role']})
        if was_empty:
            self.name = result.name or self.name
            self.project_type = result.project_type
            if result.active_geometry in mapping:
                self.active_geometry = mapping[result.active_geometry]
        return list(mapping.values())
    if isinstance(result, dict):
        # a reader's keys are for code; the name is what the object
        # *is*, and the key stays available on the result
        added = [self.add(display_name(type(obj).__name__), obj)
                 for obj in result.values()]
        # a controller's run arrives as a channel table beside the
        # data it describes: one file, so one group (Brandon,
        # 2026-09-06 — linked, and otherwise two independent
        # objects: a coordinate corrected on one is not corrected on
        # the other)
        if len(added) > 1 and any(isinstance(obj, ChannelTable)
                                  for obj in result.values()):
            self.link(*added)
        return added
    return [self.add(display_name(type(result).__name__), result)]
remove
remove(*names: str) -> None

Delete objects, pruning them out of every object group.

Parameters:

Name Type Description Default
*names str

The objects to act on, by name.

()

Returns:

Type Description
None
Source code in src/visualdynamics/project.py
def remove(self, *names: str) -> None:
    """Delete objects, pruning them out of every object group.

    Parameters
    ----------
    *names : str
        The objects to act on, by name.

    Returns
    -------
    None
    """
    for name in names:
        self.pop(name, None)
        if self.active_geometry == name:
            self.active_geometry = next(
                (n for n, obj in self.items()
                 if isinstance(obj, Geometry)), None)
    self._prune_links()
rename
rename(old: str, new: str) -> str

Rename an object; every reference to it follows.

Object groups, matched-modes sets and report block bindings all name their objects, and a rename that left any of them pointing at the old name would strand a figure or a bracket.

Parameters:

Name Type Description Default
old str

The current name.

required
new str

The name to give it.

required

Returns:

Type Description
str

The name actually used, which may carry a suffix.

Source code in src/visualdynamics/project.py
def rename(self, old: str, new: str) -> str:
    """Rename an object; every reference to it follows.

    Object groups, matched-modes sets and report block bindings all
    name their objects, and a rename that left any of them pointing
    at the old name would strand a figure or a bracket.

    Parameters
    ----------
    old : str
        The current name.
    new : str
        The name to give it.

    Returns
    -------
    str
        The name actually used, which may carry a suffix.
    """
    if old not in self:
        raise KeyError(f'no object named {old!r}')
    if new in self:
        raise ValueError(f'name {new!r} is already in use')
    if not str(new).strip():
        raise ValueError('name cannot be empty')
    # rebuilt rather than popped, so the order the tree shows holds
    items = [(new if name == old else name, obj)
             for name, obj in self.items()]
    self.clear()
    self.update(items)
    if self.active_geometry == old:
        self.active_geometry = new
    for group in self.object_groups:
        group['members'] = [new if member == old else member
                            for member in group['members']]
    for obj in self.values():
        retarget(obj, {old: new})
    if old in self.provenance:
        self.provenance[new] = self.provenance.pop(old)
    for record in self.provenance.values():
        if record.get('source') == old:
            record['source'] = new
    return new
rename_dof
rename_dof(source: Any, old: str, new: str, quantity: str | None = None) -> list[str]

Correct a channel's coordinate on an object and on everything derived from it (double-click a row or reference column of the grid and type).

The channel, not the point: a force labeled at the wrong node moves without taking the accelerometer at that node with it (Brandon, 2026-09-06 — the other is changed explicitly if it should be). The spectra computed from a time history inherited its channels, so one found mislabeled is mislabeled in every one of them; the correction follows the derivation chain rather than leaving each derived object to be fixed by hand or recomputed. An object downstream that does not carry the channel is left alone.

Parameters:

Name Type Description Default
source str or object

The object whose grid row or column was edited.

required
old str

The coordinate as it is, '101Z+'.

required
new str

The coordinate to give it, normalized the way every DOF is.

required
quantity str

Which channel at old — 'acceleration', 'force' …; every channel at the coordinate when omitted.

None

Returns:

Type Description
list of str

The names of the objects changed, the source first.

Source code in src/visualdynamics/project.py
def rename_dof(self, source: Any, old: str, new: str,
               quantity: str | None = None) -> list[str]:
    """Correct a channel's coordinate on an object and on everything
    derived from it (double-click a row or reference column of the
    grid and type).

    The channel, not the point: a force labeled at the wrong node
    moves without taking the accelerometer at that node with it
    (Brandon, 2026-09-06 — the other is changed explicitly if it
    should be). The spectra computed from a time history inherited
    its channels, so one found mislabeled is mislabeled in every
    one of them; the correction follows the derivation chain rather
    than leaving each derived object to be fixed by hand or
    recomputed. An object downstream that does not carry the
    channel is left alone.

    Parameters
    ----------
    source : str or object
        The object whose grid row or column was edited.
    old : str
        The coordinate as it is, '101Z+'.
    new : str
        The coordinate to give it, normalized the way every DOF is.
    quantity : str, optional
        Which channel at `old` — 'acceleration', 'force' …; every
        channel at the coordinate when omitted.

    Returns
    -------
    list of str
        The names of the objects changed, the source first.
    """
    name = self.name_of(source)
    obj = self[name]
    if not hasattr(obj, 'rename_dof'):
        raise TypeError(f'{name} has no coordinates to rename')
    obj.rename_dof(old, new, quantity)
    changed = [name]
    for other in list(self.provenance):
        if other not in self or not self._descends_from(other, name):
            continue
        derived = self[other]
        if not hasattr(derived, 'rename_dof'):
            continue
        try:
            if derived.rename_dof(old, new, quantity):
                changed.append(other)
        except ValueError:
            # the channel is not on this one — a modal
            # transformation carries none of the source's
            continue
    return changed
link(*names: str, role: str | None = None, name: str | None = None) -> list[str]

Declare objects part of one group, merging any they are in.

A group holds at most one geometry — its members are read against it — and a member naming nodes that geometry lacks is refused, because the link would be a claim that is not true. Returns the group's members.

Parameters:

Name Type Description Default
*names str

The objects to act on, by name.

()
role str

The role to give the group — 'Basis', or None.

None
name str

What to call the group (Brandon, 2026-09-30: an activity in the Engineering Sciences Common Data Format has a name, and a named group exports as one that keeps its identity). Left out, a group being merged into keeps the name it had.

None

Returns:

Type Description
list of str

The group's members after linking.

Source code in src/visualdynamics/project.py
def link(self, *names: str, role: str | None = None,
         name: str | None = None) -> list[str]:
    """Declare objects part of one group, merging any they are in.

    A group holds at most one geometry — its members are read
    against it — and a member naming nodes that geometry lacks is
    refused, because the link would be a claim that is not true.
    Returns the group's members.

    Parameters
    ----------
    *names : str
        The objects to act on, by name.
    role : str, optional
        The role to give the group — 'Basis', or None.
    name : str, optional
        What to call the group (Brandon, 2026-09-30: an activity in
        the Engineering Sciences Common Data Format has a name, and
        a named group exports as one that keeps its identity). Left
        out, a group being merged into keeps the name it had.

    Returns
    -------
    list of str
        The group's members after linking.
    """
    names = [str(name) for name in names]
    if len(names) < 2:
        raise ValueError('linking takes two or more objects')
    missing = [name for name in names if name not in self]
    if missing:
        raise KeyError(f'no object named {missing[0]!r}')
    # Existing members keep their places; only genuinely new names
    # append. The first version seeded `merged` with `names` and
    # prepended what each touched group already held — which moved
    # a member being re-linked to the back of its own group, and
    # the tree (which keeps arrival order within a type, read from
    # this list) showed a time history jumping down its group the
    # moment FRFs were computed from it (Brandon, 2026-08-29).
    merged, kept = [], []
    for group in self.object_groups:
        if any(name in group['members'] for name in names):
            merged += [name for name in group['members']
                       if name not in merged]
            role = role or group['role']
        else:
            kept.append(group)
    merged += [name for name in names if name not in merged]
    geometries = [name for name in merged
                  if isinstance(self.get(name), Geometry)]
    if len(geometries) > 1:
        raise ValueError('a link holds one geometry — '
                         f'{" and ".join(geometries)} cannot share')
    from .compatibility import blocks_a_link, check_object, modal_object

    # a modal member answers to a shape set in the group, whether
    # or not the group holds a geometry; everything else answers
    # to the geometry when there is one
    companions = {member: self[member] for member in merged}
    against = (self[geometries[0]], geometries[0]) if geometries else None
    for member in merged:
        if against is None and modal_object(self[member]) is None:
            continue
        issue = check_object(
            member, self[member],
            None if against is None else against[0],
            'the active geometry' if against is None else against[1],
            companions=companions)
        # a few points the geometry does not draw are a test's
        # virtual channels, not a different structure: the
        # indicator names them and the link stands
        if blocks_a_link(issue):
            raise ValueError(f'cannot link: {issue.message}')
    kept_name = name or next(
        (group.get('name') for group in self.object_groups
         if any(n in group['members'] for n in names) and group.get('name')),
        None)
    self.object_groups = kept + [{'members': merged, 'role': role,
                          **({'name': kept_name} if kept_name else {})}]
    if role is not None:
        self.set_role(merged[0], role)
    # a channel table joining time data it describes brings its
    # roles; the history is the truth from then on
    self._sync_linked_roles(merged)
    return merged
unlink(*names: str) -> None

Take objects out of their groups; a group of one dissolves.

Parameters:

Name Type Description Default
*names str

The objects to act on, by name.

()

Returns:

Type Description
None
Source code in src/visualdynamics/project.py
def unlink(self, *names: str) -> None:
    """Take objects out of their groups; a group of one dissolves.

    Parameters
    ----------
    *names : str
        The objects to act on, by name.

    Returns
    -------
    None
    """
    names = {str(name) for name in names}
    self.object_groups = [
        {'members': kept, 'role': group['role']}
        for group, kept in
        ((group, [member for member in group['members']
                  if member not in names])
         for group in self.object_groups)
        # A *named* group holds one object quite happily: the FEM
        # group of a modal test starts as a lone geometry, and
        # dissolving it is why the group could never be built up one
        # drag at a time.
        if len(kept) > 1 or (kept and group['role'])]
relink(name: Any, target: Any = None) -> list[str]

Move one object into the group holding target.

link merges the groups its arguments are in, which is right for declaring two things related and wrong for moving one thing between groups — linking a geometry to the other side would pull its whole group across with it. This takes the object out first, so only it moves; target of None just takes it out.

The group it lands in keeps its role, so dropping something into the Basis makes it part of the Basis rather than dissolving it. Returns the members of the group it ends up in, empty if none.

Parameters:

Name Type Description Default
name str or object

The object to move.

required
target str or object

An object whose group it should join. None removes it from its current group.

None

Returns:

Type Description
list of str

The group's members afterwards.

Source code in src/visualdynamics/project.py
def relink(self, name: Any, target: Any = None) -> list[str]:
    """Move one object into the group holding `target`.

    `link` *merges* the groups its arguments are in, which is right
    for declaring two things related and wrong for moving one thing
    between groups — linking a geometry to the other side would pull
    its whole group across with it. This takes the object out first,
    so only it moves; `target` of None just takes it out.

    The group it lands in keeps its role, so dropping something into
    the Basis makes it part of the Basis rather than dissolving it.
    Returns the members of the group it ends up in, empty if none.

    Parameters
    ----------
    name : str or object
        The object to move.
    target : str or object, optional
        An object whose group it should join. None removes it
        from its current group.

    Returns
    -------
    list of str
        The group's members afterwards.
    """
    name = self.name_of(name)
    if target is None:
        self.unlink(name)
        return []
    target = self.name_of(target)
    if target == name:
        raise ValueError('an object is already in its own group')
    members = self.object_group_of(target) or [target]
    if name in members:
        return list(members)
    role = self.role_of(target)
    # `link` refuses a second geometry or a member the group's
    # geometry cannot carry, and by then the object has already left
    # where it was: a refused move must leave the links untouched
    # rather than half applied.
    before = [dict(group) for group in self.object_groups]
    self.unlink(name)
    try:
        # `name` last: it joins the group it landed in, and a group
        # reads in the order its members arrived
        return self.link(*members, name, role=role)
    except (ValueError, KeyError):
        self.object_groups = before
        raise
name_object_group
name_object_group(member: str, name: str | None) -> list[str]

Name the object group an object belongs to, or unname it.

Parameters:

Name Type Description Default
member str

Any member of the group, by name.

required
name str or None

The group's new name; None removes it.

required

Returns:

Type Description
list of str

The group's members.

Source code in src/visualdynamics/project.py
def name_object_group(self, member: str, name: str | None) -> list[str]:
    """Name the object group an object belongs to, or unname it.

    Parameters
    ----------
    member : str
        Any member of the group, by name.
    name : str or None
        The group's new name; None removes it.

    Returns
    -------
    list of str
        The group's members.
    """
    for group in self.object_groups:
        if member in group['members']:
            if name:
                group['name'] = str(name)
            else:
                group.pop('name', None)
            return list(group['members'])
    raise KeyError(f'{member!r} is in no object group')
object_group_of
object_group_of(name: Any) -> list[str] | None

The members linked with name, or None.

Parameters:

Name Type Description Default
name str or object

The object to look up.

required

Returns:

Type Description
list of str, or None

The names sharing its object group, or None when it is in no group.

Source code in src/visualdynamics/project.py
def object_group_of(self, name: Any) -> list[str] | None:
    """The members linked with `name`, or None.

    Parameters
    ----------
    name : str or object
        The object to look up.

    Returns
    -------
    list of str, or None
        The names sharing its object group, or None when it is in
        no group.
    """
    group = self._group(self.name_of(name))
    return None if group is None else list(group['members'])
role_of
role_of(name: Any) -> str | None

'Basis', or None for an unroled or unlinked object.

Parameters:

Name Type Description Default
name str or object

The object to look up.

required

Returns:

Type Description
str or None

Its object group's role, or None if it has none.

Source code in src/visualdynamics/project.py
def role_of(self, name: Any) -> str | None:
    """'Basis', or None for an unroled or unlinked object.

    Parameters
    ----------
    name : str or object
        The object to look up.

    Returns
    -------
    str or None
        Its object group's role, or None if it has none.
    """
    group = self._group(self.name_of(name))
    return None if group is None else group['role']
placed
placed() -> dict[str, list[str]]

{role: [members]} — which objects are in each named group.

Nothing is guessed here. An object nobody has placed is in no named group, which is what makes the tree's gray slots mean anything: a slot is filled by an object in its own group, so a modal test whose only geometry is the model's still shows a slot for the measured one.

Source code in src/visualdynamics/project.py
def placed(self) -> dict[str, list[str]]:
    """{role: [members]} — which objects are in each *named* group.

    Nothing is guessed here. An object nobody has placed is in no
    named group, which is what makes the tree's gray slots mean
    anything: a slot is filled by an object in its own group, so a
    modal test whose only geometry is the model's still shows a
    slot for the measured one.
    """
    out: dict[str, list[str]] = {}
    for group in self.object_groups:
        if group['role']:
            out.setdefault(group['role'],
                           []).extend(group['members'])
    return out
sides
sides() -> dict[str, list[str]]

{side: [object names]} for the typed skeleton: the Basis by its role, and under OTHER_SIDE every member of every group that is not the Basis — the other side has no name, so it is read off the groups rather than declared. What the tree's gray slots and missing are computed from.

Returns:

Type Description
dict of str to list of str
Source code in src/visualdynamics/project.py
def sides(self) -> dict[str, list[str]]:
    """{side: [object names]} for the typed skeleton: the Basis by
    its role, and under `OTHER_SIDE` every member of every group
    that is not the Basis — the other side has no name, so it is
    read off the groups rather than declared. What the tree's gray
    slots and `missing` are computed from.

    Returns
    -------
    dict of str to list of str
    """
    from .core.report import OTHER_SIDE

    sides = dict(self.placed())
    others = [name for group in self.object_groups if group['role'] != 'Basis'
              for name in group['members']]
    if others:
        sides[OTHER_SIDE] = others
    return sides
missing
missing() -> list[tuple[str, type, str, int, bool, str | None]]

The typed skeleton's empty slots — what the tree shows gray.

Each is project_expectations' (label, class, icon key, ordinal, optional, side). An untyped project expects nothing. Until a Basis is declared every object counts for every slot: the window places what arrives, a script may never, and a script's project with everything in it and no groups is not one with nothing in it.

Returns:

Type Description
list of tuple
Source code in src/visualdynamics/project.py
def missing(self) -> list[tuple[str, type, str, int, bool, str | None]]:
    """The typed skeleton's empty slots — what the tree shows gray.

    Each is `project_expectations`' (label, class, icon key,
    ordinal, optional, side). An untyped project expects nothing.
    Until a Basis is declared every object counts for every slot:
    the window places what arrives, a script may never, and a
    script's project with everything in it and no groups is not
    one with nothing in it.

    Returns
    -------
    list of tuple
    """
    from .core.report import missing_expectations

    if not self.project_type:
        return []
    sides = self.sides()
    return missing_expectations(self.project_type, dict(self.items()),
                                sides if 'Basis' in sides else None)
object_group_with_role
object_group_with_role(role: str) -> ObjectGroup | None

The object group carrying a role, or None.

Parameters:

Name Type Description Default
role str

Which named group to fetch — 'Basis' is the only name.

required

Returns:

Type Description
ObjectGroup or None

That group, or None if unset.

Source code in src/visualdynamics/project.py
def object_group_with_role(self, role: str) -> ObjectGroup | None:
    """The object group carrying a role, or None.

    Parameters
    ----------
    role : str
        Which named group to fetch — 'Basis' is the only name.

    Returns
    -------
    ObjectGroup or None
        That group, or None if unset.
    """
    return next((group for group in self.object_groups
                 if group['role'] == role), None)
place
place(name: Any, role: str) -> list[str]

Put one object into the named group, making it if need be.

Unlike an ordinary link this takes a single object, because a named group is a declaration rather than an observed relation — and needing two members before the group can exist at all is what made it impossible to move a wrongly-sorted pair across one at a time. Returns the group's members.

Parameters:

Name Type Description Default
name str or object

The object being placed.

required
role str or None

'Basis', or None for the other group — the one group that is not the Basis, made if there is none yet. With several groups besides the Basis there is no one other, and this refuses: relink onto a member of the one meant.

required

Returns:

Type Description
list of str

The group's members.

Source code in src/visualdynamics/project.py
def place(self, name: Any, role: str) -> list[str]:
    """Put one object into the named group, making it if need be.

    Unlike an ordinary link this takes a single object, because a
    named group is a *declaration* rather than an observed relation
    — and needing two members before the group can exist at all is
    what made it impossible to move a wrongly-sorted pair across
    one at a time. Returns the group's members.

    Parameters
    ----------
    name : str or object
        The object being placed.
    role : str or None
        'Basis', or None for the *other* group — the one group
        that is not the Basis, made if there is none yet. With
        several groups besides the Basis there is no one other,
        and this refuses: relink onto a member of the one meant.

    Returns
    -------
    list of str
        The group's members.
    """
    name = self.name_of(name)
    if role is None:
        others = [g for g in self.object_groups if g['role'] != 'Basis'
                  and name not in g['members']]
        mine = self._group(name)
        if mine is not None and mine['role'] != 'Basis':
            return list(mine['members'])
        if len(others) > 1:
            raise ValueError(
                f'{len(others)} groups besides the Basis — drop '
                'onto a member of the one meant')
        before = [dict(existing) for existing in self.object_groups]
        self.unlink(name)
        try:
            if not others:
                self.object_groups = self.object_groups + [
                    {'members': [name], 'role': None}]
                return [name]
            return self.link(*others[0]['members'], name)
        except (ValueError, KeyError):
            self.object_groups = before
            raise
    group = self.object_group_with_role(role)
    if group is not None and name in group['members']:
        return list(group['members'])
    before = [dict(existing) for existing in self.object_groups]
    self.unlink(name)
    group = self.object_group_with_role(role)
    try:
        if group is None:
            self.object_groups = self.object_groups + [
                {'members': [name], 'role': None}]
            self.set_role(name, role)
            return [name]
        return self.link(*group['members'], name, role=role)
    except (ValueError, KeyError):
        self.object_groups = before
        raise
set_channel_role
set_channel_role(source: Any, dof: str, quantity: str, role: str) -> list[str]

Give one channel a role — reference, response or monitor — on a time history or on a channel table, and on whatever is linked with it.

The time history's role is the truth: it is what FRFs and multiple coherence read. A channel table linked with a history whose channels it describes shows the history's roles, so a change made on either lands on both (Brandon, 2026-09-25: the channel table is just a pointer to the linked time data's role). A channel table with no such history holds its own roles, as it always did.

Parameters:

Name Type Description Default
source str or object

The time history or channel table, by name or as itself.

required
dof str

The channel's degree of freedom, '101Z+'.

required
quantity str

What it measures, as a history's ordinate_dim or a table's channel type names it ('acceleration', 'force', 'length' for a displacement).

required
role str

'reference', 'response' or 'monitor'.

required

Returns:

Type Description
list of str

The objects whose roles changed.

Source code in src/visualdynamics/project.py
def set_channel_role(self, source: Any, dof: str, quantity: str,
                     role: str) -> list[str]:
    """Give one channel a role — reference, response or monitor —
    on a time history or on a channel table, and on whatever is
    linked with it.

    The time history's role is the truth: it is what FRFs and
    multiple coherence read. A channel table linked with a history
    whose channels it describes shows the history's roles, so a
    change made on either lands on both (Brandon, 2026-09-25: *the
    channel table is just a pointer to the linked time data's
    role*). A channel table with no such history holds its own
    roles, as it always did.

    Parameters
    ----------
    source : str or object
        The time history or channel table, by name or as itself.
    dof : str
        The channel's degree of freedom, '101Z+'.
    quantity : str
        What it measures, as a history's `ordinate_dim` or a
        table's channel type names it ('acceleration', 'force',
        'length' for a displacement).
    role : str
        'reference', 'response' or 'monitor'.

    Returns
    -------
    list of str
        The objects whose roles changed.
    """
    from .core.channel_table import ROLES

    if role not in ROLES:
        raise ValueError(f'{role!r} is not a role: ' + ', '.join(ROLES))
    name = self.name_of(source)
    obj = self[name]
    asked = (str(dof), str(quantity))
    if isinstance(obj, TimeHistory):
        targets = [(name, asked)]
    elif isinstance(obj, ChannelTable):
        rows = [row for row, own in enumerate(_table_identities(obj, None))
                if own == asked]
        if not rows:
            raise ValueError(f'{name} has no channel {dof} ({quantity})')
        # the table row names a channel of each linked history —
        # through its channel type, or its DOF alone when the type
        # is blank and the DOF has one channel there
        targets = [(other, mapped[row]) for other, mapped
                   in self._role_partners(name) for row in rows
                   if mapped[row] is not None]
        if not targets:
            # no time data it describes: the table's own record
            for row in rows:
                obj.set_cell('role', row, role)
            return [name]
    else:
        raise TypeError(f'{name!r} is neither time data nor a channel '
                        'table')
    for history_name, identity in targets:
        if identity not in self[history_name].channel_identities():
            raise ValueError(f'{history_name} has no channel {identity[0]} '
                             f'({identity[1]})')
    changed = []
    for history_name, identity in targets:
        history = self[history_name]
        roles = history.channel_roles()
        roles[identity] = role
        history.roles = roles
        changed.append(history_name)
        changed += self._push_roles(history_name)
    return list(dict.fromkeys(changed))
set_role
set_role(name: str, role: str | None) -> None

Name what a group is. The Basis is unique: taking the role takes it from whatever group held it.

Parameters:

Name Type Description Default
name str

The object whose group is being labeled.

required
role str or None

The role, or None to clear it.

required

Returns:

Type Description
None
Source code in src/visualdynamics/project.py
def set_role(self, name: str, role: str | None) -> None:
    """Name what a group is. The Basis is unique: taking the role
    takes it from whatever group held it.

    Parameters
    ----------
    name : str
        The object whose group is being labeled.
    role : str or None
        The role, or None to clear it.

    Returns
    -------
    None
    """
    group = self._group(name)
    if group is None:
        raise ValueError(f'{name!r} is not in an object group')
    if role is not None:
        for other in self.object_groups:
            if other is not group and other['role'] == role:
                other['role'] = None
    group['role'] = role
set_basis
set_basis(*names: Any) -> list[str]

Declare the Basis of comparisons: the group whose DOFs comparisons happen in, whose modes are the MAC rows and the frequency-error baseline. One name marks that object's group; several link them first.

Parameters:

Name Type Description Default
*names str or object

The objects that form the basis set.

()

Returns:

Type Description
list of str

The basis group's members.

Source code in src/visualdynamics/project.py
def set_basis(self, *names: Any) -> list[str]:
    """Declare the Basis of comparisons: the group whose DOFs
    comparisons happen in, whose modes are the MAC rows and the
    frequency-error baseline. One name marks that object's group;
    several link them first.

    Parameters
    ----------
    *names : str or object
        The objects that form the basis set.

    Returns
    -------
    list of str
        The basis group's members.
    """
    names = tuple(self.name_of(name) for name in names)
    if len(names) > 1:
        self.link(*names, role='Basis')
    elif names:
        self.set_role(names[0], 'Basis')
    return self.basis.names
geometry_for
geometry_for(name: Any) -> tuple[str, Geometry] | None

(name, geometry) the object answers to: its group's, else the active one. What it is drawn on, and checked against.

Parameters:

Name Type Description Default
name str or object

The object whose geometry is wanted.

required

Returns:

Type Description
tuple of (str, Geometry), or None

The geometry's name and the geometry itself, or None when the object is not linked to one.

Source code in src/visualdynamics/project.py
def geometry_for(self, name: Any) -> tuple[str, Geometry] | None:
    """(name, geometry) the object answers to: its group's, else
    the active one. What it is drawn on, and checked against.

    Parameters
    ----------
    name : str or object
        The object whose geometry is wanted.

    Returns
    -------
    tuple of (str, Geometry), or None
        The geometry's name and the geometry itself, or None when
        the object is not linked to one.
    """
    name = self.name_of(name)
    for member in self.object_group_of(name) or ():
        if isinstance(self.get(member), Geometry):
            return member, self[member]
    active = self.active_geometry
    if active is not None and active in self:
        return active, self[active]
    return None
absorb_links(groups: Iterable[ObjectGroup]) -> None

Take on the object groups of a project being imported.

The arriving objects have already been placed in named groups by the project type's rules — one at a time, as each arrived, which is a guess made without the file's own structure to go on. The file knows better, so it goes last and _prune_links lets it win.

Parameters:

Name Type Description Default
groups iterable of ObjectGroup

Object groups from another project, merged into this one's.

required

Returns:

Type Description
None
Source code in src/visualdynamics/project.py
def absorb_links(self, groups: Iterable[ObjectGroup]) -> None:
    """Take on the object groups of a project being imported.

    The arriving objects have *already* been placed in named
    groups by the project type's rules — one at a time, as each arrived,
    which is a guess made without the file's own structure to go
    on. The file knows better, so it goes last and `_prune_links`
    lets it win.

    Parameters
    ----------
    groups : iterable of ObjectGroup
        Object groups from another project, merged into this one's.

    Returns
    -------
    None
    """
    self.object_groups = list(self.object_groups) + [dict(group) for group in groups]
    self._prune_links()
verbs
verbs(source: Any = None) -> list[tuple[str, str]]

The processing verbs that apply to an object, each with its one-line reading — how a script writer discovers what can be done with what (Brandon, 2026-08-31: a flat method list says nothing about what integrate is for).

The applicability table is the same one the window's bar reads, so the two surfaces cannot disagree; the summaries are the first paragraph of each verb's own docstring, so this and the API reference cannot disagree either.

Parameters:

Name Type Description Default
source str or object

The object to ask about — a name looks it up here, an object answers for itself whether or not it has been added (what applies to a result is knowable before it is kept). Omitted, every processing verb is listed.

None

Returns:

Type Description
list of (str, str)

(verb, summary) pairs, in the order the verbs are declared. Call the verb as getattr(project, verb), or just read the list and type the name.

Source code in src/visualdynamics/project.py
def verbs(self, source: Any = None) -> list[tuple[str, str]]:
    """The processing verbs that apply to an object, each with its
    one-line reading — how a script writer discovers what can be
    done with what (Brandon, 2026-08-31: a flat method list says
    nothing about what `integrate` is *for*).

    The applicability table is the same one the window's bar
    reads, so the two surfaces cannot disagree; the summaries
    are the first paragraph of each verb's own docstring, so this
    and the API reference cannot disagree either.

    Parameters
    ----------
    source : str or object, optional
        The object to ask about — a name looks it up here, an
        object answers for itself whether or not it has been
        added (what applies to a result is knowable before it is
        kept). Omitted, every processing verb is listed.

    Returns
    -------
    list of (str, str)
        ``(verb, summary)`` pairs, in the order the verbs are
        declared. Call the verb as ``getattr(project, verb)``, or
        just read the list and type the name.
    """
    import inspect

    obj = self[source] if isinstance(source, str) else source
    out = []
    for verb, applies in _VERB_APPLIES:
        if obj is not None and not applies(self, obj):
            continue
        doc = inspect.cleandoc(getattr(type(self), verb).__doc__)
        out.append((verb, ' '.join(doc.split('\n\n', 1)[0].split())))
    return out
selection_verbs
selection_verbs(*names: Any) -> list[tuple[str, str]]

The processing verbs a selection can act on, each with its one-line reading — what the window's bar offers (Brandon, 2026-09-04: every act on the bar, none behind a menu).

One object: its own verbs, less the ones that need a partner (transform needs a shape set beside the record). Several: the partner verbs that apply to exactly that combination — a record and a shape set transform or expand, two shape sets on two geometries project, siblings of one type merge — and none of the verbs that apply to one of them alone. The same table verbs reads, so the bar and the API cannot disagree.

Parameters:

Name Type Description Default
*names str or object

The selection, by name or as the objects themselves.

()

Returns:

Type Description
list of (str, str)

(verb, summary) pairs, in the order the verbs are declared.

Source code in src/visualdynamics/project.py
def selection_verbs(self, *names: Any) -> list[tuple[str, str]]:
    """The processing verbs a *selection* can act on, each with its
    one-line reading — what the window's bar offers (Brandon,
    2026-09-04: every act on the bar, none behind a menu).

    One object: its own verbs, less the ones that need a partner
    (`transform` needs a shape set beside the record). Several: the
    partner verbs that apply to exactly that combination — a
    record and a shape set transform or expand, two shape sets on
    two geometries project, siblings of one type merge — and none
    of the verbs that apply to one of them alone. The same table
    `verbs` reads, so the bar and the API cannot disagree.

    Parameters
    ----------
    *names : str or object
        The selection, by name or as the objects themselves.

    Returns
    -------
    list of (str, str)
        ``(verb, summary)`` pairs, in the order the verbs are
        declared.
    """
    import inspect

    objects = [self[self.name_of(name)] for name in names]
    if not objects:
        return []
    if len(objects) == 1:
        return [(verb, summary) for verb, summary in self.verbs(objects[0])
                if verb not in PARTNER_VERBS]
    out = []
    for verb, applies in _SELECTION_APPLIES:
        if applies(self, objects):
            doc = inspect.cleandoc(getattr(type(self), verb).__doc__)
            out.append((verb, ' '.join(doc.split('\n\n', 1)[0].split())))
    return out
compute_spectra
compute_spectra(source: Any) -> str

Spectra from a time history's averages (the averaging view's Compute Spectra).

Parameters:

Name Type Description Default
source str or object

The object to read, by name or as the object itself; name_of resolves either.

required

Returns:

Type Description
str

The name the result was added under, which is unique within the project — a clash gets a numbered suffix.

Source code in src/visualdynamics/project.py
def compute_spectra(self, source: Any) -> str:
    """Spectra from a time history's averages (the averaging
    view's Compute Spectra).

    Parameters
    ----------
    source : str or object
        The object to read, by name or as the object itself;
        `name_of` resolves either.

    Returns
    -------
    str
        The name the result was added under, which is unique
        within the project — a clash gets a numbered suffix.
    """
    source = self.name_of(source)
    return self._derive(source, self[source].compute_spectra(),
                        f'{source} Spectra', recipe=('compute_spectra', {}))
compute_psds
compute_psds(source: Any) -> str

PSDs from a time history's averages (Compute PSDs).

Parameters:

Name Type Description Default
source str or object

The object to read, by name or as the object itself; name_of resolves either.

required

Returns:

Type Description
str

The name the result was added under, which is unique within the project — a clash gets a numbered suffix.

Source code in src/visualdynamics/project.py
def compute_psds(self, source: Any) -> str:
    """PSDs from a time history's averages (Compute PSDs).

    Parameters
    ----------
    source : str or object
        The object to read, by name or as the object itself;
        `name_of` resolves either.

    Returns
    -------
    str
        The name the result was added under, which is unique
        within the project — a clash gets a numbered suffix.
    """
    source = self.name_of(source)
    return self._derive(source, self[source].compute_psds(),
                        f'{source} PSDs', recipe=('compute_psds', {}))
compute_octave
compute_octave(source: Any, per_octave: int | None = None) -> str

A spectrum integrated onto proportional bands (Compute Octave Bands) — the same power, arranged the way it is read; a specification with its warning and abort limits banded the same way.

Parameters:

Name Type Description Default
source str or object

The object to read, by name or as the object itself; name_of resolves either.

required
per_octave int

Bands per octave. Defaults to core.octave.PER_OCTAVE.

None

Returns:

Type Description
str

The name the result was added under, which is unique within the project — a clash gets a numbered suffix.

Source code in src/visualdynamics/project.py
def compute_octave(self, source: Any, per_octave: int | None = None
                   ) -> str:
    """A spectrum integrated onto proportional bands (Compute
    Octave Bands) — the same power, arranged the way it is read;
    a specification with its warning and abort limits banded the
    same way.

    Parameters
    ----------
    source : str or object
        The object to read, by name or as the object itself;
        `name_of` resolves either.
    per_octave : int, optional
        Bands per octave. Defaults to `core.octave.PER_OCTAVE`.

    Returns
    -------
    str
        The name the result was added under, which is unique
        within the project — a clash gets a numbered suffix.
    """
    from .core.octave import PER_OCTAVE

    per_octave = PER_OCTAVE if per_octave is None else int(per_octave)
    source = self.name_of(source)
    return self._derive(
        source, self[source].to_octave(per_octave),
        f'{source} 1/{per_octave} Octave',
        recipe=('compute_octave', {'per_octave': per_octave}))
compute_frfs
compute_frfs(source: Any, method: str = 'Hv') -> str

Frequency response functions from a time history (Compute FRFs) — one per response and drive, over the frames a PSD uses.

method is 'Hv', 'H1' or 'H2': where the noise is assumed to be, which is the one thing the three estimators disagree about. The name goes on the object, since two FRF sets from one history differ in nothing else a reader can see.

The frames are detected if the history has none, the same way the coherence does it, so the two describe one measurement.

Parameters:

Name Type Description Default
source str or object

The object to read, by name or as the object itself; name_of resolves either.

required
method str

Which estimator: 'Hv', 'H1' or 'H2' — where the noise is assumed to be, which is the one thing they disagree about.

'Hv'

Returns:

Type Description
str

The name the result was added under, which is unique within the project — a clash gets a numbered suffix.

Source code in src/visualdynamics/project.py
def compute_frfs(self, source: Any, method: str = 'Hv') -> str:
    """Frequency response functions from a time history (Compute
    FRFs) — one per response and drive, over the frames a PSD uses.

    `method` is 'Hv', 'H1' or 'H2': where the noise is assumed to
    be, which is the one thing the three estimators disagree about.
    The name goes on the object, since two FRF sets from one history
    differ in nothing else a reader can see.

    The frames are detected if the history has none, the same way
    the coherence does it, so the two describe one measurement.

    Parameters
    ----------
    source : str or object
        The object to read, by name or as the object itself;
        `name_of` resolves either.
    method : str, default 'Hv'
        Which estimator: 'Hv', 'H1' or 'H2' — where the noise is
        assumed to be, which is the one thing they disagree about.

    Returns
    -------
    str
        The name the result was added under, which is unique
        within the project — a clash gets a numbered suffix.
    """
    source = self.name_of(source)
    history = self[source]
    if history.averaging is None:
        history.averaging = history.suggest_averaging()
    return self._derive(source, history.compute_frfs(method=method),
                        f'{source} {method} FRFs',
                        recipe=('compute_frfs', {'method': method}))
compute_multiple_coherence
compute_multiple_coherence(source: Any) -> str

Multiple coherence from a time history (Compute Multiple Coherence) — how much of each response the drives account for.

Averaged over frames, and the frames are detected if the history has none. Computed over the whole selection instead, the reference set fits every response exactly and the answer is 1.0 at every line — a number that says nothing, arrived at honestly.

Parameters:

Name Type Description Default
source str or object

The object to read, by name or as the object itself; name_of resolves either.

required

Returns:

Type Description
str

The name the result was added under, which is unique within the project — a clash gets a numbered suffix.

Source code in src/visualdynamics/project.py
def compute_multiple_coherence(self, source: Any) -> str:
    """Multiple coherence from a time history (Compute Multiple
    Coherence) — how much of each response the drives account for.

    Averaged over frames, and the frames are detected if the history
    has none. Computed over the whole selection instead, the
    reference set fits every response exactly and the answer is 1.0
    at every line — a number that says nothing, arrived at honestly.

    Parameters
    ----------
    source : str or object
        The object to read, by name or as the object itself;
        `name_of` resolves either.

    Returns
    -------
    str
        The name the result was added under, which is unique
        within the project — a clash gets a numbered suffix.
    """
    source = self.name_of(source)
    history = self[source]
    if history.averaging is None:
        history.averaging = history.suggest_averaging()
    return self._derive(source, history.compute_multiple_coherence(),
                        f'{source} Multiple Coherence',
                        recipe=('compute_multiple_coherence', {}))
compute_srs
compute_srs(source: Any, *, per_octave: int | None = None, q: float | None = None, kind: str = 'maximax') -> str

Shock response spectra from a time history's shocks (Compute SRS) — one curve per channel per event.

The events are the history's own — detected only when there is nothing else to say where they are, so this answers rather than asking the caller to go and find them.

Detection is the last resort and not the first. A record being read as frames already says where its events are: a transient run's playings are its averaging, and set loose on one the detector answered with thirty-one events where there were six. A target is one playing by definition and gets no detector at all — it was being cut into three.

Parameters:

Name Type Description Default
source str or object

The object to read, by name or as the object itself; name_of resolves either.

required
per_octave int

Natural-frequency lines per octave. Defaults to the module's convention (12).

None
q float

The oscillator amplification, Q = 1/(2ζ); 10 — 5% damping, the shock-test convention — when omitted.

None
kind str

Which peak each oscillator reports: 'maximax' (largest magnitude of either sign), 'positive' or 'negative'.

'maximax'

Returns:

Type Description
str

The name the result was added under, which is unique within the project — a clash gets a numbered suffix.

Source code in src/visualdynamics/project.py
def compute_srs(self, source: Any, *,
                per_octave: int | None = None,
                q: float | None = None,
                kind: str = 'maximax') -> str:
    """Shock response spectra from a time history's shocks (Compute
    SRS) — one curve per channel per event.

    The events are the history's own — detected only when there is
    nothing else to say where they are, so this answers rather than
    asking the caller to go and find them.

    Detection is the last resort and not the first. A record being
    read as frames already says where its events are: a transient
    run's playings *are* its averaging, and set loose on one the
    detector answered with thirty-one events where there were six.
    A target is one playing by definition and gets no detector at
    all — it was being cut into three.

    Parameters
    ----------
    source : str or object
        The object to read, by name or as the object itself;
        `name_of` resolves either.
    per_octave : int, optional
        Natural-frequency lines per octave. Defaults to the
        module's convention (12).
    q : float, optional
        The oscillator amplification, Q = 1/(2ζ); 10 — 5%
        damping, the shock-test convention — when omitted.
    kind : str, default 'maximax'
        Which peak each oscillator reports: 'maximax' (largest
        magnitude of either sign), 'positive' or 'negative'.

    Returns
    -------
    str
        The name the result was added under, which is unique
        within the project — a clash gets a numbered suffix.
    """
    from .core.data import TransientSpecification

    source = self.name_of(source)
    history = self[source]
    if (not history.shocks and history.averaging is None
            and not isinstance(history, TransientSpecification)):
        from .core.shocks import suggest

        history.shocks = suggest(history)
    # the analysis choices are parameters of the act, recorded in
    # the recipe like integrate's drift corner — a refresh after
    # the windows move recomputes at the same Q, spacing and kind
    return self._derive(source,
                        history.compute_srs(per_octave=per_octave,
                                            q=q, kind=kind),
                        f'{source} SRS',
                        recipe=('compute_srs',
                                {'per_octave': per_octave,
                                 'q': q, 'kind': kind}))
detect_shocks
detect_shocks(source: Any) -> int

Find the events in a time history and mark them on it (the shock view's Detect), returning how many.

The verb the API was missing (Brandon, 2026-08-25). compute_srs detects as a side effect when a record carries no windows, which served while the SRS came straight off the recording — but the recommended shock workflow filters first, and then the detection happened on the filtered record and the recording itself was left unmarked. The events belong to the recording: mark them there and every derivation carries them forward, because core.filters copies the marks onto whatever it makes.

Parameters:

Name Type Description Default
source str or object

The object to read, by name or as the object itself; name_of resolves either.

required

Returns:

Type Description
int

How many events were found and marked on the record.

Source code in src/visualdynamics/project.py
def detect_shocks(self, source: Any) -> int:
    """Find the events in a time history and mark them on it (the
    shock view's Detect), returning how many.

    The verb the API was missing (Brandon, 2026-08-25). `compute_srs`
    detects as a side effect when a record carries no windows, which
    served while the SRS came straight off the recording — but the
    recommended shock workflow filters first, and then the detection
    happened on the *filtered* record and the recording itself was
    left unmarked. The events belong to the recording: mark them
    there and every derivation carries them forward, because
    `core.filters` copies the marks onto whatever it makes.

    Parameters
    ----------
    source : str or object
        The object to read, by name or as the object itself;
        `name_of` resolves either.

    Returns
    -------
    int
        How many events were found and marked on the record.
    """
    source = self.name_of(source)
    from .core.shocks import find

    history = self[source]
    history.shocks = find(history)
    return len(history.shocks or ())
filter_data
filter_data(source: Any) -> str

A time history through its low-pass (the filter view's Apply Filter) — every channel, zero phase, so the peaks stay put.

The settings are the history's own filtering, set in the filter view; with none set, the suggestion is adopted the way compute_frfs adopts a suggested averaging, so the button works before the view has been visited.

Parameters:

Name Type Description Default
source str or object

The object to read, by name or as the object itself; name_of resolves either.

required

Returns:

Type Description
str

The name the result was added under, which is unique within the project — a clash gets a numbered suffix.

Source code in src/visualdynamics/project.py
def filter_data(self, source: Any) -> str:
    """A time history through its low-pass (the filter view's
    Apply Filter) — every channel, zero phase, so the peaks stay put.

    The settings are the history's own `filtering`, set in the
    filter view; with none set, the suggestion is adopted the way
    `compute_frfs` adopts a suggested averaging, so the button
    works before the view has been visited.

    Parameters
    ----------
    source : str or object
        The object to read, by name or as the object itself;
        `name_of` resolves either.

    Returns
    -------
    str
        The name the result was added under, which is unique
        within the project — a clash gets a numbered suffix.
    """
    source = self.name_of(source)
    history = self[source]
    if history.filtering is None:
        history.filtering = history.suggest_filtering()
    return self._derive(source, history.filter(),
                        f'{source} Filtered',
                        recipe=('filter_data', {}))
truncate_data
truncate_data(source: Any) -> str

A time history cut to its truncation's span (the truncate view's Apply Truncation) — every channel between start and stop, the clock kept.

The span is the history's own truncation, set in the truncate view. Unlike Filter Data there is no suggestion to adopt: the whole record is the only neutral span and keeping all of it is not an act, so with none set this refuses and says where to set one.

Parameters:

Name Type Description Default
source str or object

The object to read, by name or as the object itself; name_of resolves either.

required

Returns:

Type Description
str

The name the result was added under, which is unique within the project — a clash gets a numbered suffix.

Source code in src/visualdynamics/project.py
def truncate_data(self, source: Any) -> str:
    """A time history cut to its truncation's span (the
    truncate view's Apply Truncation) — every channel between start and
    stop, the clock kept.

    The span is the history's own `truncation`, set in the
    truncate view. Unlike Filter Data there is no suggestion to
    adopt: the whole record is the only neutral span and keeping
    all of it is not an act, so with none set this refuses and
    says where to set one.

    Parameters
    ----------
    source : str or object
        The object to read, by name or as the object itself;
        `name_of` resolves either.

    Returns
    -------
    str
        The name the result was added under, which is unique
        within the project — a clash gets a numbered suffix.
    """
    source = self.name_of(source)
    history = self[source]
    if history.truncation is None:
        raise ValueError('no span set: drag one in the truncate '
                         'view first')
    return self._derive(source, history.truncate(),
                        f'{source} Truncated',
                        recipe=('truncate_data', {}))
integrate
integrate(source: Any, drift_corner: Any = ...) -> str

One integration of a time history (Integrate): acceleration channels become velocity, velocity becomes displacement.

Whole record, never the shock windows — the reasons live in core.filters. The drift corner is a parameter of the act, recorded in the recipe: ... takes the default, None integrates raw.

Parameters:

Name Type Description Default
source str or object

The object to read, by name or as the object itself; name_of resolves either.

required
drift_corner float or None

High-pass corner in Hz applied after integration, to stop a sensor bias becoming a ramp. ... takes the default; None integrates raw, drift and all.

...

Returns:

Type Description
str

The name the result was added under, which is unique within the project — a clash gets a numbered suffix.

Source code in src/visualdynamics/project.py
def integrate(self, source: Any, drift_corner: Any = ...) -> str:
    """One integration of a time history (Integrate): acceleration
    channels become velocity, velocity becomes displacement.

    Whole record, never the shock windows — the reasons live in
    `core.filters`. The drift corner is a parameter of the act,
    recorded in the recipe: ``...`` takes the default, ``None``
    integrates raw.

    Parameters
    ----------
    source : str or object
        The object to read, by name or as the object itself;
        `name_of` resolves either.
    drift_corner : float or None, optional
        High-pass corner in Hz applied after integration, to stop a
        sensor bias becoming a ramp. `...` takes the default;
        `None` integrates raw, drift and all.

    Returns
    -------
    str
        The name the result was added under, which is unique
        within the project — a clash gets a numbered suffix.
    """
    from .core.filters import DRIFT_CORNER

    corner = DRIFT_CORNER if drift_corner is ... else drift_corner
    source = self.name_of(source)
    result = self[source].integrate(corner)
    return self._derive(source, result,
                        _motion_name(source, result, 'Integrated'),
                        recipe=('integrate', {'drift_corner': corner}))
differentiate
differentiate(source: Any) -> str

One differentiation of a time history (Differentiate): displacement channels become velocity, velocity becomes acceleration.

Parameters:

Name Type Description Default
source str or object

The object to read, by name or as the object itself; name_of resolves either.

required

Returns:

Type Description
str

The name the result was added under, which is unique within the project — a clash gets a numbered suffix.

Source code in src/visualdynamics/project.py
def differentiate(self, source: Any) -> str:
    """One differentiation of a time history (Differentiate):
    displacement channels become velocity, velocity becomes
    acceleration.

    Parameters
    ----------
    source : str or object
        The object to read, by name or as the object itself;
        `name_of` resolves either.

    Returns
    -------
    str
        The name the result was added under, which is unique
        within the project — a clash gets a numbered suffix.
    """
    source = self.name_of(source)
    result = self[source].differentiate()
    return self._derive(source, result,
                        _motion_name(source, result, 'Differentiated'),
                        recipe=('differentiate', {}))
compute_cpsds
compute_cpsds(source: Any) -> str

The full cross-spectral matrix from a time history's averages (Compute CPSDs) — every channel against every channel.

Parameters:

Name Type Description Default
source str or object

The object to read, by name or as the object itself; name_of resolves either.

required

Returns:

Type Description
str

The name the result was added under, which is unique within the project — a clash gets a numbered suffix.

Source code in src/visualdynamics/project.py
def compute_cpsds(self, source: Any) -> str:
    """The full cross-spectral matrix from a time history's averages
    (Compute CPSDs) — every channel against every channel.

    Parameters
    ----------
    source : str or object
        The object to read, by name or as the object itself;
        `name_of` resolves either.

    Returns
    -------
    str
        The name the result was added under, which is unique
        within the project — a clash gets a numbered suffix.
    """
    source = self.name_of(source)
    return self._derive(source, self[source].compute_cpsds(),
                        f'{source} CPSDs', recipe=('compute_cpsds', {}))
transform
transform(source: Any, shapes: Any, *, records: Sequence[int] | None = None, name: str | None = None) -> str

Physical responses through a shape set to modal responses (Transform to Modal Responses) — q = Φ⁺u for the motions, Φᵀf for the forces, one record per mode and quantity at the modal coordinates M1 … Mn.

Any set serves: the six rigid-body shapes of a geometry make this the virtual point transformation. The result stands alone in the tree — its DOFs are on no geometry — with its provenance naming both the record and the set, and a transform_report saying what was shared, dropped and left unexplained.

Parameters:

Name Type Description Default
source str or object

The time history, by name or as the object itself; name_of resolves either.

required
shapes str or object

The shape set to transform through, by name or as itself.

required
records sequence of int

Which of the record's channels to carry through — the ones picked in the tree. All of them when omitted.

None
name str

What to call the result. Defaults to the record's name followed by 'Modal Responses'.

None

Returns:

Type Description
str

The name the result was added under, which is unique within the project — a clash gets a numbered suffix.

Source code in src/visualdynamics/project.py
def transform(self, source: Any, shapes: Any, *,
              records: Sequence[int] | None = None,
              name: str | None = None) -> str:
    """Physical responses through a shape set to modal responses
    (Transform to Modal Responses) — `q = Φ⁺u` for the motions,
    `Φᵀf` for the forces, one record per mode and quantity at the
    modal coordinates `M1` … `Mn`.

    Any set serves: the six rigid-body shapes of a geometry make
    this the virtual point transformation. The result stands alone
    in the tree — its DOFs are on no geometry — with its
    provenance naming both the record and the set, and a
    `transform_report` saying what was shared, dropped and left
    unexplained.

    Parameters
    ----------
    source : str or object
        The time history, by name or as the object itself;
        `name_of` resolves either.
    shapes : str or object
        The shape set to transform through, by name or as itself.
    records : sequence of int, optional
        Which of the record's channels to carry through — the ones
        picked in the tree. All of them when omitted.
    name : str, optional
        What to call the result. Defaults to the record's name
        followed by 'Modal Responses'.

    Returns
    -------
    str
        The name the result was added under, which is unique
        within the project — a clash gets a numbered suffix.
    """
    from .core.transform import to_modal

    source, shapes = self.name_of(source), self.name_of(shapes)
    data, shape_set = self._transform_pair(source, shapes)
    picked = None if records is None else [int(i) for i in records]
    result, report = to_modal(data, shape_set, picked)
    result.transform_report = report
    params = {'shapes': shapes}
    if picked is not None:
        params['records'] = picked
    return self._derive(source, result,
                        name or f'{source} Modal Responses',
                        recipe=('transform', params))
expand
expand(source: Any, shapes: Any, *, records: Sequence[int] | None = None, name: str | None = None) -> str

Modal responses back through a shape set to physical responses (Expand to Physical Responses) — u = Φq at every DOF the set covers, linked into the set's group so the result animates on the geometry. A pick of modes expands those modes' contribution alone, and the name says which.

Parameters:

Name Type Description Default
source str or object

The modal time history, by name or as the object itself; name_of resolves either.

required
shapes str or object

The shape set it was transformed through, by name or as itself.

required
records sequence of int

Which modal records to expand — the modes picked in the tree. All of them when omitted.

None
name str

What to call the result. Defaults to the record's name with 'Modal Responses' read as 'Physical Responses', and the modes carried in brackets when they are not all.

None

Returns:

Type Description
str

The name the result was added under, which is unique within the project — a clash gets a numbered suffix.

Source code in src/visualdynamics/project.py
def expand(self, source: Any, shapes: Any, *,
           records: Sequence[int] | None = None,
           name: str | None = None) -> str:
    """Modal responses back through a shape set to physical
    responses (Expand to Physical Responses) — `u = Φq` at every
    DOF the set covers, linked into the set's group so the result
    animates on the geometry. A pick of modes expands those modes'
    contribution alone, and the name says which.

    Parameters
    ----------
    source : str or object
        The modal time history, by name or as the object itself;
        `name_of` resolves either.
    shapes : str or object
        The shape set it was transformed through, by name or as
        itself.
    records : sequence of int, optional
        Which modal records to expand — the modes picked in the
        tree. All of them when omitted.
    name : str, optional
        What to call the result. Defaults to the record's name
        with 'Modal Responses' read as 'Physical Responses', and
        the modes carried in brackets when they are not all.

    Returns
    -------
    str
        The name the result was added under, which is unique
        within the project — a clash gets a numbered suffix.
    """
    from .core.transform import carried_modes, to_physical

    source, shapes = self.name_of(source), self.name_of(shapes)
    data, shape_set = self._transform_pair(source, shapes)
    picked = None if records is None else [int(i) for i in records]
    result, report = to_physical(data, shape_set, picked)
    result.transform_report = report
    params = {'shapes': shapes}
    if picked is not None:
        params['records'] = picked
    if name is None:
        name = _physical_name(source, result)
        carried = carried_modes(report, shape_set)
        if carried:
            name += f' ({", ".join(carried)})'
    # its DOFs are the set's, so it belongs where the set is —
    # with the geometry it animates on — and not with its modal
    # source, which no geometry group can hold
    added = self._derive(source, result, name,
                         recipe=('expand', params), link=False)
    with contextlib.suppress(ValueError):
        self.link(shapes, added)
    return added
author_specification
author_specification(source: Any, draft: Any, *, name: str | None = None, replace: bool = False) -> str

A specification written from a sheet (the Specification reading's Make Specification) — autospectra at breakpoints, every cross term from a stated coherence and phase, bands in decibels — beside the object the sheet was opened on: a shape set (at its modal coordinates, ready to expand through it), a channel table (at its control channels), or a specification. With replace, the sheet is written into the specification it was opened from under its own name, so links and report slots hold — or into several at once, from a sheet that spans them. A sheet holding every channel of the specification rewrites it in the sheet's own form, its breakpoints or its lines becoming the object's; a sheet holding a picked subset merges back at the specification's own lines with the other channels untouched. A pair the sheet leaves unstated is absent from the result — nothing is assumed for it.

Parameters:

Name Type Description Default
source str, object, or list of str

The object the sheet was opened on, by name or as itself; the specifications' names when the sheet spans several.

required
draft SpecificationDraft

What the author stated (core.author). A draft with a pair unstated is refused by name.

required
name str

What to call the result. Defaults to the source's name followed by 'Specification'.

None
replace bool

Write the sheet into source — a specification, or several — in place.

False

Returns:

Type Description
str

The name the result was added under, which is unique within the project — a clash gets a numbered suffix — or the source's own name when replaced (the first of them when several).

Source code in src/visualdynamics/project.py
def author_specification(self, source: Any, draft: Any, *,
                         name: str | None = None,
                         replace: bool = False) -> str:
    """A specification written from a sheet (the Specification
    reading's Make Specification) — autospectra at breakpoints,
    every cross term from a stated coherence and phase, bands in
    decibels — beside the object the sheet was opened on: a shape
    set (at its modal coordinates, ready to expand through it), a
    channel table (at its control channels), or a specification.
    With `replace`, the sheet is written *into* the specification
    it was opened from under its own name, so links and report
    slots hold — or into several at once, from a sheet that spans
    them. A sheet holding every channel of the specification
    rewrites it in the sheet's own form, its breakpoints or its
    lines becoming the object's; a sheet holding a picked subset
    merges back at the specification's own lines with the other
    channels untouched. A pair the sheet leaves unstated is absent
    from the result — nothing is assumed for it.

    Parameters
    ----------
    source : str, object, or list of str
        The object the sheet was opened on, by name or as itself;
        the specifications' names when the sheet spans several.
    draft : SpecificationDraft
        What the author stated (`core.author`). A draft with a
        pair unstated is refused by name.
    name : str, optional
        What to call the result. Defaults to the source's name
        followed by 'Specification'.
    replace : bool, default False
        Write the sheet into `source` — a specification, or
        several — in place.

    Returns
    -------
    str
        The name the result was added under, which is unique
        within the project — a clash gets a numbered suffix — or
        the source's own name when replaced (the first of them
        when several).
    """
    from .core.author import SpecificationDraft

    if isinstance(draft, dict):
        draft = SpecificationDraft.from_dict(draft)
    if replace:
        names = ([self.name_of(s) for s in source]
                 if isinstance(source, (list, tuple))
                 else [self.name_of(source)])
        for each in names:
            if not isinstance(self[each], Specification):
                raise TypeError(f'{each!r} is not a specification to '
                                'replace')
        spanning = len(set(draft.sources)) > 1 or (
            bool(draft.sources) and len(names) > 1)
        for each in names:
            own = draft.for_source(each) if spanning else draft
            if own.sources:
                own = own._copy(sources=[])
            self[each] = _rewritten(self[each], own,
                                    f'edited as a sheet on {each}')
            self.provenance[each] = {
                'verb': 'author_specification', 'source': each,
                'params': {'draft': own.as_dict(), 'into': True},
                'state': None}
        return names[0]
    source = self.name_of(source)
    result = draft.make(f'written on {source}')
    return self._derive(source, result,
                        name or f'{source} Specification',
                        recipe=('author_specification',
                                {'draft': draft.as_dict()}))
generate_rigid_body_modes
generate_rigid_body_modes(source: Any, *, name: str | None = None) -> str

The six rigid-body mode shapes of a geometry (Generate Rigid Body Mode Shapes) — three translations and three rotations about its reference point, as a shape set in the geometry's group.

The point, and the mass and inertia that mass-normalize the set, are the geometry's own mass_properties, set in the rigid-body view. With none set the centroid is adopted, unit shapes about the middle of the model — a real answer, unlike a whole-record truncation, and the one the virtual-point transformation wants most often — and stored, so the staleness fingerprint records what was actually used.

Parameters:

Name Type Description Default
source str or object

The geometry, by name or as the object itself; name_of resolves either.

required
name str

What to call the result. Defaults to the geometry's name followed by 'Rigid Body Modes'.

None

Returns:

Type Description
str

The name the result was added under, which is unique within the project — a clash gets a numbered suffix.

Source code in src/visualdynamics/project.py
def generate_rigid_body_modes(self, source: Any, *,
                              name: str | None = None) -> str:
    """The six rigid-body mode shapes of a geometry (Generate Rigid
    Body Mode Shapes) — three translations and three rotations
    about its reference point, as a shape set in the geometry's
    group.

    The point, and the mass and inertia that mass-normalize the
    set, are the geometry's own `mass_properties`, set in the
    rigid-body view. With none set the centroid is adopted, unit
    shapes about the middle of the model — a real answer, unlike
    a whole-record truncation, and the one the virtual-point
    transformation wants most often — and stored, so the
    staleness fingerprint records what was actually used.

    Parameters
    ----------
    source : str or object
        The geometry, by name or as the object itself; `name_of`
        resolves either.
    name : str, optional
        What to call the result. Defaults to the geometry's name
        followed by 'Rigid Body Modes'.

    Returns
    -------
    str
        The name the result was added under, which is unique
        within the project — a clash gets a numbered suffix.
    """
    from .core.rigid import rigid_body_shapes

    source = self.name_of(source)
    geometry = self[source]
    if not isinstance(geometry, Geometry):
        raise TypeError(f'{source!r} is not a geometry')
    if geometry.mass_properties is None:
        geometry.mass_properties = geometry.suggest_mass_properties()
    return self._derive(
        source, rigid_body_shapes(geometry, geometry.mass_properties),
        name or f'{source} Rigid Body Modes',
        recipe=('generate_rigid_body_modes', {}))
merge_coincident_nodes
merge_coincident_nodes(source: Any, tolerance: float | None = None) -> dict

Make a geometry's coincident nodes one node (Merge Coincident Nodes): elements renamed to the lowest id at each point, the rest removed. Plates connect only where they share nodes, so this is what ties planes meshed apart at the lines where they meet.

Refused while an object linked with the geometry names a node the merge would remove — its data would point at a node that is gone; merge first, then measure or solve.

Parameters:

Name Type Description Default
source str or object

The geometry, by name or as the object itself.

required
tolerance float

How close two nodes must be to be one, in meters (as the geometry holds its coordinates). Defaults to a millionth of the geometry's size.

None

Returns:

Type Description
dict

'merged', the nodes removed, and 'into', the nodes they became.

Source code in src/visualdynamics/project.py
def merge_coincident_nodes(self, source: Any,
                           tolerance: float | None = None) -> dict:
    """Make a geometry's coincident nodes one node (Merge Coincident
    Nodes): elements renamed to the lowest id at each
    point, the rest removed. Plates connect only where they share
    nodes, so this is what ties planes meshed apart at the lines where
    they meet.

    Refused while an object linked with the geometry names a node the
    merge would remove — its data would point at a node that is gone;
    merge first, then measure or solve.

    Parameters
    ----------
    source : str or object
        The geometry, by name or as the object itself.
    tolerance : float, optional
        How close two nodes must be to be one, in meters (as the
        geometry holds its coordinates). Defaults to a millionth of
        the geometry's size.

    Returns
    -------
    dict
        'merged', the nodes removed, and 'into', the nodes they
        became.
    """
    name = self.name_of(source)
    geometry = self[name]
    if not isinstance(geometry, Geometry):
        raise TypeError(f'{name!r} is not a geometry')
    if tolerance is None:
        low, high = geometry.extent
        tolerance = 1e-6 * float(np.linalg.norm(high - low) or 1.0)
    going = geometry.coincident_nodes(tolerance)
    for other in self.object_group_of(name) or []:
        obj = self.get(other)
        dofs = [*(getattr(obj, 'response_dof', None) or []),
                *(getattr(obj, 'reference_dof', None) or []),
                *(getattr(obj, 'coordinate', None) or [])]
        if other == name or not dofs:
            continue
        named = {int(''.join(ch for ch in str(dof) if ch.isdigit()) or -1)
                 for dof in dofs}
        clash = sorted(named & set(going))
        if clash:
            raise ValueError(
                f'{other} names node {clash[0]}, which the merge would '
                'remove — merge before measuring or solving, or unlink '
                'it first')
    return geometry.merge_coincident_nodes(tolerance)
new_geometry
new_geometry(name: str = 'Geometry', *, unit: str = 'm') -> str

An empty geometry, to build a model in (the project's +): planes added with add_plane, links in the app's add mode.

Parameters:

Name Type Description Default
name str

What to call it.

'Geometry'
unit str

Its length unit. Coordinates are held in SI either way; this is the unit it remembers it was built in.

'm'

Returns:

Type Description
str

The name the result was added under, unique within the project — a clash gets a numbered suffix.

Source code in src/visualdynamics/project.py
def new_geometry(self, name: str = 'Geometry', *,
                 unit: str = 'm') -> str:
    """An empty geometry, to build a model in (the project's **+**):
    planes added with `add_plane`, links in the app's add mode.

    Parameters
    ----------
    name : str, default 'Geometry'
        What to call it.
    unit : str, default 'm'
        Its length unit. Coordinates are held in SI either way; this
        is the unit it remembers it was built in.

    Returns
    -------
    str
        The name the result was added under, unique within the
        project — a clash gets a numbered suffix.
    """
    return self.add(name, Geometry(node_id=[], node_xyz=np.empty((0, 3)),
                                   length_unit=unit))
set_view
set_view(source: Any, view: View | None = None) -> None

Set the view a geometry opens on in 3-D, in the app, the report and every exported figure.

The app's Set Default View captures the view on screen; from a script it is stated, a model built Y-up seen from the front and above, say. Everything drawn on the geometry — its shapes, an ODS, the DOF arrows — opens on it too, and Reset View returns to it. The nodes are not turned: only how the model is looked at (Brandon, 2026-09-27).

Parameters:

Name Type Description Default
source str or Geometry

The geometry, by name or as the object itself.

required
view View

Where the eye is, seen from the model's center, and which way is up; None goes back to the default isometric (from +X+Y+Z, Z up).

None

Returns:

Type Description
None

Examples:

>>> from visualdynamics import View
>>> project.set_view('BARC', View(eye=(1, 1, -1), up=(0, 1, 0)))
Source code in src/visualdynamics/project.py
def set_view(self, source: Any, view: View | None = None) -> None:
    """Set the view a geometry opens on in 3-D, in the app, the report
    and every exported figure.

    The app's Set Default View captures the view on screen; from a
    script it is stated, a model built Y-up seen from the front and
    above, say. Everything drawn on the geometry — its shapes, an
    ODS, the DOF arrows — opens on it too, and Reset View returns to
    it. The nodes are not turned: only how the model is looked at
    (Brandon, 2026-09-27).

    Parameters
    ----------
    source : str or Geometry
        The geometry, by name or as the object itself.
    view : View, optional
        Where the eye is, seen from the model's center, and which way
        is up; None goes back to the default isometric (from +X+Y+Z,
        Z up).

    Returns
    -------
    None

    Examples
    --------
    >>> from visualdynamics import View
    >>> project.set_view('BARC', View(eye=(1, 1, -1), up=(0, 1, 0)))  # doctest: +SKIP
    """
    name = self.name_of(source)
    geometry = self[name]
    if not isinstance(geometry, Geometry):
        raise TypeError(f'{name!r} is not a geometry')
    if view is not None and not isinstance(view, View):
        raise TypeError('view must be a View, or None for the default')
    geometry.view = view
add_plane
add_plane(source: Any, corner: Any, edge_a: Any, edge_b: Any, size: float, group: str = '', *, unit: str = 'm', tolerance: float | None = None) -> dict

Add a meshed rectangle of plates to a geometry (Add Plane): a corner, two perpendicular edges and an element size, each edge divided evenly into the whole number of elements nearest that size (mesh.plane). Its nodes that fall on nodes already there become them, so planes meeting along a line are tied there, and the nodes already there keep their ids (mesh.join).

Planes given the same element group name are one element group — the five walls of a box, each named 'box', are the box, given its material once in the Element Groups table.

Parameters:

Name Type Description Default
source str or object

The geometry, by name or as the object itself.

required
corner array_like

One corner, (x, y, z).

required
edge_a array_like

The two edges from that corner, as vectors; perpendicular.

required
edge_b array_like

The two edges from that corner, as vectors; perpendicular.

required
size float

The element size aimed at.

required
group str

The element group the plates go in, by name: an existing element group of that name, or a new one.

''
unit str

The unit the lengths above are in. A geometry whose units are not defined takes them as given.

'm'
tolerance float

How close a node must be to one already there to be it, in meters. Defaults to a millionth of the size of the two together.

None

Returns:

Type Description
dict

'added', the nodes added; 'shared', the plane's nodes that fell on nodes already there; 'elements', the plates added; 'groups', the element group they went into.

Source code in src/visualdynamics/project.py
def add_plane(self, source: Any, corner: Any, edge_a: Any, edge_b: Any,
              size: float, group: str = '', *, unit: str = 'm',
              tolerance: float | None = None) -> dict:
    """Add a meshed rectangle of plates to a geometry (Add Plane): a
    corner, two perpendicular edges and an element size, each edge
    divided evenly into the whole number of elements nearest that
    size (`mesh.plane`). Its nodes that fall on nodes already there
    become them, so planes meeting along a line are tied there, and
    the nodes already there keep their ids (`mesh.join`).

    Planes given the same element group name are one element group — the five walls
    of a box, each named 'box', are the box, given its material once
    in the Element Groups table.

    Parameters
    ----------
    source : str or object
        The geometry, by name or as the object itself.
    corner : array_like
        One corner, (x, y, z).
    edge_a, edge_b : array_like
        The two edges from that corner, as vectors; perpendicular.
    size : float
        The element size aimed at.
    group : str, optional
        The element group the plates go in, by name: an existing element group of
        that name, or a new one.
    unit : str, default 'm'
        The unit the lengths above are in. A geometry whose units
        are not defined takes them as given.
    tolerance : float, optional
        How close a node must be to one already there to be it, in
        meters. Defaults to a millionth of the size of the two
        together.

    Returns
    -------
    dict
        'added', the nodes added; 'shared', the plane's nodes that
        fell on nodes already there; 'elements', the plates added;
        'groups', the element group they went into.
    """
    from .core import mesh

    name = self.name_of(source)
    geometry = self[name]
    if not isinstance(geometry, Geometry):
        raise TypeError(f'{name!r} is not a geometry')
    defined = geometry.units_defined or not geometry.num_nodes
    part = mesh.plane(corner, edge_a, edge_b, size, group,
                      unit=unit if defined else None)
    return mesh.join(geometry, part, tolerance)
tie_elements
tie_elements(source: Any, elements: Any, to: Any, group: str | None = None) -> dict

Tie a patch of a geometry's elements rigidly to the part under it (Tie): each node of the patch linked by a rigid, massless link to the nearest node of to — a bolted joint, from the elements its washer covers (mesh.tie).

Parameters:

Name Type Description Default
source str or object

The geometry, by name or as the object itself.

required
elements sequence of int

The patch, by element id.

required
to str, int or sequence of int

An element group, by name or id, or a second patch, by element ids.

required
group str

The element group the links go into, made rigid if new. Defaults to the geometry's first rigid element group, or a new one named 'ties'.

None

Returns:

Type Description
dict

'links', how many were added; 'shared', patch nodes the target already holds; 'group', where the links went.

Source code in src/visualdynamics/project.py
def tie_elements(self, source: Any, elements: Any, to: Any,
                 group: str | None = None) -> dict:
    """Tie a patch of a geometry's elements rigidly to the part under
    it (Tie): each node of the patch linked by a rigid, massless link
    to the nearest node of `to` — a bolted joint, from the elements
    its washer covers (`mesh.tie`).

    Parameters
    ----------
    source : str or object
        The geometry, by name or as the object itself.
    elements : sequence of int
        The patch, by element id.
    to : str, int or sequence of int
        An element group, by name or id, or a second patch, by element ids.
    group : str, optional
        The element group the links go into, made rigid if new. Defaults to
        the geometry's first rigid element group, or a new one named 'ties'.

    Returns
    -------
    dict
        'links', how many were added; 'shared', patch nodes the
        target already holds; 'group', where the links went.
    """
    from .core import mesh

    name = self.name_of(source)
    geometry = self[name]
    if not isinstance(geometry, Geometry):
        raise TypeError(f'{name!r} is not a geometry')
    to = to if isinstance(to, (str, int)) else [int(e) for e in to]
    return mesh.tie(geometry, [int(e) for e in elements], to, group)
merge_groups
merge_groups(source: Any, groups: Any) -> dict

Merge a geometry's element groups into one (Merge Element Groups): the first keeps its id, name and properties and the others' elements move into it — refused unless they hold the same element types and carry the same material and thickness or section (Geometry.merge_refusal).

Parameters:

Name Type Description Default
source str or object

The geometry, by name or as the object itself.

required
groups sequence of int

The element groups, by id; the first is the one kept.

required

Returns:

Type Description
dict

'into', the element group kept; 'groups', how many merged into it; 'elements', how many elements moved.

Source code in src/visualdynamics/project.py
def merge_groups(self, source: Any, groups: Any) -> dict:
    """Merge a geometry's element groups into one (Merge Element Groups): the first
    keeps its id, name and properties and the others' elements move
    into it — refused unless they hold the same element types and
    carry the same material and thickness or section
    (`Geometry.merge_refusal`).

    Parameters
    ----------
    source : str or object
        The geometry, by name or as the object itself.
    groups : sequence of int
        The element groups, by id; the first is the one kept.

    Returns
    -------
    dict
        'into', the element group kept; 'groups', how many merged into it;
        'elements', how many elements moved.
    """
    name = self.name_of(source)
    geometry = self[name]
    if not isinstance(geometry, Geometry):
        raise TypeError(f'{name!r} is not a geometry')
    return geometry.merge_groups([int(b) for b in groups])
add_block
add_block(source: Any, corner: Any, edge_a: Any, edge_b: Any, edge_c: Any, size: float, group: str = '', *, unit: str = 'm', holes: Any = (), hole_group: str | None = None, tolerance: float | None = None) -> dict

Add a meshed box of solid bricks to a geometry (Add Block): a corner, three perpendicular edges and an element size, each edge divided evenly into the whole number of elements nearest that size (mesh.block), with cylindrical holes cut the way a structured mesh cuts them. Its nodes that fall on nodes already there become them, so blocks meeting over a face are tied there (mesh.join).

Blocks given the same element group name are one element group — the rails and uprights of a frame, each named 'frame', are the frame, given its material once in the Element Groups table.

Parameters:

Name Type Description Default
source str or object

The geometry, by name or as the object itself.

required
corner array_like

One corner, (x, y, z).

required
edge_a array_like

The three edges from that corner, as vectors; perpendicular.

required
edge_b array_like

The three edges from that corner, as vectors; perpendicular.

required
edge_c array_like

The three edges from that corner, as vectors; perpendicular.

required
size float

The element size aimed at.

required
group str

The element group the bricks go in, by name: an existing group of that name, or a new one.

''
unit str

The unit the lengths above are in. A geometry whose units are not defined takes them as given.

'm'
holes sequence of tuple

Holes as mesh.block takes them: (center, radius, axis) or (center, radius, axis, depth).

()
hole_group str

The element group the holes' bricks go to — an insert's — or None to leave them out.

None
tolerance float

How close a node must be to one already there to be it, in meters. Defaults to a millionth of the size of the two together.

None

Returns:

Type Description
dict

'added', the nodes added; 'shared', the box's nodes that fell on nodes already there; 'elements', the bricks added; 'groups', the element groups they went into.

Source code in src/visualdynamics/project.py
def add_block(self, source: Any, corner: Any, edge_a: Any, edge_b: Any,
              edge_c: Any, size: float, group: str = '', *,
              unit: str = 'm', holes: Any = (),
              hole_group: str | None = None,
              tolerance: float | None = None) -> dict:
    """Add a meshed box of solid bricks to a geometry (Add Block):
    a corner, three perpendicular edges and an element size, each
    edge divided evenly into the whole number of elements nearest
    that size (`mesh.block`), with cylindrical holes cut the way a
    structured mesh cuts them. Its nodes that fall on nodes already
    there become them, so blocks meeting over a face are tied there
    (`mesh.join`).

    Blocks given the same element group name are one element group —
    the rails and uprights of a frame, each named 'frame', are the
    frame, given its material once in the Element Groups table.

    Parameters
    ----------
    source : str or object
        The geometry, by name or as the object itself.
    corner : array_like
        One corner, (x, y, z).
    edge_a, edge_b, edge_c : array_like
        The three edges from that corner, as vectors; perpendicular.
    size : float
        The element size aimed at.
    group : str, optional
        The element group the bricks go in, by name: an existing
        group of that name, or a new one.
    unit : str, default 'm'
        The unit the lengths above are in. A geometry whose units
        are not defined takes them as given.
    holes : sequence of tuple, optional
        Holes as `mesh.block` takes them: (center, radius, axis) or
        (center, radius, axis, depth).
    hole_group : str, optional
        The element group the holes' bricks go to — an insert's —
        or None to leave them out.
    tolerance : float, optional
        How close a node must be to one already there to be it, in
        meters. Defaults to a millionth of the size of the two
        together.

    Returns
    -------
    dict
        'added', the nodes added; 'shared', the box's nodes that
        fell on nodes already there; 'elements', the bricks added;
        'groups', the element groups they went into.
    """
    from .core import mesh

    name = self.name_of(source)
    geometry = self[name]
    if not isinstance(geometry, Geometry):
        raise TypeError(f'{name!r} is not a geometry')
    defined = geometry.units_defined or not geometry.num_nodes
    part = mesh.block(corner, edge_a, edge_b, edge_c, size, group,
                      unit=unit if defined else None, holes=holes,
                      hole_name=hole_group)
    return mesh.join(geometry, part, tolerance)
solve_modes
solve_modes(source: Any, *, maximum_frequency: float | None = None, num_modes: int | None = None, damping: float = 0.0, name: str | None = None, progress: Any = None) -> str

The normal modes of a geometry whose element groups carry their properties (Solve Modes): the finite element model built from the element groups, solved, and the shapes added in the geometry's group.

The geometry is the model: each element group a material and a thickness or a section (fem.GroupProperties, set in the Element Groups table or on geometry.group_properties), every quad a plate, every triangle a triangle, every two-node line a beam (fem.Model.from_geometry). The solution is free-free unless the geometry says otherwise later; the six rigid-body modes come back at exactly 0 Hz with the elastic ones after them.

Parameters:

Name Type Description Default
source str or object

The geometry, by name or as the object itself.

required
maximum_frequency float

Solve for every mode up to this frequency, in Hz.

None
num_modes int

Or for this many modes, rigid ones included.

None
damping float

The fraction of critical damping every mode is given; a finite element model has none of its own.

0.0
name str

What to call the result. Defaults to the geometry's name with ' Modes' after it.

None
progress callable

Told (done, total) as the solve advances; the window's strip bar reads it, a script can print it.

None

Returns:

Type Description
str

The name the shape set was added under.

Source code in src/visualdynamics/project.py
def solve_modes(self, source: Any, *,
                maximum_frequency: float | None = None,
                num_modes: int | None = None, damping: float = 0.0,
                name: str | None = None, progress: Any = None) -> str:
    """The normal modes of a geometry whose element groups carry their
    properties (Solve Modes): the finite element model built from
    the element groups, solved, and the shapes added in the geometry's
    group.

    The geometry is the model: each element group a material and a thickness
    or a section (`fem.GroupProperties`, set in the Element Groups table or
    on `geometry.group_properties`), every quad a plate, every
    triangle a triangle, every two-node line a beam
    (`fem.Model.from_geometry`). The solution is free-free unless the
    geometry says otherwise later; the six rigid-body modes come
    back at exactly 0 Hz with the elastic ones after them.

    Parameters
    ----------
    source : str or object
        The geometry, by name or as the object itself.
    maximum_frequency : float, optional
        Solve for every mode up to this frequency, in Hz.
    num_modes : int, optional
        Or for this many modes, rigid ones included.
    damping : float, default 0.0
        The fraction of critical damping every mode is given; a
        finite element model has none of its own.
    name : str, optional
        What to call the result. Defaults to the geometry's name
        with ' Modes' after it.
    progress : callable, optional
        Told ``(done, total)`` as the solve advances; the window's
        strip bar reads it, a script can print it.

    Returns
    -------
    str
        The name the shape set was added under.
    """
    from .core.fem import Model

    source = self.name_of(source)
    geometry = self[source]
    if not isinstance(geometry, Geometry):
        raise TypeError(f'{source!r} is not a geometry')
    if not geometry.group_properties:
        raise ValueError(
            f'{source} has no element group properties: give each element group a '
            'material and a thickness or a section, in the Element Groups '
            'table or on geometry.group_properties')
    model = Model.from_geometry(geometry, name=source)
    shapes = model.eigensolution(maximum_frequency=maximum_frequency,
                                 num_modes=num_modes, damping=damping,
                                 progress=progress)
    params = {'maximum_frequency': maximum_frequency,
              'num_modes': num_modes, 'damping': damping}
    return self._derive(source, shapes, name or f'{source} Modes',
                        recipe=('solve_modes', params))
fit_modes
fit_modes(source: str, *, bounds: tuple[float, float] | None = None, limit: int = 30, name: str | None = None, at: Sequence[tuple[float, float]] | None = None, refine: int = 0) -> str

Fit a modal model to an FRF set (the fitting screen).

The screen's loop, scripted: confirm the suggestion, take the next, limit times. The session's own suggestion logic is the whole judgment — a confirmed peak is spoken for unless the shape standing there is somebody else's — so the loop adds no second opinion. It used to: a proximity guard here vetoed any suggestion within 1 Hz of a confirmed mode, which was the same ridge-trap bandaid the session has since outgrown, and it silently skipped the repeated pair's second tooth that suggest had deliberately offered. On the screen the person stops the loop; scripted, limit is that judgment, and the plate demo's own cap is the worked example of choosing it.

On the screen the equivalent of bounds is the zoom — what is on the plot is what gets searched. A script has no plot, so it says so here.

Parameters:

Name Type Description Default
source str

The FRF set to fit.

required
bounds tuple of float

(low, high) frequency limits to fit within. Defaults to the whole band.

None
limit int

The most modes to accept.

30
name str

What to call the shape set.

None
at sequence of tuple

Explicit (frequency, damping) picks — each optionally (frequency, damping, description) — confirmed in the order given — the residual is peeled sequentially, so the order is part of the fit. This is how an interactive session replays: the fitting screen journals its confirms as exactly this call. bounds and limit are ignored when picks are given.

None
refine int

Times to run the joint residue refinement after the confirms — the screen's Refine All, counted.

0

Returns:

Type Description
str

The name the result was added under, unique within the project — a clash gets a numbered suffix.

Source code in src/visualdynamics/project.py
def fit_modes(self, source: str, *, bounds: tuple[float, float] | None
              = None, limit: int = 30, name: str | None = None,
              at: Sequence[tuple[float, float]] | None = None,
              refine: int = 0) -> str:
    """Fit a modal model to an FRF set (the fitting screen).

    The screen's loop, scripted: confirm the suggestion, take the
    next, `limit` times. The session's own suggestion logic is the
    whole judgment — a confirmed peak is spoken for unless the
    shape standing there is somebody else's — so the loop adds no
    second opinion. It used to: a proximity guard here vetoed any
    suggestion within 1 Hz of a confirmed mode, which was the same
    ridge-trap bandaid the session has since outgrown, and it
    silently skipped the repeated pair's second tooth that
    `suggest` had deliberately offered. On the screen the person
    stops the loop; scripted, `limit` is that judgment, and the
    plate demo's own cap is the worked example of choosing it.

    On the screen the equivalent of `bounds` is the zoom — what is
    on the plot is what gets searched. A script has no plot, so it
    says so here.

    Parameters
    ----------
    source : str
        The FRF set to fit.
    bounds : tuple of float, optional
        (low, high) frequency limits to fit within. Defaults to
        the whole band.
    limit : int, default 30
        The most modes to accept.
    name : str, optional
        What to call the shape set.
    at : sequence of tuple, optional
        Explicit (frequency, damping) picks — each optionally
        (frequency, damping, description) — confirmed in the
        order given — the residual is peeled sequentially, so the
        order is part of the fit. This is how an interactive
        session replays: the fitting screen journals its confirms
        as exactly this call. `bounds` and `limit` are ignored
        when picks are given.
    refine : int, default 0
        Times to run the joint residue refinement after the
        confirms — the screen's Refine All, counted.

    Returns
    -------
    str
        The name the result was added under, unique within
        the project — a clash gets a numbered suffix.
    """
    from .core.modal_fit import ModalFitSession

    source = self.name_of(source)
    session = ModalFitSession(self[source])
    if at is not None:
        for pick in at:
            frequency, damping, *described = pick
            session.confirm(frequency=float(frequency),
                            damping=float(damping),
                            description=(described[0] if described
                                         else None))
    else:
        low, high = bounds or (float(self[source].abscissa[0]),
                               float(self[source].abscissa[-1]))
        session.suggest((low, high))
        for _ in range(limit):
            session.confirm()
            session.suggest((low, high))
    for _ in range(int(refine)):
        session.refine_residues()
    return self._derive(source, session.shape_set(),
                        name or f'{source} Modes')
project_onto_basis
project_onto_basis(source: str, *, onto: str | None = None, tolerance: float = 0.02, name: str | None = None) -> str

A shape set sampled at the Basis set's DOFs (Project onto Basis DOFs): nearest node within tolerance of the basis model's extent, the displacement there dotted with each basis DOF's direction. Returns the new set's name.

Parameters:

Name Type Description Default
source str

The shape set to project.

required
onto str

The basis to project onto. Defaults to the project's basis.

None
tolerance float

The residual a fit may leave.

0.02
name str

What to call the result.

None

Returns:

Type Description
str

The name the result was added under, unique within the project — a clash gets a numbered suffix.

Source code in src/visualdynamics/project.py
def project_onto_basis(self, source: str, *, onto: str | None = None,
                       tolerance: float = 0.02,
                       name: str | None = None) -> str:
    """A shape set sampled at the Basis set's DOFs (Project onto
    Basis DOFs): nearest node within `tolerance` of the basis
    model's extent, the displacement there dotted with each basis
    DOF's direction. Returns the new set's name.

    Parameters
    ----------
    source : str
        The shape set to project.
    onto : str, optional
        The basis to project onto. Defaults to the project's basis.
    tolerance : float, default 0.02
        The residual a fit may leave.
    name : str, optional
        What to call the result.

    Returns
    -------
    str
        The name the result was added under, unique within
        the project — a clash gets a numbered suffix.
    """
    from .core.correlate import project_shapes

    source = self.name_of(source)
    onto = None if onto is None else self.name_of(onto)
    basis = ((onto, self[onto]) if onto is not None
             else self._basis_shapes())
    if basis is None:
        raise ValueError('name the set to project onto, or declare a '
                         'Basis holding one')
    basis_name, basis_shapes = basis
    home = self.geometry_for(basis_name)
    theirs = self.geometry_for(source)
    if home is None or theirs is None:
        raise ValueError('both shape sets need a geometry — link one '
                         'to each side')
    projected, report = project_shapes(self[source], theirs[1],
                                       basis_shapes, home[1],
                                       tolerance=tolerance)
    # how it was made travels with it: matched and dropped counts,
    # the worst distance accepted
    projected.projection_report = report
    return self._derive(basis_name, projected,
                        name or f'{source} @ Basis DOFs')
match_modes
match_modes(first: str, second: str, *, pairs: Iterable[tuple[int, int]] | None = None, macs: Iterable[float] | None = None, threshold: float = 0.7, name: str = 'Matched Modes') -> str

Commit matched mode pairs (the comparison screen's +).

With pairs those pairs exactly; otherwise each of first's modes takes its best partner in second when the MAC clears threshold. Comparing across geometries goes through the projection first, as the screen does.

Parameters:

Name Type Description Default
first str

One shape set, by name.

required
second str

The other shape set, by name.

required
pairs iterable of tuple of int

Explicit (first, second) index pairs, overriding the automatic matching.

None
macs iterable of float

MAC values for those pairs.

None
threshold float

The lowest MAC an automatic pairing may have.

0.7
name str

What to call the result.

'Matched Modes'

Returns:

Type Description
str

The name the result was added under, unique within the project — a clash gets a numbered suffix.

Source code in src/visualdynamics/project.py
def match_modes(self, first: str, second: str, *,
                pairs: Iterable[tuple[int, int]] | None = None,
                macs: Iterable[float] | None = None,
                threshold: float = 0.7,
                name: str = 'Matched Modes') -> str:
    """Commit matched mode pairs (the comparison screen's **+**).

    With `pairs` those pairs exactly; otherwise each of `first`'s
    modes takes its best partner in `second` when the MAC clears
    `threshold`. Comparing across geometries goes through the
    projection first, as the screen does.

    Parameters
    ----------
    first : str
        One shape set, by name.
    second : str
        The other shape set, by name.
    pairs : iterable of tuple of int, optional
        Explicit (first, second) index pairs, overriding the
        automatic matching.
    macs : iterable of float, optional
        MAC values for those pairs.
    threshold : float, default 0.7
        The lowest MAC an automatic pairing may have.
    name : str, default 'Matched Modes'
        What to call the result.

    Returns
    -------
    str
        The name the result was added under, unique within
        the project — a clash gets a numbered suffix.
    """
    import numpy as np

    from .core.matches import MatchedModes

    first, second = self.name_of(first), self.name_of(second)
    if pairs is None or macs is None:
        matrix = self.comparison_mac(first, second)
        if pairs is None:
            pairs = [(row, int(np.argmax(matrix[row])))
                     for row in range(matrix.shape[0])
                     if matrix[row].max() >= threshold]
        pairs = [(int(row), int(column)) for row, column in pairs]
        # the displayed comparison's own values, which a
        # name-matched recompute would not reproduce
        macs = [float(matrix[row, column]) for row, column in pairs]
    pairs = [(int(row), int(column)) for row, column in pairs]
    macs = [float(mac) for mac in macs]
    first_home = self.geometry_for(first)
    second_home = self.geometry_for(second)
    matched = MatchedModes(
        first, second, pairs, macs,
        first_geometry=first_home[0] if first_home else None,
        second_geometry=second_home[0] if second_home else None)
    # Unlinked, unlike every other derived object. An object group is a
    # side of the comparison, and this object *is* the comparison —
    # it names a set on each side, so putting it in one of them
    # claims it belongs to the half it is measuring against the
    # other. It carries its own two geometries (`first_geometry`,
    # `second_geometry`), so it needs no group to find them.
    #
    # Linking it by hand still works, for anyone who wants it
    # bracketed with a side.
    return self.add(name, matched)
comparison_mac
comparison_mac(first: str, second: str) -> ndarray

The MAC between two shape sets as the comparison screen shows it: across geometries the second set is projected onto the first's DOFs, because matching DOF names across geometries would trust them to mean the same directions.

Parameters:

Name Type Description Default
first str

One shape set, by name.

required
second str

The other shape set, by name.

required

Returns:

Type Description
ndarray

The MAC matrix, first's shapes down the rows and second's across the columns.

Source code in src/visualdynamics/project.py
def comparison_mac(self, first: str, second: str) -> np.ndarray:
    """The MAC between two shape sets as the comparison screen
    shows it: across geometries the second set is projected onto
    the first's DOFs, because matching DOF *names* across
    geometries would trust them to mean the same directions.

    Parameters
    ----------
    first : str
        One shape set, by name.
    second : str
        The other shape set, by name.

    Returns
    -------
    numpy.ndarray
        The MAC matrix, first's shapes down the
        rows and second's across the columns.
    """
    from .core.correlate import project_shapes
    from .core.shapes import cross_mac

    first, second = self.name_of(first), self.name_of(second)
    home, theirs = self.geometry_for(first), self.geometry_for(second)
    if (home is None or theirs is None or home[1] is theirs[1]):
        return cross_mac(self[first], self[second])
    projected, _report = project_shapes(self[second], theirs[1],
                                        self[first], home[1])
    return cross_mac(self[first], projected)
plot_mac
plot_mac(first: Any, second: Any = None, **kwargs: Any) -> Any

The MAC picture the comparison screen draws: first against itself, or against second — projected across geometries exactly as comparison_mac does it, which a shape set's own plot_mac cannot, since it compares by DOF name and knows no geometry.

Parameters:

Name Type Description Default
first str or ShapeSet

One shape set, by name or as the object.

required
second str or ShapeSet

The other. Absent, the auto-MAC.

None
**kwargs Any

Passed to the drawing: path= renders to a file, bars=True is the 3-D reading (then screenshot=), theme, title, size, show.

{}

Returns:

Type Description
object

Whatever the drawing returns — a window, an image.

Source code in src/visualdynamics/project.py
def plot_mac(self, first: Any, second: Any = None,
             **kwargs: Any) -> Any:
    """The MAC picture the comparison screen draws: `first`
    against itself, or against `second` — projected across
    geometries exactly as `comparison_mac` does it, which a
    shape set's own `plot_mac` cannot, since it compares by DOF
    name and knows no geometry.

    Parameters
    ----------
    first : str or ShapeSet
        One shape set, by name or as the object.
    second : str or ShapeSet, optional
        The other. Absent, the auto-MAC.
    **kwargs
        Passed to the drawing: `path=` renders to a file,
        `bars=True` is the 3-D reading (then `screenshot=`),
        `theme`, `title`, `size`, `show`.

    Returns
    -------
    object
        Whatever the drawing returns — a window, an image.
    """
    from .plot import plot_mac_matrix

    first = self.name_of(first)
    if second is None:
        return plot_mac_matrix(self[first].frequency,
                               self[first].auto_mac(), **kwargs)
    second = self.name_of(second)
    return plot_mac_matrix(self[first].frequency,
                           self.comparison_mac(first, second),
                           column_frequencies=self[second].frequency,
                           **kwargs)
merge
merge(*names: str, name: str | None = None) -> str

Combine compatible objects into one (Merge).

Same concrete type only, and each kind has its own rule about what may join: geometries need disjoint node ids, shape sets the same DOF cover, data arrays an identical abscissa. The merged object replaces its parts.

Parameters:

Name Type Description Default
*names str

The objects to act on, by name.

()
name str

What to call the merged object.

None

Returns:

Type Description
str

The name the result was added under, unique within the project — a clash gets a numbered suffix.

Source code in src/visualdynamics/project.py
def merge(self, *names: str, name: str | None = None) -> str:
    """Combine compatible objects into one (Merge).

    Same concrete type only, and each kind has its own rule about
    what may join: geometries need disjoint node ids, shape sets
    the same DOF cover, data arrays an identical abscissa. The
    merged object replaces its parts.

    Parameters
    ----------
    *names : str
        The objects to act on, by name.
    name : str, optional
        What to call the merged object.

    Returns
    -------
    str
        The name the result was added under, unique within
        the project — a clash gets a numbered suffix.
    """
    from .core.merge import merge as merge_objects

    names = tuple(self.name_of(n) for n in names)
    merged = merge_objects([self[n] for n in names])
    group = self.object_group_of(names[0]) or []
    self.remove(*names)
    added = self.add(name or names[0], merged)
    rest = [member for member in group if member in self]
    if rest:
        self.link(added, *rest)
    return added
export
export(name: str, path: str | PathLike, unit_system: Any = None, **kwargs: Any) -> str

Write an object to a foreign format, chosen by suffix — every registered writer, .unv, .exo, .npz, .bdf, .afu/.ati/.ash, .xlsx, .3mf, .stl, .vdreport and the rest of io.exporters() (Export).

Parameters:

Name Type Description Default
name str

The object to write.

required
path str or PathLike

Where to write it. The format follows the extension.

required
unit_system UnitSystem

Units to write in. Defaults to the project's own.

None
**kwargs Any

Passed through to the exporter.

{}

Returns:

Type Description
str

The path written.

Source code in src/visualdynamics/project.py
def export(self, name: str, path: str | os.PathLike,
           unit_system: Any = None, **kwargs: Any) -> str:
    """Write an object to a foreign format, chosen by suffix —
    every registered writer, `.unv`, `.exo`, `.npz`, `.bdf`,
    `.afu`/`.ati`/`.ash`, `.xlsx`, `.3mf`, `.stl`, `.vdreport` and
    the rest of `io.exporters()` (Export).

    Parameters
    ----------
    name : str
        The object to write.
    path : str or os.PathLike
        Where to write it. The format follows the extension.
    unit_system : UnitSystem, optional
        Units to write in. Defaults to the project's own.
    **kwargs
        Passed through to the exporter.

    Returns
    -------
    str
        The path written.
    """
    from .io import export_file

    name = self.name_of(name)
    export_file(self[name], str(path), unit_system=unit_system,
                **kwargs)
    return str(path)
generate_report
generate_report(template: str = 'modal', name: str = 'Report', marking: str | None = None, marking_color: str | None = None) -> str

Build a report from a starter template, bound symbolically to this project's structure (Generate Report).

Parameters:

Name Type Description Default
template str

Which starter to build: 'modal', 'random', 'shock', 'transient', 'sine', 'sysid' or 'empty' (a random-and-sine project makes 'random' and 'sine', one report each) — or a saved template, by the name it was saved under in the templates folder or by the path of a .vdreport file (a report exported on its own; io.report_template).

'modal'
name str

What to call the report object.

'Report'
marking str

The banner across the top and bottom of every page — 'UNCLASSIFIED' unless said. A batch stamps its own (Brandon, 2026-10-02).

None
marking_color str

'ink' or 'red'.

None

Returns:

Type Description
str

The name the result was added under, which is unique within the project — a clash gets a numbered suffix.

Source code in src/visualdynamics/project.py
def generate_report(self, template: str = 'modal',
                    name: str = 'Report', marking: str | None = None,
                    marking_color: str | None = None) -> str:
    """Build a report from a starter template, bound symbolically
    to this project's structure (Generate Report).

    Parameters
    ----------
    template : str, default 'modal'
        Which starter to build: 'modal', 'random', 'shock',
        'transient', 'sine', 'sysid' or 'empty' (a random-and-sine
        project makes 'random' and 'sine', one report each) — or a saved
        template, by the name it was saved under in the templates
        folder or by the path of a `.vdreport` file (a report
        exported on its own; `io.report_template`).
    name : str, default 'Report'
        What to call the report object.
    marking : str, optional
        The banner across the top and bottom of every page —
        'UNCLASSIFIED' unless said. A batch stamps its own
        (Brandon, 2026-10-02).
    marking_color : str, optional
        'ink' or 'red'.

    Returns
    -------
    str
        The name the result was added under, which is unique
        within the project — a clash gets a numbered suffix.
    """
    from .core.report import (
        TEMPLATE_BUILDERS,
        Report,
    )

    builders = TEMPLATE_BUILDERS
    if template in builders:
        report = builders[template](self, object_groups=self.object_groups)
    elif template == 'empty':
        report = Report('Report')
    else:
        import pathlib

        from .io import report_template

        saved = dict(report_template.saved_templates())
        path = pathlib.Path(saved.get(str(template), str(template)))
        if not (path.name.endswith(report_template.SUFFIX)
                and path.is_file()):
            raise ValueError(
                f'unknown template {template!r}: not a built-in '
                f'({", ".join([*builders, "empty"])}), a saved '
                f'template ({", ".join(saved) or "none saved"}) or a '
                f'{report_template.SUFFIX} file')
        report = report_template.load(path)
    if marking is not None:
        report.marking = str(marking)
    if marking_color is not None:
        report.marking_color = marking_color
    return self.add(name, report)
work_up
work_up() -> list[str]

Every missing object the project's type expects, computed from what is loaded, and the report last (the tree bar's Automatic; Brandon, 2026-09-30).

The skeleton's empty slots in their own order, each made from the objects already there the way the workflow guide makes it — PSDs from the time data, the octave bands from the PSDs and the specification, the coherence from the time data; the FRFs and a fitted shape set for a modal test; the levels for a sine sweep; the filtered record, its SRS and the motion chain for a shock; the two streams' PSDs on shared frames and the H1 plant for a system identification — and the typed report once they are in. A slot the loaded data cannot fill (a geometry, the photographs, a specification) is left gray; a computation the data refuses (no drive channel for a coherence) is skipped, and the rest still happen. Nothing already present is remade: pressed twice, the second press adds nothing.

One journal line, project.work_up(), the way merge is one line: the verbs it calls are the ones the guide teaches, and a script wanting them one at a time calls them.

Returns:

Type Description
list of str

The names added, in the order made; empty when the skeleton was already full.

Raises:

Type Description
ValueError

When the project has no type — there is no skeleton to fill.

Source code in src/visualdynamics/project.py
def work_up(self) -> list[str]:
    """Every missing object the project's type expects, computed
    from what is loaded, and the report last (the tree bar's
    **Automatic**; Brandon, 2026-09-30).

    The skeleton's empty slots in their own order, each made from
    the objects already there the way the workflow guide makes it
    — PSDs from the time data, the octave bands from the PSDs and
    the specification, the coherence from the time data; the FRFs
    and a fitted shape set for a modal test; the levels for a sine
    sweep; the filtered record, its SRS and the motion chain for a
    shock; the two streams' PSDs on shared frames and the H1 plant
    for a system identification — and the typed report once they
    are in. A slot the loaded data cannot fill (a geometry, the
    photographs, a specification) is left gray; a computation the
    data refuses (no drive channel for a coherence) is skipped, and
    the rest still happen. Nothing already present is remade:
    pressed twice, the second press adds nothing.

    One journal line, `project.work_up()`, the way `merge` is one
    line: the verbs it calls are the ones the guide teaches, and a
    script wanting them one at a time calls them.

    Returns
    -------
    list of str
        The names added, in the order made; empty when the
        skeleton was already full.

    Raises
    ------
    ValueError
        When the project has no type — there is no skeleton to
        fill.
    """
    from .core.data import Psd, Specification, TransientSpecification
    from .core.report import (
        PROJECT_TEMPLATES,
        TEMPLATE_BUILDERS,
        is_banded,
        project_expectations,
    )
    from .core.shapes import ShapeSet

    project_type = self.project_type
    if not project_type:
        raise ValueError('the project has no type, so there is nothing '
                         'to work up — Set Project Type says what a '
                         'finished project holds')
    added: list[str] = []

    def make(verb, *args, **kwargs):
        names = verb(*args, **kwargs)
        names = names if isinstance(names, list) else [names]
        added.extend(names)
        return names

    def on(side, cls):
        sides = self.sides()
        pool = sides.get(side, []) if 'Basis' in sides else self.names
        return [name for name in pool if isinstance(self[name], cls)]

    def source_of(name):
        return self.provenance.get(name, {}).get('source')

    def verb_of(name):
        return self.provenance.get(name, {}).get('verb')

    def derived(name, verb):
        return next((other for other in self.names
                     if verb_of(other) == verb
                     and source_of(other) == name), None)

    def recorded(side):
        # the imported record, ahead of anything derived from one
        histories = on(side, TimeHistory)
        histories = [name for name in histories
                     if not isinstance(self[name], TransientSpecification)]
        return next((name for name in histories
                     if verb_of(name) is None), None) or (
            histories[0] if histories else None)

    def filtered(side):
        record = recorded(side)
        if record is None:
            return None
        done = derived(record, 'filter_data')
        return done or make(self.filter_data, record)[0]

    for slot in project_expectations(project_type):
        if slot not in self.missing():
            continue
        _label, _cls, icon, ordinal, _optional, side = slot
        side = side or 'Basis'
        try:
            if icon == 'Psd':
                if project_type == 'System ID':
                    noise, driven = _quiet_and_driven(self)
                    # the excitation's frames are the detected ones,
                    # as `compute_frfs` adopts them, and the ambient
                    # borrows them on purpose: the ratio the report
                    # reads is only defined on lines both hold
                    if self[driven].averaging is None:
                        self[driven].averaging = \
                            self[driven].suggest_averaging()
                    if derived(driven, 'compute_psds') is None:
                        make(self.compute_psds, driven)
                    if noise and derived(noise, 'compute_psds') is None:
                        self[noise].averaging = self[driven].averaging
                        make(self.compute_psds, noise)
                    continue
                record = recorded(side)
                if record is not None:
                    make(self.compute_psds, record)
            elif icon == 'Specification':
                # a transient's target is a recording too, and its
                # spectrum is the requirement the run is read against
                target = next((name for name in on(side, TransientSpecification)
                               if derived(name, 'compute_psds') is None),
                              None)
                if target is not None:
                    make(self.compute_psds, target)
            elif icon == 'OctavePsd':
                plain = [name for name in on(side, Psd)
                         if not isinstance(self[name], Specification)
                         and not is_banded(self[name])]
                if plain:
                    make(self.compute_octave, plain[0])
            elif icon == 'OctaveSpecification':
                plain = [name for name in on(side, Specification)
                         if not is_banded(self[name])]
                if plain:
                    make(self.compute_octave, plain[0])
            elif icon == 'MultipleCoherence':
                record = (_quiet_and_driven(self)[1]
                          if project_type == 'System ID'
                          else recorded(side))
                if record is not None:
                    make(self.compute_multiple_coherence, record)
            elif icon == 'Frf':
                record = (_quiet_and_driven(self)[1]
                          if project_type == 'System ID'
                          else recorded(side))
                if record is not None:
                    # H1 for a system identification: the excitation
                    # is known and what noise there is sits on the
                    # response
                    make(self.compute_frfs, record,
                         *(['H1'] if project_type == 'System ID' else []))
            elif icon == 'ShapeSet' and side == 'Basis':
                frfs = on(side, Frf)
                if frfs:
                    make(self.fit_modes, frfs[0])
            elif icon == 'MatchedModes':
                from .core.report import OTHER_SIDE
                ours, theirs = on('Basis', ShapeSet), on(OTHER_SIDE, ShapeSet)
                if ours and theirs and ours[0] != theirs[0]:
                    make(self.match_modes, ours[0], theirs[0])
            elif icon == 'SineLevelSet':
                record = recorded(side)
                if record is not None:
                    make(self.extract_sine, record)
            elif icon == 'Srs':
                # the recommended order: the SRS is read from the
                # filtered record, so the filtering comes first even
                # though its slot is listed after
                record = filtered(side) if project_type == 'Shock' \
                    else recorded(side)
                if record is not None:
                    make(self.compute_srs, record)
            elif icon == 'TimeHistory' and ordinal > 1:
                # the shock's motion chain: filtered, then velocity,
                # then displacement, each from the one before
                chain = filtered(side)
                for _step in range(ordinal - 2):
                    if chain is None:
                        break
                    chain = derived(chain, 'integrate') or \
                        make(self.integrate, chain)[0]
            elif icon == 'Report':
                # the type's reports in order, each made once: chosen
                # by which are already here (by title), not by the
                # slot's number, so a random-and-sine project holding
                # its sine report gets the random one, not a second
                # sine (2026-10-05, two reports for that type)
                made = {self[name].title for name in self.names
                        if isinstance(self[name], Report)}
                wanted = next(
                    (template for template
                     in PROJECT_TEMPLATES[project_type]
                     if TEMPLATE_BUILDERS[template]({}, object_groups=[]).title
                     not in made), None)
                if wanted is not None:
                    make(self.generate_report, wanted)
        except ValueError:
            # what the data refuses (a coherence with no drive
            # channel) stays gray; the slots after it still fill
            continue
    return added
export_report
export_report(name: str, path: str | PathLike, unit_system: Any = None) -> str

Write a report as one self-contained HTML file (Export).

Parameters:

Name Type Description Default
name str

The report object to render.

required
path str or PathLike

Where to write the self-contained HTML file.

required
unit_system UnitSystem

Units to render in. Defaults to the project's own.

None

Returns:

Type Description
str

The path written.

Source code in src/visualdynamics/project.py
def export_report(self, name: str, path: str | os.PathLike,
                  unit_system: Any = None) -> str:
    """Write a report as one self-contained HTML file (Export).

    Parameters
    ----------
    name : str
        The report object to render.
    path : str or os.PathLike
        Where to write the self-contained HTML file.
    unit_system : UnitSystem, optional
        Units to render in. Defaults to the project's own.

    Returns
    -------
    str
        The path written.
    """
    from .report import render_html

    name = self.name_of(name)
    html = render_html(self[name], self, unit_system, object_groups=self.object_groups)
    path = os.path.expanduser(str(path))
    with open(path, 'w', encoding='utf-8') as out:
        out.write(html)
    return path
table
table(name: Any) -> tuple[list[str], list[list[str]]]

(headers, rows) for an object that reads as a table.

The instrumentation of a channel table, the identified parameters of a shape set — the same rows the report prints, so a script and a report cannot disagree about what is in one.

Parameters:

Name Type Description Default
name str or object

The object to tabulate.

required

Returns:

Type Description
tuple of (list of str, list of list of str)

The column headings and the rows, both as text.

Source code in src/visualdynamics/project.py
def table(self, name: Any) -> tuple[list[str], list[list[str]]]:
    """(headers, rows) for an object that reads as a table.

    The instrumentation of a channel table, the identified
    parameters of a shape set — the same rows the report prints,
    so a script and a report cannot disagree about what is in one.

    Parameters
    ----------
    name : str or object
        The object to tabulate.

    Returns
    -------
    tuple of (list of str, list of list of str)
        The column headings and the rows, both as text.
    """
    from .core.tables import table_of

    name = self.name_of(name)
    built = table_of(self[name])
    if built is None:
        raise ValueError(f'{name} is a {type(self[name]).__name__}, '
                         'which does not read as a table')
    return built
plot
plot(name: str, **kwargs: Any) -> Any

Plot an object the way the GUI plots it: data as curves, a geometry as its scene, a shape set as its auto-MAC.

Parameters:

Name Type Description Default
name str

The object to draw.

required
**kwargs Any

Passed through to the object's own plot method.

{}

Returns:

Type Description
object

Whatever the underlying plot call returns.

Source code in src/visualdynamics/project.py
def plot(self, name: str, **kwargs: Any) -> Any:
    """Plot an object the way the GUI plots it: data as curves, a
    geometry as its scene, a shape set as its auto-MAC.

    Parameters
    ----------
    name : str
        The object to draw.
    **kwargs
        Passed through to the object's own plot method.

    Returns
    -------
    object
        Whatever the underlying plot call returns.
    """
    obj = self[self.name_of(name)]
    plot = getattr(obj, 'plot', None)
    if plot is None:
        raise TypeError(f'{name!r} is a {type(obj).__name__}, which '
                        'has no plot')
    return plot(**kwargs)
animate
animate(name: str, mode: int = 0, **kwargs: Any) -> Any

A mode shape — or a complex spectrum's operating deflection — moving on the geometry it answers to.

Parameters:

Name Type Description Default
name str

The shape set to animate.

required
mode int

Which mode, by index.

0
**kwargs Any

Passed through to the scene.

{}

Returns:

Type Description
object

The plotter the animation is running in.

Source code in src/visualdynamics/project.py
def animate(self, name: str, mode: int = 0, **kwargs: Any) -> Any:
    """A mode shape — or a complex spectrum's operating deflection —
    moving on the geometry it answers to.

    Parameters
    ----------
    name : str
        The shape set to animate.
    mode : int, default 0
        Which mode, by index.
    **kwargs
        Passed through to the scene.

    Returns
    -------
    object
        The plotter the animation is running in.
    """
    name = self.name_of(name)
    home = self.geometry_for(name)
    if home is None:
        raise ValueError(f'{name!r} has no geometry — link one')
    target = self[name]
    if isinstance(target, ShapeSet):
        return target.animate(home[1], mode, **kwargs)
    # spectra pick a line by `frequency=`, not a mode number, and a
    # positional 0 must not read as 0 Hz
    return target.animate(home[1], **kwargs)
name_of
name_of(target: Any) -> str

The name an object goes by here; a name passes through.

Verbs take either, so project.compute_psds('Time History') and project.compute_psds(project.basis.time_history) are the same call.

Parameters:

Name Type Description Default
target str or object

A name, or an object the project holds.

required

Returns:

Type Description
str

The name it is stored under.

Source code in src/visualdynamics/project.py
def name_of(self, target: Any) -> str:
    """The name an object goes by here; a name passes through.

    Verbs take either, so `project.compute_psds('Time History')`
    and `project.compute_psds(project.basis.time_history)` are the
    same call.

    Parameters
    ----------
    target : str or object
        A name, or an object the project holds.

    Returns
    -------
    str
        The name it is stored under.
    """
    if isinstance(target, str):
        return target
    for name, obj in self.items():
        if obj is target:
            return name
    raise ValueError(f'that {type(target).__name__} is not in this '
                     f'project — add it first')
extract_sine
extract_sine(source: Any, specification: Any = None, progress: Any = None) -> list[str]

Each specification tone's level, read out of a recording (Extract Sine Levels) — one object per tone, because each tone sweeps its own frequencies on its own clock.

The specification is found in the project when not named — the one SineSweepSpecification there is — and each result is linked to the recording it was read from.

Parameters:

Name Type Description Default
source str or object

The sine level set or run to read.

required
specification str or object

The sweep specification to extract against. Defaults to the project's own, when it holds exactly one.

None
progress callable

Told (done, total) as the extraction advances — the matched filter per tone, the smoothing ladder, then the solve's pieces per channel. The window's strip bar reads it; a script can print it. Anything it raises stops the extraction.

None

Returns:

Type Description
list of str

The names of the levels added, one per tone.

Source code in src/visualdynamics/project.py
def extract_sine(self, source: Any,
                 specification: Any = None,
                 progress: Any = None) -> list[str]:
    """Each specification tone's level, read out of a recording
    (Extract Sine Levels) — one object per tone, because each tone
    sweeps its own frequencies on its own clock.

    The specification is found in the project when not named —
    the one SineSweepSpecification there is — and each result is
    linked to the recording it was read from.

    Parameters
    ----------
    source : str or object
        The sine level set or run to read.
    specification : str or object, optional
        The sweep specification to extract against. Defaults to
        the project's own, when it holds exactly one.
    progress : callable, optional
        Told ``(done, total)`` as the extraction advances — the
        matched filter per tone, the smoothing ladder, then the
        solve's pieces per channel. The window's strip bar reads
        it; a script can print it. Anything it raises stops the
        extraction.

    Returns
    -------
    list of str
        The names of the levels added, one per tone.
    """
    from dataclasses import replace

    from .core.sine import extract_sine

    source = self.name_of(source)
    if specification is None:
        spec = self.sine_sweep_specification
        spec_name = self.name_of(spec)
    else:
        spec_name = self.name_of(specification)
        spec = self[spec_name]
    history = self[source]
    # the settings are the history's own; with none set, the
    # suggestion is adopted the way `filter_data` adopts its filter,
    # so the button works before the sine view has been visited
    setting = history.sine_extraction
    if setting is None:
        setting = history.suggest_sine_extraction()
    levels = extract_sine(history, spec, cycles=setting.cycles,
                          refine=setting.refine,
                          target_db=setting.target_db,
                          progress=progress)
    # what the automatic chose rides the setting, so the view and
    # the journal say what was used
    history.sine_extraction = replace(setting, chosen=levels.cycles)
    return [self._derive(source, levels, 'Sine Levels',
                         recipe=('extract_sine',
                                 {'specification': spec_name}))]
stale
stale() -> dict[str, str]

{derived name: why} for everything whose source's settings have moved since it was computed.

A missing source, or a derivation this bookkeeping predates, answers nothing — absence of evidence is not staleness.

Source code in src/visualdynamics/project.py
def stale(self) -> dict[str, str]:
    """{derived name: why} for everything whose source's settings
    have moved since it was computed.

    A missing source, or a derivation this bookkeeping predates,
    answers nothing — absence of evidence is not staleness.
    """
    out = {}
    for name, record in self.provenance.items():
        if name not in self or record.get('source') not in self:
            continue
        now = self._analysis_state(record['source'], record['verb'],
                                   record.get('params'))
        was = record.get('state')
        if was is None or now is None:
            continue
        if _state_tuple(now) != _state_tuple(was):
            out[name] = _state_story(was, now)
    return out
refresh
refresh(name: Any) -> str

Recompute a derived object in place, under its own name.

The links, the report's bindings and the grids all key on the name, so replacing the value under it is what keeps every reference honest. Anything derived from this object goes stale by content, which is the cascade — refreshed one badge at a time, or all at once, but always by a person.

Parameters:

Name Type Description Default
name str or object

The derived object to recompute, in place and under its own name.

required

Returns:

Type Description
str

The name refreshed.

Source code in src/visualdynamics/project.py
def refresh(self, name: Any) -> str:
    """Recompute a derived object in place, under its own name.

    The links, the report's bindings and the grids all key on the
    name, so replacing the value under it is what keeps every
    reference honest. Anything derived from *this* object goes
    stale by content, which is the cascade — refreshed one badge
    at a time, or all at once, but always by a person.

    Parameters
    ----------
    name : str or object
        The derived object to recompute, in place and under
        its own name.

    Returns
    -------
    str
        The name refreshed.
    """
    name = self.name_of(name)
    record = self.provenance.get(name)
    if record is None:
        raise ValueError(f'{name!r} records no derivation to re-run')
    source = record['source']
    if source not in self:
        raise ValueError(
            f"{name!r} was computed from {source!r}, which is gone")
    verb, params = record['verb'], record.get('params', {})
    rebuilt = _RECOMPUTE[verb](self, source, params)
    self[name] = rebuilt
    record['state'] = self._analysis_state(source, verb, params)
    return name
refresh_stale
refresh_stale() -> list[str]

Refresh everything stale, sources before their dependents, until nothing is — the project row's one click.

Source code in src/visualdynamics/project.py
def refresh_stale(self) -> list[str]:
    """Refresh everything stale, sources before their dependents,
    until nothing is — the project row's one click."""
    done: list[str] = []
    # bounded: each pass refreshes at least one or stops, and a
    # refresh can only newly stale things derived from it
    for _ in range(len(self.provenance) + 1):
        waiting = self.stale()
        if not waiting:
            break
        for name in self.ordered_names():
            if name in waiting:
                done.append(self.refresh(name))
    return done
save
save(path: str | PathLike, **options: Any) -> str

Write the whole project to one file: .vdyn, .mat for the same layout in MATLAB's container, or .h5 for the Engineering Sciences Common Data Format.

Parameters:

Name Type Description Default
path str or PathLike

Where to write the file. A .mat suffix writes the project as MATLAB structs (io.matlab), .h5 (or .hdf5, or .escdf) as the standard's types with each object whole in an attachment (io.escdf_objects); anything else is .vdyn.

required
**options Any

Passed to a foreign writer: an ESCDF file's created_by.

{}

Returns:

Type Description
str

The path written.

Source code in src/visualdynamics/project.py
def save(self, path: str | os.PathLike, **options: Any) -> str:
    """Write the whole project to one file: `.vdyn`, `.mat` for
    the same layout in MATLAB's container, or `.h5` for the
    Engineering Sciences Common Data Format.

    Parameters
    ----------
    path : str or os.PathLike
        Where to write the file. A `.mat` suffix writes the project
        as MATLAB structs (`io.matlab`), `.h5` (or `.hdf5`, or
        `.escdf`) as the standard's types with each object whole in
        an attachment (`io.escdf_objects`); anything else is
        `.vdyn`.
    **options
        Passed to a foreign writer: an ESCDF file's `created_by`.

    Returns
    -------
    str
        The path written.
    """
    from .io import export_file, save_test
    from .io.escdf_objects import SUFFIXES

    if str(path).endswith('.mat'):
        export_file(self, str(path), **options)
        return str(path)
    if str(path).endswith(SUFFIXES):
        # by name: the writer's own suffix is `.h5`, and picking by
        # suffix would refuse the other two it keeps
        export_file(self, str(path), format='escdf', **options)
        return str(path)
    save_test(str(path), self.name, dict(self),
              active_geometry=self.active_geometry,
              project_type=self.project_type, object_groups=self.object_groups,
              provenance=self.provenance)
    return str(path)
open classmethod
open(path: str | PathLike) -> Project

Read a project back, from .vdyn, .mat or an ESCDF .h5 (.hdf5, .escdf).

Source code in src/visualdynamics/project.py
@classmethod
def open(cls, path: str | os.PathLike) -> Project:
    """Read a project back, from `.vdyn`, `.mat` or an ESCDF `.h5`
    (`.hdf5`, `.escdf`)."""
    from .io import import_file, load
    from .io.escdf_objects import SUFFIXES

    loaded = (import_file(str(path))
              if str(path).endswith(('.mat', *SUFFIXES))
              else load(str(path)))
    if isinstance(loaded, Project):
        # whatever the loader did on the way — construct, add,
        # link — the session's story starts here: one line that
        # reproduces this state exactly
        loaded.journal = [
            f'project = visualdynamics.Project.open({str(path)!r})']
        return loaded
    raise ValueError(f'{path} holds a single object, not a project')
journal_as
journal_as(line: str | None)

Record a stretch of front-end work as one replaying line.

The GUI imports a file by building the objects itself and adding them one by one; journaled verb by verb, that stretch is a pile of not-replayable comments — when the honest record is the single import_file call a script would make. Inside the stretch every verb stays quiet, exactly as verbs nested in verbs do; the line lands only when the stretch succeeds.

Parameters:

Name Type Description Default
line str or None

The line that replays the stretch — None to record nothing at all.

required

Returns:

Type Description
None
Source code in src/visualdynamics/project.py
@contextlib.contextmanager
def journal_as(self, line: str | None):
    """Record a stretch of front-end work as one replaying line.

    The GUI imports a file by building the objects itself and
    adding them one by one; journaled verb by verb, that stretch
    is a pile of not-replayable comments — when the honest record
    is the single `import_file` call a script would make. Inside
    the stretch every verb stays quiet, exactly as verbs nested in
    verbs do; the line lands only when the stretch succeeds.

    Parameters
    ----------
    line : str or None
        The line that replays the stretch — None to record
        nothing at all.

    Returns
    -------
    None
    """
    self._journal_depth += 1
    try:
        yield
    finally:
        self._journal_depth -= 1
    if line is not None:
        self.journal.append(line)
record_setting
record_setting(target: Any, attribute: str, value: Any) -> None

A settings write, journaled the way a script would make it.

The front ends' funnel: the GUI stores analysis settings by assignment — a dragged averaging span, a filter corner, the shock windows — and those writes are session acts as much as any verb. A repeated write to the same slot replaces its own last line, so a session of nudging settles to the one assignment that stands rather than a line per keystroke.

Parameters:

Name Type Description Default
target str or object

The object written to, by name or as itself.

required
attribute str

Which settings attribute was stored.

required
value Any

What was stored; its repr must rebuild it, which every settings dataclass here guarantees.

required

Returns:

Type Description
None
Source code in src/visualdynamics/project.py
def record_setting(self, target: Any, attribute: str,
                   value: Any) -> None:
    """A settings write, journaled the way a script would make it.

    The front ends' funnel: the GUI stores analysis settings by
    assignment — a dragged averaging span, a filter corner, the
    shock windows — and those writes are session acts as much as
    any verb. A repeated write to the same slot replaces its own
    last line, so a session of nudging settles to the one
    assignment that stands rather than a line per keystroke.

    Parameters
    ----------
    target : str or object
        The object written to, by name or as itself.
    attribute : str
        Which settings attribute was stored.
    value : Any
        What was stored; its repr must rebuild it, which every
        settings dataclass here guarantees.

    Returns
    -------
    None
    """
    try:
        name = self.name_of(target)
    except (KeyError, ValueError):
        return                     # not (or no longer) in the project
    prefix = f'project[{name!r}].{attribute} = '
    line = prefix + repr(value)
    if self.journal and self.journal[-1].startswith(prefix):
        self.journal[-1] = line
    else:
        self.journal.append(line)
record_call
record_call(target: Any, method: str, *args: Any, **kwargs: Any) -> None

A method call on an object, journaled as a script makes it.

The front ends' funnel for object verbs that are not Project verbs — a line added to a geometry, a photo renamed — each an act of the session the console must speak (Brandon, 2026-08-30: adding a line said nothing).

Parameters:

Name Type Description Default
target str or object

The object acted on, by name or as itself.

required
method str

The method a script would call.

required
*args Any

The call's arguments; their reprs must rebuild them.

()
**kwargs Any

Keyword arguments, same rule.

{}

Returns:

Type Description
None
Source code in src/visualdynamics/project.py
def record_call(self, target: Any, method: str, *args: Any,
                **kwargs: Any) -> None:
    """A method call on an object, journaled as a script makes it.

    The front ends' funnel for object verbs that are not Project
    verbs — a line added to a geometry, a photo renamed —
    each an act of the session the console must speak (Brandon,
    2026-08-30: adding a line said nothing).

    Parameters
    ----------
    target : str or object
        The object acted on, by name or as itself.
    method : str
        The method a script would call.
    *args : Any
        The call's arguments; their reprs must rebuild them.
    **kwargs : Any
        Keyword arguments, same rule.

    Returns
    -------
    None
    """
    try:
        name = self.name_of(target)
    except (KeyError, ValueError):
        return
    shown = [self._journal_arg(a) for a in args]
    shown += [f'{key}={self._journal_arg(value)}'
              for key, value in kwargs.items()]
    self.journal.append(
        f'project[{name!r}].{method}({", ".join(shown)})')
session_script
session_script() -> str

This sitting's acts as a runnable Python script.

The journal joined under its imports: every verb that ran and every setting stored — clicked in the GUI or called from a script — recorded as the line that reproduces it, so a session worked up by hand can be replayed, adapted, or kept. Reads and refusals are absent on purpose: the script is what happened to the project, and a verb that raised changed nothing.

Returns:

Type Description
str

A Python script; running it rebuilds this session's project from the same inputs.

Source code in src/visualdynamics/project.py
def session_script(self) -> str:
    """This sitting's acts as a runnable Python script.

    The journal joined under its imports: every verb that ran and
    every setting stored — clicked in the GUI or called from a
    script — recorded as the line that reproduces it, so a session
    worked up by hand can be replayed, adapted, or kept. Reads and
    refusals are absent on purpose: the script is what *happened
    to the project*, and a verb that raised changed nothing.

    Returns
    -------
    str
        A Python script; running it rebuilds this session's
        project from the same inputs.
    """
    head = ['import visualdynamics']
    head += [imported for token, imported in self._SCRIPT_IMPORTS
             if any(token in line for line in self.journal)]
    return '\n'.join([*head, '', *self.journal])

Functions:

type_rank

type_rank(obj: Any) -> int

Where an object sits in the canonical order.

Source code in src/visualdynamics/project.py
def type_rank(obj: Any) -> int:
    """Where an object sits in the canonical order."""
    return next((rank for rank, cls in enumerate(TYPE_ORDER)
                 if isinstance(obj, cls)), len(TYPE_ORDER))

describe

describe(obj: Any) -> str

A few words about what an object holds — how big it is, not what it is called. Empty categories are left out rather than reported as zero.

Source code in src/visualdynamics/project.py
def describe(obj: Any) -> str:
    """A few words about what an object holds — how big it is, not what
    it is called. Empty categories are left out rather than reported as
    zero."""
    if isinstance(obj, Geometry):
        counts = [(obj.num_nodes, 'node'),
                  (len(obj.elem_conn), 'element')]
        if len(obj.cs_id) > 1:
            counts.insert(1, (len(obj.cs_id), 'coordinate system'))
    elif isinstance(obj, ShapeSet):
        span = (f', {obj.frequency.min():.4g}-{obj.frequency.max():.4g} Hz'
                if obj.num_shapes else '')
        note = (' — unscaled: no drive point measured, modal masses '
                'are not physical'
                if getattr(obj, 'unscaled', False) else '')
        return f'{obj.num_shapes} modes{span}{note}'
    elif isinstance(obj, SineLevelSet):
        return (f'{len(obj.levels)} tone{"s" * (len(obj.levels) != 1)}, '
                f'{len(obj.response_dof)} channel'
                f'{"s" * (len(obj.response_dof) != 1)}')
    elif isinstance(obj, SineSweepSpecification):
        lo = min(tone.frequency.min() for tone in obj.tones)
        hi = max(tone.frequency.max() for tone in obj.tones)
        return (f'{len(obj.tones)} tone{"s" * (len(obj.tones) != 1)}, '
                f'{lo:.4g}-{hi:.4g} Hz, {len(obj.response_dof)} control '
                f'channel{"s" * (len(obj.response_dof) != 1)}')
    elif isinstance(obj, DataArray):
        counts = [(obj.num_records, 'record'), (len(obj.abscissa), 'sample')]
    elif isinstance(obj, ChannelTable):
        counts = [(obj.num_channels, 'channel')]
    elif isinstance(obj, Photos):
        counts = [(obj.num_photos, 'photo')]
    elif isinstance(obj, MatchedModes):
        return (f'{obj.num_matches} matched pairs, '
                f'{obj.first} against {obj.second}')
    elif isinstance(obj, Report):
        counts = [(obj.num_blocks, 'block')]
    else:
        return ''
    return ', '.join(f'{n} {word}{"s" * (n != 1)}'
                     for n, word in counts if n)
remap_links(object_groups: Iterable[ObjectGroup], mapping: dict[str, str], roles_taken: Iterable[str] = ()) -> list[ObjectGroup]

Object groups translated through a {old name: new name} mapping.

Importing a project into one that already holds objects renames what clashes; its groups have to follow, or the structure the file carried is lost. A role already spoken for stays with the group that has it — the Basis is the project's, not the file's.

Source code in src/visualdynamics/project.py
def remap_links(object_groups: Iterable[ObjectGroup],
                mapping: dict[str, str],
                roles_taken: Iterable[str] = ()) -> list[ObjectGroup]:
    """Object groups translated through a {old name: new name} mapping.

    Importing a project into one that already holds objects renames
    what clashes; its groups have to follow, or the structure the file
    carried is lost. A role already spoken for stays with the group
    that has it — the Basis is the project's, not the file's.
    """
    taken, out = set(roles_taken), []
    for group in object_groups or ():
        members = [mapping[name] for name in group.get('members', ())
                   if name in mapping]
        if len(members) < 2:
            continue
        role = group.get('role')
        role = None if role in taken else role
        if role is not None:
            taken.add(role)
        out.append({'members': members, 'role': role,
                    **({'name': group['name']} if group.get('name') else {})})
    return out

retarget

retarget(obj: Any, mapping: dict[str, str]) -> None

Point an object's references at renamed objects.

Objects that name others — matched modes name their two shape sets, a report's blocks name what they draw from — go stale the moment a name changes under them, and a stale binding is an unbound figure or a bracket that vanished.

Source code in src/visualdynamics/project.py
def retarget(obj: Any, mapping: dict[str, str]) -> None:
    """Point an object's references at renamed objects.

    Objects that name others — matched modes name their two shape
    sets, a report's blocks name what they draw from — go stale the
    moment a name changes under them, and a stale binding is an
    unbound figure or a bracket that vanished.
    """
    follow = getattr(obj, 'rename_source', None)
    if callable(follow):                            # MatchedModes
        for old, new in mapping.items():
            follow(old, new)
    # the type, not the attribute: a Geometry grew a `blocks` of its own
    # (its element groups, now `groups`), and duck-typing walked those instead — a
    # rename then died inside a Qt signal, where the traceback goes
    # nowhere and the rename simply does not happen
    if isinstance(obj, Report):
        for block in obj.blocks:
            for key in ('source', 'geometry', 'dofs_source', 'shapes'):
                if block.get(key) in mapping:
                    block[key] = mapping[block[key]]

random_vibration_run

random_vibration_run(run: str | PathLike, per_octave: int | None = None, *, last: float | None = None, geometry: str | PathLike | None = None, length_unit: str | None = None, photos: Any = None) -> Project

A Rattlesnake random vibration run, worked up into a project.

project = visualdynamics.random_vibration_run('run.nc4')
project = visualdynamics.random_vibration_run(
    'run.nc4', last=100.0, geometry='article.stp', length_unit='mm',
    photos='setup_photos/')

Every step the window would take on the way from a controller file to a finished project, in the order it takes them: import the run, average PSDs from the control time histories, band those onto proportional bands — and the specification onto the same bands, limits and all, since the report reads the banded measurement against the banded requirement (Brandon, 2026-09-18) — and measure how much of each response the drives account for. The run says it is a random vibration test, so the project comes back declared as one.

The averaging is the Detect answer: the controller's own frame length, window and overlap from the file, with the start and the count worked out from the record — where the run is at level and how many frames that stretch holds — so a script, an import and a click on Detect reach the same numbers (Brandon, 2026-09-19).

The rest is what the window's tree asks for after the run is in, given here so a script never has to open it (Brandon, 2026-09-19): the article's geometry, with its length unit declared when the file does not carry one; the setup photographs, as a folder of png or jpeg files, one file, or a list of files in the order they should appear; and, for a run too long to hold, last — the seconds before the end to import, the same window the import dialog's Last field sets, and a run shorter than that is taken whole. The geometry and the photographs are linked into the run's own group, which is what lets the report read them against the channel table.

Parameters:

Name Type Description Default
run str or PathLike

The controller's .nc4.

required
per_octave int

Bands per octave for the banded PSD and specification; the project's default (a sixth) when omitted.

None
last float

Import only the last last seconds of the run's streams.

None
geometry str or PathLike

A geometry file to import and link to the run.

None
length_unit str

The geometry's length unit, for a file that does not say.

None
photos str, os.PathLike or sequence of them

A folder of photographs, one photograph, or several.

None

Returns:

Type Description
Project

The worked-up project, declared Random Vibration.

Source code in src/visualdynamics/project.py
def random_vibration_run(run: str | os.PathLike,
                         per_octave: int | None = None, *,
                         last: float | None = None,
                         geometry: str | os.PathLike | None = None,
                         length_unit: str | None = None,
                         photos: Any = None) -> Project:
    """A Rattlesnake random vibration run, worked up into a project.

        project = visualdynamics.random_vibration_run('run.nc4')
        project = visualdynamics.random_vibration_run(
            'run.nc4', last=100.0, geometry='article.stp', length_unit='mm',
            photos='setup_photos/')

    Every step the window would take on the way from a controller file
    to a finished project, in the order it takes them: import the run,
    average PSDs from the control time histories, band those onto
    proportional bands — and the specification onto the same bands,
    limits and all, since the report reads the banded measurement
    against the banded requirement (Brandon, 2026-09-18) — and
    measure how much of each response the drives account for. The run
    says it is a random vibration test, so the project comes back
    declared as one.

    The averaging is the Detect answer: the controller's own frame
    length, window and overlap from the file, with the start and the
    count worked out from the record — where the run is at level and
    how many frames that stretch holds — so a script, an import and a
    click on Detect reach the same numbers (Brandon, 2026-09-19).

    The rest is what the window's tree asks for after the run is in,
    given here so a script never has to open it (Brandon,
    2026-09-19): the article's geometry, with its length unit
    declared when the file does not carry one; the setup photographs,
    as a folder of png or jpeg files, one file, or a list of files in
    the order they should appear; and, for a run too long to hold,
    `last` — the seconds before the end to import, the same window the
    import dialog's *Last* field sets, and a run shorter than that is
    taken whole. The geometry and the photographs are linked into the
    run's own group, which is what lets the report read them against
    the channel table.

    Parameters
    ----------
    run : str or os.PathLike
        The controller's `.nc4`.
    per_octave : int, optional
        Bands per octave for the banded PSD and specification; the
        project's default (a sixth) when omitted.
    last : float, optional
        Import only the last `last` seconds of the run's streams.
    geometry : str or os.PathLike, optional
        A geometry file to import and link to the run.
    length_unit : str, optional
        The geometry's length unit, for a file that does not say.
    photos : str, os.PathLike or sequence of them, optional
        A folder of photographs, one photograph, or several.

    Returns
    -------
    Project
        The worked-up project, declared Random Vibration.
    """
    project = Project()
    project.import_file(run, **(_last_window(run, last) if last is not None
                                else {}))
    history = next((name for name, obj in project.items()
                    if isinstance(obj, TimeHistory)), None)
    if history is None:
        raise ValueError(f'{run} holds no time data to work up')
    psds = project.compute_psds(history)
    project.compute_octave(psds, per_octave)
    from .core.data import Specification
    specification = next((name for name, obj in project.items()
                          if isinstance(obj, Specification)), None)
    if specification is not None:
        project.compute_octave(specification, per_octave)
    project.compute_multiple_coherence(history)
    extras: list[str] = []
    if geometry is not None:
        options = {} if length_unit is None else {'length_unit': length_unit}
        extras += project.import_file(geometry, **options)
    if photos is not None:
        extras.append(project.add('Photos', _photos_from(photos)))
    if extras:
        project.link(history, *extras)
    return project

work_up_system_id

work_up_system_id(project: Project) -> tuple[str | None, str]

An imported system identification, worked up in place.

The seam between reading a file and doing the work, so the work can be exercised on a recording built by hand: no file a test can commit holds the two streams a system identification is made of, and a rule that is never exercised is a rule that is not tested (2026-09-22).

Returns the two stream names, the ambient one None where the recording holds only the driven stream.

Source code in src/visualdynamics/project.py
def work_up_system_id(project: Project) -> tuple[str | None, str]:
    """An imported system identification, worked up in place.

    The seam between reading a file and doing the work, so the work
    can be exercised on a recording built by hand: no file a test can
    commit holds the *two* streams a system identification is made of,
    and a rule that is never exercised is a rule that is not tested
    (2026-09-22).

    Returns the two stream names, the ambient one None where the
    recording holds only the driven stream.
    """
    noise, driven = _quiet_and_driven(project)
    project.project_type = 'System ID'
    driven = project.rename(driven, 'Excitation Time History')
    if noise is not None:
        noise = project.rename(noise, 'Noise Time History')
    # H1: the plant is measured driving through a known excitation, so
    # what noise there is sits on the response
    project.compute_frfs(driven, 'H1')
    project.compute_multiple_coherence(driven)
    project.compute_psds(driven)
    if noise is not None:
        # the ambient borrows the excitation's frames on purpose: the
        # ratio the report reads is only defined on lines both hold
        project[noise].averaging = project[driven].averaging
        project.compute_psds(noise)
    return noise, driven

system_id_run

system_id_run(run: str | PathLike, *, last: float | None = None, geometry: str | PathLike | None = None, length_unit: str | None = None, photos: Any = None) -> Project

A Rattlesnake system identification, worked up into a project.

project = visualdynamics.system_id_run('sysid.nc4')

The steps the window would take, in its order: import the recording, name the two streams for what they are, measure the plant from the driven one by H1 — the controller's own estimator, the excitation being known and the noise on the response — take the multiple coherence, and average a density from each stream on the same frames, since the signal-to-noise is a ratio of densities and a ratio only exists on shared lines. The project comes back declared a System ID.

last, geometry, length_unit and photos are random_vibration_run's, and mean the same things.

Parameters:

Name Type Description Default
run str or PathLike

The controller's .nc4.

required
last float

Import only the last last seconds of the run's streams; a shorter run is taken whole.

None
geometry str or PathLike

A geometry file to import and link to the run.

None
length_unit str

The geometry's length unit, for a file that does not say.

None
photos str, os.PathLike or sequence of them

A folder of photographs, one photograph, or several in order.

None

Returns:

Type Description
Project

The worked-up project, declared System ID.

Source code in src/visualdynamics/project.py
def system_id_run(run: str | os.PathLike, *,
                  last: float | None = None,
                  geometry: str | os.PathLike | None = None,
                  length_unit: str | None = None,
                  photos: Any = None) -> Project:
    """A Rattlesnake system identification, worked up into a project.

        project = visualdynamics.system_id_run('sysid.nc4')

    The steps the window would take, in its order: import the
    recording, name the two streams for what they are, measure the
    plant from the driven one by H1 — the controller's own estimator,
    the excitation being known and the noise on the response — take
    the multiple coherence, and average a density from each stream on
    **the same frames**, since the signal-to-noise is a ratio of
    densities and a ratio only exists on shared lines. The project
    comes back declared a System ID.

    `last`, `geometry`, `length_unit` and `photos` are
    `random_vibration_run`'s, and mean the same things.

    Parameters
    ----------
    run : str or os.PathLike
        The controller's `.nc4`.
    last : float, optional
        Import only the last `last` seconds of the run's streams; a
        shorter run is taken whole.
    geometry : str or os.PathLike, optional
        A geometry file to import and link to the run.
    length_unit : str, optional
        The geometry's length unit, for a file that does not say.
    photos : str, os.PathLike or sequence of them, optional
        A folder of photographs, one photograph, or several in order.

    Returns
    -------
    Project
        The worked-up project, declared System ID.
    """
    project = Project()
    project.import_file(run, **(_last_window(run, last) if last is not None
                                else {}))
    try:
        noise, driven = work_up_system_id(project)
    except ValueError as exc:
        raise ValueError(f'{run} holds no time data to work up') from exc
    extras: list[str] = []
    if geometry is not None:
        options = {} if length_unit is None else {'length_unit': length_unit}
        extras += project.import_file(geometry, **options)
    if photos is not None:
        extras.append(project.add('Photos', _photos_from(photos)))
    linked = [name for name in (noise, *extras) if name]
    if linked:
        project.link(driven, *linked)
    return project

mixed_run

mixed_run(run: str | PathLike, per_octave: int | None = None, *, last: float | None = None, geometry: str | PathLike | None = None, length_unit: str | None = None, photos: Any = None) -> Project

A Rattlesnake random run with a sine sweep under it, worked up into a project: both halves.

project = visualdynamics.mixed_run('run.nc4')

Everything random_vibration_run does — the run imported, the PSDs averaged and banded, the specification banded, the coherence measured, the geometry and photographs brought in — and then the sine half: each tone's level extracted from the same recording against the sweep specification the file carried. The run says it is both, so the project comes back declared Random and Sine. A run with no sine specification is refused by name rather than worked up as half of what was asked for; random_vibration_run is the call for it. The keywords are random_vibration_run's.

Parameters:

Name Type Description Default
run str or PathLike

The controller's .nc4.

required
per_octave int

Bands per octave for the banded PSD and specification; the project's default (a sixth) when omitted.

None
last float

Import only the last last seconds of the run's streams; a shorter run is taken whole.

None
geometry str or PathLike

A geometry file to import and link to the run.

None
length_unit str

The geometry's length unit, for a file that does not say.

None
photos str, os.PathLike or sequence of them

A folder of photographs, one photograph, or several in order.

None

Returns:

Type Description
Project

The worked-up project, declared Random and Sine.

Source code in src/visualdynamics/project.py
def mixed_run(run: str | os.PathLike, per_octave: int | None = None, *,
              last: float | None = None,
              geometry: str | os.PathLike | None = None,
              length_unit: str | None = None,
              photos: Any = None) -> Project:
    """A Rattlesnake random run with a sine sweep under it, worked up
    into a project: both halves.

        project = visualdynamics.mixed_run('run.nc4')

    Everything `random_vibration_run` does — the run imported, the PSDs
    averaged and banded, the specification banded, the coherence
    measured, the geometry and photographs brought in — and then the
    sine half: each tone's level extracted from the same recording
    against the sweep specification the file carried. The run says it
    is both, so the project comes back declared Random and Sine. A run
    with no sine specification is refused by name rather than worked
    up as half of what was asked for; `random_vibration_run` is the
    call for it. The keywords are `random_vibration_run`'s.

    Parameters
    ----------
    run : str or os.PathLike
        The controller's `.nc4`.
    per_octave : int, optional
        Bands per octave for the banded PSD and specification; the
        project's default (a sixth) when omitted.
    last : float, optional
        Import only the last `last` seconds of the run's streams; a
        shorter run is taken whole.
    geometry : str or os.PathLike, optional
        A geometry file to import and link to the run.
    length_unit : str, optional
        The geometry's length unit, for a file that does not say.
    photos : str, os.PathLike or sequence of them, optional
        A folder of photographs, one photograph, or several in order.

    Returns
    -------
    Project
        The worked-up project, declared Random and Sine.
    """
    project = random_vibration_run(run, per_octave, last=last,
                                   geometry=geometry,
                                   length_unit=length_unit, photos=photos)
    from .core.sine import SineSweepSpecification

    if not any(isinstance(obj, SineSweepSpecification)
               for _name, obj in project.items()):
        raise ValueError(
            f'{run} holds no sine sweep specification: it is not a random '
            'and sine run — random_vibration_run is the call for it')
    project.extract_sine(project.time_history)
    return project

sine_run

sine_run(run: str | PathLike, *, last: float | None = None, geometry: str | PathLike | None = None, length_unit: str | None = None, photos: Any = None) -> Project

A Rattlesnake sine sweep run, worked up into a project.

project = visualdynamics.sine_run('sweep.nc4')

The run imported, each tone's level extracted from the control channels against the sweep specification the file carried, and the geometry and photographs brought in and linked, as random_vibration_run brings them. The run says it is a sine sweep, so the project comes back declared as one. A run with no sine sweep specification is refused by name; random_vibration_run or mixed_run is the call for it. The keywords are random_vibration_run's, less per_octave, which a sweep has no use for.

Parameters:

Name Type Description Default
run str or PathLike

The controller's .nc4.

required
last float

Import only the last last seconds of the run's streams; a shorter run is taken whole.

None
geometry str or PathLike

A geometry file to import and link to the run.

None
length_unit str

The geometry's length unit, for a file that does not say.

None
photos str, os.PathLike or sequence of them

A folder of photographs, one photograph, or several in order.

None

Returns:

Type Description
Project

The worked-up project, declared Sine Sweep.

Source code in src/visualdynamics/project.py
def sine_run(run: str | os.PathLike, *,
             last: float | None = None,
             geometry: str | os.PathLike | None = None,
             length_unit: str | None = None,
             photos: Any = None) -> Project:
    """A Rattlesnake sine sweep run, worked up into a project.

        project = visualdynamics.sine_run('sweep.nc4')

    The run imported, each tone's level extracted from the control
    channels against the sweep specification the file carried, and the
    geometry and photographs brought in and linked, as
    `random_vibration_run` brings them. The run says it is a sine
    sweep, so the project comes back declared as one. A run with no
    sine sweep specification is refused by name; `random_vibration_run`
    or `mixed_run` is the call for it. The keywords are
    `random_vibration_run`'s, less `per_octave`, which a sweep has no
    use for.

    Parameters
    ----------
    run : str or os.PathLike
        The controller's `.nc4`.
    last : float, optional
        Import only the last `last` seconds of the run's streams; a
        shorter run is taken whole.
    geometry : str or os.PathLike, optional
        A geometry file to import and link to the run.
    length_unit : str, optional
        The geometry's length unit, for a file that does not say.
    photos : str, os.PathLike or sequence of them, optional
        A folder of photographs, one photograph, or several in order.

    Returns
    -------
    Project
        The worked-up project, declared Sine Sweep.
    """
    project = Project()
    project.import_file(run, **(_last_window(run, last) if last is not None
                                else {}))
    from .core.sine import SineSweepSpecification

    if not any(isinstance(obj, SineSweepSpecification)
               for _name, obj in project.items()):
        raise ValueError(
            f'{run} holds no sine sweep specification: it is not a sine '
            'sweep run — random_vibration_run or mixed_run is the call for it')
    history = next((name for name, obj in project.items()
                    if isinstance(obj, TimeHistory)), None)
    if history is None:
        raise ValueError(f'{run} holds no time data to work up')
    project.extract_sine(history)
    extras: list[str] = []
    if geometry is not None:
        options = {} if length_unit is None else {'length_unit': length_unit}
        extras += project.import_file(geometry, **options)
    if photos is not None:
        extras.append(project.add('Photos', _photos_from(photos)))
    if extras:
        project.link(history, *extras)
    return project

sine_report

sine_report(run: Any = ASK, path: str | PathLike | None = None, *, last: float | None = None, geometry: Any = None, photos: Any = None, unit_system: Any = None, marking: str | None = None) -> Any

A Rattlesnake sine sweep run in, an HTML report out.

visualdynamics.sine_report('sweep.nc4', 'report.html')
visualdynamics.sine_report()                   # ask for both
visualdynamics.sine_report('sweep.nc4', 'reports/')

random_vibration_report's twin for a sweep: the same asking when the run is left out, the same batch and folder rules, the workup of sine_run, and the Sine Sweep report written as one self-contained HTML file. Returns the path written, or the list of them when several runs were chosen (Brandon, 2026-10-02: the batch tool stopped at a run that was sine alone). marking is the banner across every page of the report, 'UNCLASSIFIED' unless said — on every one-call report since 2026-10-02, when a batch wanted its own heading.

Parameters:

Name Type Description Default
run str, os.PathLike, sequence of them, or `visualdynamics.ASK`

The controller's .nc4, or a list of them for a batch; asked for in a file dialog, as many as wanted, when omitted.

ASK
path (str, PathLike or None)

The file to write, or a folder (one that exists, or a name ending in a separator) the report lands in under the run's own name. Beside the run when omitted. Several runs need a folder or None.

None
last float

Import only the last last seconds of the run; a shorter run is taken whole.

None
geometry str, os.PathLike, `visualdynamics.ASK` or None

A geometry file to import and link to the run. Asked for once for the whole batch when the run was asked for, Cancel meaning none; ASK asks even when the run was given.

None
photos str, os.PathLike or sequence of them

A folder of photographs, one photograph, or several in order.

None
unit_system UnitSystem

The units the report is written in; the package default, 'in-slinch-lbf-s (g)', when omitted.

None
marking str

The banner across the top and bottom of every page; 'UNCLASSIFIED' when omitted.

None

Returns:

Type Description
str or list of str

The path written; a list of them when several runs were chosen or a list of runs was given.

Source code in src/visualdynamics/project.py
def sine_report(run: Any = ASK, path: str | os.PathLike | None = None, *,
                last: float | None = None,
                geometry: Any = None,
                photos: Any = None,
                unit_system: Any = None,
                marking: str | None = None) -> Any:
    """A Rattlesnake sine sweep run in, an HTML report out.

        visualdynamics.sine_report('sweep.nc4', 'report.html')
        visualdynamics.sine_report()                   # ask for both
        visualdynamics.sine_report('sweep.nc4', 'reports/')

    `random_vibration_report`'s twin for a sweep: the same asking when
    the run is left out, the same batch and folder rules, the workup
    of `sine_run`, and the Sine Sweep report written as one
    self-contained HTML file. Returns the path written, or the list of
    them when several runs were chosen (Brandon, 2026-10-02: the batch
    tool stopped at a run that was sine alone). `marking` is the banner
    across every page of the report, 'UNCLASSIFIED' unless said — on
    every one-call report since 2026-10-02, when a batch wanted its own
    heading.

    Parameters
    ----------
    run : str, os.PathLike, sequence of them, or `visualdynamics.ASK`
        The controller's `.nc4`, or a list of them for a batch; asked for
        in a file dialog, as many as wanted, when omitted.
    path : str, os.PathLike or None, optional
        The file to write, or a folder (one that exists, or a name ending
        in a separator) the report lands in under the run's own name.
        Beside the run when omitted. Several runs need a folder or None.
    last : float, optional
        Import only the last `last` seconds of the run; a shorter run is
        taken whole.
    geometry : str, os.PathLike, `visualdynamics.ASK` or None, optional
        A geometry file to import and link to the run. Asked for once for
        the whole batch when the run was asked for, Cancel meaning none;
        `ASK` asks even when the run was given.
    photos : str, os.PathLike or sequence of them, optional
        A folder of photographs, one photograph, or several in order.
    unit_system : UnitSystem, optional
        The units the report is written in; the package default,
        'in-slinch-lbf-s (g)', when omitted.
    marking : str, optional
        The banner across the top and bottom of every page;
        'UNCLASSIFIED' when omitted.

    Returns
    -------
    str or list of str
        The path written; a list of them when several runs were chosen or
        a list of runs was given.
    """
    return _one_call_reports(
        run, path, geometry, unit_system, 'sine',
        lambda one, geo: sine_run(one, last=last, geometry=geo, photos=photos),
        marking=marking)

report_kind

report_kind(run: str | PathLike) -> str

Which one-call reports a Rattlesnake run gets: 'random', 'mixed', 'sine' or 'sysid', from the project type the file declares — 'mixed' being a random run with a sweep under it, which gets a random report and a sine report (run_report) — and 'sysid' for a streamed save with a system ID's shape, two streams, a quiet one then a loud one, where the window asks and this decides (Brandon, 2026-10-02: a batch defaults to the likeliest reading). A run of a type with no one-call report, a modal or a transient run, is refused by name.

Parameters:

Name Type Description Default
run str or PathLike

The controller's .nc4.

required

Returns:

Type Description
str

'random', 'mixed', 'sine' or 'sysid'.

Source code in src/visualdynamics/project.py
def report_kind(run: str | os.PathLike) -> str:
    """Which one-call reports a Rattlesnake run gets: 'random', 'mixed',
    'sine' or 'sysid', from the project type the file declares — 'mixed'
    being a random run with a sweep under it, which gets a random report
    and a sine report (`run_report`) — and
    'sysid' for a streamed save with a system ID's shape, two streams,
    a quiet one then a loud one, where the window asks and this
    decides (Brandon, 2026-10-02: a batch defaults to the likeliest
    reading). A run of a type with no one-call report, a modal or a
    transient run, is refused by name.

    Parameters
    ----------
    run : str or os.PathLike
        The controller's `.nc4`.

    Returns
    -------
    str
        'random', 'mixed', 'sine' or 'sysid'.
    """
    from .io.rattlesnake import project_type, streamed_sysid_candidate

    declared = project_type(run)
    if declared == 'System ID' or streamed_sysid_candidate(run):
        return 'sysid'
    kind = _REPORTS_BY_TYPE.get(declared or '')
    if kind is None:
        raise ValueError(
            f'{run} is {declared or "of no type visualdynamics reports on"}: '
            'no one-call report exists for it — a project worked up in the '
            'window can still generate its report')
    return kind

run_report

run_report(run: Any = ASK, path: str | PathLike | None = None, *, last: Any = None, kinds: Any = None, geometry: Any = None, photos: Any = None, per_octave: int | None = None, unit_system: Any = None, marking: str | None = None, progress: Any = None) -> Any

A Rattlesnake run in, the report its type calls for out.

visualdynamics.run_report('run.nc4', 'report.html')
visualdynamics.run_report()               # ask for the runs and the geometry
visualdynamics.run_report('run.nc4', 'reports/')
visualdynamics.run_report(['a.nc4', 'b.nc4'], 'reports/',
                          geometry='article.stp')

The one-call report that reads the run's own type (report_kind) and writes that report: a random run gets random_vibration_report's, a sweep sine_report's, a system identification system_id_report's, and a random-and-sine run both a random and a sine report, as <name>_random.html and <name>_sine.html — the random reading only the last last seconds when they are given, the sine always the whole run, since a sweep cut short loses tones (the combined report retired 2026-10-05). A streamed save that looks like a system ID, two streams quiet then loud, is taken as one rather than asked about. Named for what it takes, since visualdynamics.report is the rendering package. The asking, the batch and the folder are the same rule the typed functions share, and a batch may mix kinds. The keywords are the union of theirs; per_octave reaches the random halves alone.

Every run's kind is read before any report is written, so a batch holding a run with no one-call report is refused whole rather than stopping partway through. kinds overrides the reading run by run — a restarted random run the system-ID guess took for one, a file that declares no type (Reports from Runs' per-run choice, 2026-10-06) — and last may be given run by run too. progress(done, total) is told after each run, the hook the window's bar and Cancel ride on (Reports from Runs, 2026-10-04); a run is the smallest step, so a cancel lands between runs.

Parameters:

Name Type Description Default
run str, os.PathLike, sequence of them, or `visualdynamics.ASK`

The controller's .nc4, or a list of them for a batch; asked for in a file dialog, as many as wanted, when omitted.

ASK
path (str, PathLike or None)

The file to write, or a folder (one that exists, or a name ending in a separator) the report lands in under the run's own name. Beside the run when omitted. Several runs need a folder or None.

None
last float or Mapping

Import only the last last seconds of the run; a shorter run is taken whole. For a random-and-sine run the random report alone: its sine report reads the whole run. A mapping gives each run its own, by path; a run it leaves out, or maps to None, is read whole.

None
kinds Mapping

The report each run gets, by path: 'random', 'mixed' (a random and a sine report), 'sine' or 'sysid'. A run it leaves out gets the report its file declares (report_kind).

None
geometry str, os.PathLike, `visualdynamics.ASK` or None

A geometry file to import and link to the run. Asked for once for the whole batch when the run was asked for, Cancel meaning none; ASK asks even when the run was given.

None
photos str, os.PathLike or sequence of them

A folder of photographs, one photograph, or several in order.

None
per_octave int

Bands per octave for the banded PSD and specification; the project's default (a sixth) when omitted. The random halves alone read it.

None
unit_system UnitSystem

The units the report is written in; the package default, 'in-slinch-lbf-s (g)', when omitted.

None
marking str

The banner across the top and bottom of every page; 'UNCLASSIFIED' when omitted.

None
progress callable

Called as progress(done, total) before the first run and after each; it may raise to stop the batch between runs.

None

Returns:

Type Description
str or list of str

The path written; a list of them when several runs were chosen, a list of runs was given, or a run was random and sine (two reports).

Source code in src/visualdynamics/project.py
def run_report(run: Any = ASK, path: str | os.PathLike | None = None, *,
           last: Any = None,
           kinds: Any = None,
           geometry: Any = None,
           photos: Any = None,
           per_octave: int | None = None,
           unit_system: Any = None,
           marking: str | None = None,
           progress: Any = None) -> Any:
    """A Rattlesnake run in, the report its type calls for out.

        visualdynamics.run_report('run.nc4', 'report.html')
        visualdynamics.run_report()               # ask for the runs and the geometry
        visualdynamics.run_report('run.nc4', 'reports/')
        visualdynamics.run_report(['a.nc4', 'b.nc4'], 'reports/',
                                  geometry='article.stp')

    The one-call report that reads the run's own type (`report_kind`)
    and writes that report: a random run gets `random_vibration_report`'s,
    a sweep `sine_report`'s, a system identification
    `system_id_report`'s, and a random-and-sine run both a random and a
    sine report, as `<name>_random.html` and `<name>_sine.html` — the
    random reading only the last `last` seconds when they are given, the
    sine always the whole run, since a sweep cut short loses tones (the
    combined report retired 2026-10-05). A streamed save
    that looks like a system ID, two streams quiet then loud, is taken
    as one rather than asked about. Named for what it takes, since
    `visualdynamics.report` is the rendering package. The asking, the batch and the
    folder are the same rule the typed functions share, and a batch
    may mix kinds. The keywords are the union of theirs; `per_octave`
    reaches the random halves alone.

    Every run's kind is read before any report is written, so a batch
    holding a run with no one-call report is refused whole rather than
    stopping partway through. `kinds` overrides the reading run by run —
    a restarted random run the system-ID guess took for one, a file
    that declares no type (Reports from Runs' per-run choice,
    2026-10-06) — and `last` may be given run by run too. `progress(done, total)` is told after
    each run, the hook the window's bar and Cancel ride on (Reports
    from Runs, 2026-10-04); a run is the smallest step, so a cancel
    lands between runs.

    Parameters
    ----------
    run : str, os.PathLike, sequence of them, or `visualdynamics.ASK`
        The controller's `.nc4`, or a list of them for a batch; asked for
        in a file dialog, as many as wanted, when omitted.
    path : str, os.PathLike or None, optional
        The file to write, or a folder (one that exists, or a name ending
        in a separator) the report lands in under the run's own name.
        Beside the run when omitted. Several runs need a folder or None.
    last : float or Mapping, optional
        Import only the last `last` seconds of the run; a shorter run is
        taken whole. For a random-and-sine run the random report alone:
        its sine report reads the whole run. A mapping gives each run its
        own, by path; a run it leaves out, or maps to None, is read
        whole.
    kinds : Mapping, optional
        The report each run gets, by path: 'random', 'mixed' (a random
        and a sine report), 'sine' or 'sysid'. A run it leaves out gets
        the report its file declares (`report_kind`).
    geometry : str, os.PathLike, `visualdynamics.ASK` or None, optional
        A geometry file to import and link to the run. Asked for once for
        the whole batch when the run was asked for, Cancel meaning none;
        `ASK` asks even when the run was given.
    photos : str, os.PathLike or sequence of them, optional
        A folder of photographs, one photograph, or several in order.
    per_octave : int, optional
        Bands per octave for the banded PSD and specification; the
        project's default (a sixth) when omitted. The random
        halves alone read it.
    unit_system : UnitSystem, optional
        The units the report is written in; the package default,
        'in-slinch-lbf-s (g)', when omitted.
    marking : str, optional
        The banner across the top and bottom of every page;
        'UNCLASSIFIED' when omitted.
    progress : callable, optional
        Called as ``progress(done, total)`` before the first run and after
        each; it may raise to stop the batch between runs.

    Returns
    -------
    str or list of str
        The path written; a list of them when several runs were chosen, a
        list of runs was given, or a run was random and sine (two
        reports).
    """
    runs, asked, many = _runs_of(run)
    chosen = {str(key): value for key, value in (kinds or {}).items()}
    wrong = sorted({str(value) for value in chosen.values()
                    if value not in REPORT_KINDS})
    if wrong:
        raise ValueError(f'no report is called {", ".join(wrong)}: '
                         f'the kinds are {", ".join(REPORT_KINDS)}')
    kinds = [chosen.get(str(one)) or report_kind(one) for one in runs]

    def seconds(one):
        # one number for the batch, or each run's own; read whole when
        # a mapping says nothing about it
        if isinstance(last, Mapping):
            return last.get(str(one))
        return last

    if geometry is ASK or (asked and geometry is None):
        from .gui.ask import for_geometry
        geometry = for_geometry()
    if len(runs) > 1 and path is not None and not (
            os.path.isdir(os.path.expanduser(str(path)))
            or str(path).endswith((os.sep, '/'))):
        raise ValueError(
            f'{len(runs)} runs cannot be written to one file '
            f'{str(path)!r} — give a folder, or no path at all')
    workups = {
        'random': lambda one, geo: random_vibration_run(
            one, per_octave, last=seconds(one), geometry=geo, photos=photos),
        'sine': lambda one, geo: sine_run(one, last=seconds(one),
                                          geometry=geo, photos=photos),
        'sysid': lambda one, geo: system_id_run(one, last=seconds(one),
                                                geometry=geo, photos=photos),
    }
    written = []
    if progress is not None:
        progress(0, len(runs))
    for done, (one, kind) in enumerate(zip(runs, kinds), start=1):
        if kind == 'mixed':
            # two reports, each read the way its own test is: the
            # random from the last `last` seconds when given, the sweep
            # from the whole run, since a sweep cut short loses tones
            # (Brandon, 2026-10-05, when the combined report retired)
            stem, suffix = os.path.splitext(_report_path(one, path))
            suffix = suffix or '.html'
            written.append(_one_call_reports(
                one, f'{stem}_random{suffix}', geometry, unit_system,
                'random', workups['random'], marking=marking))
            written.append(_one_call_reports(
                one, f'{stem}_sine{suffix}', geometry, unit_system, 'sine',
                lambda whole, geo: sine_run(whole, geometry=geo,
                                            photos=photos),
                marking=marking))
        else:
            written.append(_one_call_reports(one, path, geometry,
                                             unit_system, kind,
                                             workups[kind], marking=marking))
        if progress is not None:
            progress(done, len(runs))
    return written if many or len(written) > 1 else written[0]

system_id_report

system_id_report(run: Any = ASK, path: str | PathLike | None = None, *, last: float | None = None, geometry: Any = None, photos: Any = None, unit_system: Any = None, marking: str | None = None) -> Any

A Rattlesnake system identification in, an HTML report out.

visualdynamics.system_id_report('sysid.nc4', 'sysid.html')
visualdynamics.system_id_report()            # ask for both

system_id_run followed by the System ID report: the measured plant's FRFs, the coherence map, and the signal-to-noise of the measurement, line by line and per channel — where it falls to zero decibels, the plant is the room.

Everything about being asked, writing a batch and taking a folder is random_vibration_report's, and means the same things: left out, the run is asked for and as many may be chosen as there are reports wanted; the geometry is asked for once for the whole batch when the run was asked for, Cancel meaning none; visualdynamics.ASK forces either question; and path may be the file to write or the folder to write into.

Parameters:

Name Type Description Default
run str, os.PathLike, sequence of them, or `visualdynamics.ASK`

The controller's .nc4, or a list of them for a batch; asked for in a file dialog, as many as wanted, when omitted.

ASK
path (str, PathLike or None)

The file to write, or a folder (one that exists, or a name ending in a separator) the report lands in under the run's own name. Beside the run when omitted. Several runs need a folder or None.

None
last float

Import only the last last seconds of the run; a shorter run is taken whole.

None
geometry str, os.PathLike, `visualdynamics.ASK` or None

A geometry file to import and link to the run. Asked for once for the whole batch when the run was asked for, Cancel meaning none; ASK asks even when the run was given.

None
photos str, os.PathLike or sequence of them

A folder of photographs, one photograph, or several in order.

None
unit_system UnitSystem

The units the report is written in; the package default, 'in-slinch-lbf-s (g)', when omitted.

None
marking str

The banner across the top and bottom of every page; 'UNCLASSIFIED' when omitted.

None

Returns:

Type Description
str or list of str

The path written; a list of them when several runs were chosen or a list of runs was given.

Source code in src/visualdynamics/project.py
def system_id_report(run: Any = ASK,
                     path: str | os.PathLike | None = None, *,
                     last: float | None = None,
                     geometry: Any = None,
                     photos: Any = None,
                     unit_system: Any = None,
                     marking: str | None = None) -> Any:
    """A Rattlesnake system identification in, an HTML report out.

        visualdynamics.system_id_report('sysid.nc4', 'sysid.html')
        visualdynamics.system_id_report()            # ask for both

    `system_id_run` followed by the System ID report: the measured
    plant's FRFs, the coherence map, and the signal-to-noise of the
    measurement, line by line and per channel — where it falls to
    zero decibels, the plant is the room.

    Everything about being asked, writing a batch and taking a folder
    is `random_vibration_report`'s, and means the same things: left
    out, the run is asked for and as many may be chosen as there are
    reports wanted; the geometry is asked for once for the whole
    batch when the run was asked for, Cancel meaning none;
    `visualdynamics.ASK` forces either question; and `path` may be the
    file to write or the folder to write into.

    Parameters
    ----------
    run : str, os.PathLike, sequence of them, or `visualdynamics.ASK`
        The controller's `.nc4`, or a list of them for a batch; asked for
        in a file dialog, as many as wanted, when omitted.
    path : str, os.PathLike or None, optional
        The file to write, or a folder (one that exists, or a name ending
        in a separator) the report lands in under the run's own name.
        Beside the run when omitted. Several runs need a folder or None.
    last : float, optional
        Import only the last `last` seconds of the run; a shorter run is
        taken whole.
    geometry : str, os.PathLike, `visualdynamics.ASK` or None, optional
        A geometry file to import and link to the run. Asked for once for
        the whole batch when the run was asked for, Cancel meaning none;
        `ASK` asks even when the run was given.
    photos : str, os.PathLike or sequence of them, optional
        A folder of photographs, one photograph, or several in order.
    unit_system : UnitSystem, optional
        The units the report is written in; the package default,
        'in-slinch-lbf-s (g)', when omitted.
    marking : str, optional
        The banner across the top and bottom of every page;
        'UNCLASSIFIED' when omitted.

    Returns
    -------
    str or list of str
        The path written; a list of them when several runs were chosen or
        a list of runs was given.
    """
    return _one_call_reports(
        run, path, geometry, unit_system, 'sysid',
        lambda one, geo: system_id_run(one, last=last, geometry=geo,
                                       photos=photos),
        marking=marking)

random_vibration_report

random_vibration_report(run: Any = ASK, path: str | PathLike | None = None, *, last: float | None = None, geometry: Any = None, photos: Any = None, per_octave: int | None = None, unit_system: Any = None, marking: str | None = None) -> Any

A Rattlesnake random vibration run in, an HTML report out.

visualdynamics.random_vibration_report('run.nc4', 'report.html')
visualdynamics.random_vibration_report(
    'run.nc4', 'report.html', last=100.0,
    geometry='article.stp', photos='setup_photos/')
visualdynamics.random_vibration_report()          # ask for both
visualdynamics.random_vibration_report('run.nc4', 'reports/')

Left out, it asks. Called with no run, it opens a file dialog and takes as many runs as are chosen, writing one report each and returning the list; a run chosen that way is asked about its geometry too, where Cancel means none. visualdynamics.ASK in either place forces the question, so a script with a run in hand can still be asked for the geometry. A run given without a geometry keyword means no geometry, as it always has, and nothing opens.

A folder is a folder. path may be the file to write, or a folder the report lands in under the run's own name with an .html extension. Without a path it lands beside the run. Several runs need a folder or no path, never one file name.

The whole workflow in one call, with nothing to click (Brandon, 2026-09-19): import the run — or only its last last seconds, a shorter run taken whole — detect the averaging, compute the PSDs, band them and the specification onto octave bands, compute the multiple coherence, bring in the geometry and the photographs when they are given, generate the Random Vibration report and write it as one self-contained HTML file. Returns the path written, or the list of them when several runs were chosen. The keywords are random_vibration_run's, plus unit_system for the units the report is written in.

Everything it does is random_vibration_run followed by generate_report and export_report; reach for those instead when the project is wanted afterwards — to write the test summary, or to save it as .vdyn.

Parameters:

Name Type Description Default
run str, os.PathLike, sequence of them, or `visualdynamics.ASK`

The controller's .nc4, or a list of them for a batch; asked for in a file dialog, as many as wanted, when omitted.

ASK
path (str, PathLike or None)

The file to write, or a folder (one that exists, or a name ending in a separator) the report lands in under the run's own name. Beside the run when omitted. Several runs need a folder or None.

None
last float

Import only the last last seconds of the run; a shorter run is taken whole.

None
geometry str, os.PathLike, `visualdynamics.ASK` or None

A geometry file to import and link to the run. Asked for once for the whole batch when the run was asked for, Cancel meaning none; ASK asks even when the run was given.

None
photos str, os.PathLike or sequence of them

A folder of photographs, one photograph, or several in order.

None
per_octave int

Bands per octave for the banded PSD and specification; the project's default (a sixth) when omitted.

None
unit_system UnitSystem

The units the report is written in; the package default, 'in-slinch-lbf-s (g)', when omitted.

None
marking str

The banner across the top and bottom of every page; 'UNCLASSIFIED' when omitted.

None

Returns:

Type Description
str or list of str

The path written; a list of them when several runs were chosen or a list of runs was given.

Source code in src/visualdynamics/project.py
def random_vibration_report(run: Any = ASK,
                            path: str | os.PathLike | None = None, *,
                            last: float | None = None,
                            geometry: Any = None,
                            photos: Any = None,
                            per_octave: int | None = None,
                            unit_system: Any = None,
                            marking: str | None = None) -> Any:
    """A Rattlesnake random vibration run in, an HTML report out.

        visualdynamics.random_vibration_report('run.nc4', 'report.html')
        visualdynamics.random_vibration_report(
            'run.nc4', 'report.html', last=100.0,
            geometry='article.stp', photos='setup_photos/')
        visualdynamics.random_vibration_report()          # ask for both
        visualdynamics.random_vibration_report('run.nc4', 'reports/')

    **Left out, it asks.** Called with no run, it opens a file dialog
    and takes as many runs as are chosen, writing one report each and
    returning the list; a run chosen that way is asked about its
    geometry too, where Cancel means none. `visualdynamics.ASK` in
    either place forces the question, so a script with a run in hand
    can still be asked for the geometry. A run given without a
    geometry keyword means *no geometry*, as it always has, and
    nothing opens.

    **A folder is a folder.** `path` may be the file to write, or a
    folder the report lands in under the run's own name with an
    `.html` extension. Without a path it lands beside the run. Several
    runs need a folder or no path, never one file name.

    The whole workflow in one call, with nothing to click (Brandon,
    2026-09-19): import the run — or only its last `last` seconds, a
    shorter run taken whole — detect the averaging, compute the PSDs,
    band them and the specification onto octave bands, compute the
    multiple coherence, bring in the geometry and the photographs when
    they are given, generate the Random Vibration report and write it
    as one self-contained HTML file. Returns the path written, or the
    list of them when several runs were chosen. The keywords are
    `random_vibration_run`'s, plus `unit_system` for the units the
    report is written in.

    Everything it does is `random_vibration_run` followed by
    `generate_report` and `export_report`; reach for those instead when
    the project is wanted afterwards — to write the test summary, or to
    save it as `.vdyn`.

    Parameters
    ----------
    run : str, os.PathLike, sequence of them, or `visualdynamics.ASK`
        The controller's `.nc4`, or a list of them for a batch; asked for
        in a file dialog, as many as wanted, when omitted.
    path : str, os.PathLike or None, optional
        The file to write, or a folder (one that exists, or a name ending
        in a separator) the report lands in under the run's own name.
        Beside the run when omitted. Several runs need a folder or None.
    last : float, optional
        Import only the last `last` seconds of the run; a shorter run is
        taken whole.
    geometry : str, os.PathLike, `visualdynamics.ASK` or None, optional
        A geometry file to import and link to the run. Asked for once for
        the whole batch when the run was asked for, Cancel meaning none;
        `ASK` asks even when the run was given.
    photos : str, os.PathLike or sequence of them, optional
        A folder of photographs, one photograph, or several in order.
    per_octave : int, optional
        Bands per octave for the banded PSD and specification; the
        project's default (a sixth) when omitted.
    unit_system : UnitSystem, optional
        The units the report is written in; the package default,
        'in-slinch-lbf-s (g)', when omitted.
    marking : str, optional
        The banner across the top and bottom of every page;
        'UNCLASSIFIED' when omitted.

    Returns
    -------
    str or list of str
        The path written; a list of them when several runs were chosen or
        a list of runs was given.
    """
    # no length unit reaches the workup: the report draws the geometry
    # as a shape and never states a coordinate or a scale, so declaring
    # what the file's numbers meant changed one label and nothing else
    # (Brandon, 2026-09-22). The `_run` calls still take it, for a
    # project that lives on.
    return _one_call_reports(
        run, path, geometry, unit_system, 'random',
        lambda one, geo: random_vibration_run(one, per_octave, last=last,
                                              geometry=geo, photos=photos),
        marking=marking)