Skip to content

visualdynamics.core.report

report

Reports: an ordered document built from a project's objects.

A Report is a list of blocks (text the user wrote, plots, animated geometry scenes, tables, bar charts) that each name the object they draw from. A report holds references, never data. A reference is a literal object name, or a symbolic selector such as '@basis:Frf' that is resolved against the project's object groups when the report is rendered (resolve_binding). Because of the selectors, a saved report works as a template for the next project, whatever its objects are named. A block whose reference does not resolve stays unbound in the editor until it is pointed at a compatible object, and an unbound block is not rendered.

This module also holds the project types (PROJECT_TYPES), the objects each type expects (project_expectations), and the report template each type generates (PROJECT_TEMPLATES, TEMPLATE_BUILDERS). A Random and Sine project generates two reports, a random report and a sine report. A multiple-input random run's report (mimo_template) belongs to no project type and is generated by its name, 'mimo'.

Classes:

Name Description
Report

An ordered list of blocks, each a plain dict.

Functions:

Name Description
source_options

The names of the objects a block of this kind could draw from.

binding_tokens

Every symbolic token, as {token: (class, banded)}.

is_banded

Whether the object is on bands (an octave-band PSD or

binding_types

The types a symbolic binding can name, as {token: class}.

resolve_binding

The object name a binding field refers to.

binding_label

A readable label for a binding, such as 'Basis FRF (auto)' for

symbolic_options

The symbolic selectors this block could bind instead of a name.

photo_options

The photo names held by the Photos object a photo block is bound to.

shape_options

The names of the shape sets in objects.

base_quantity

The quantity a record's dimension names: 'acceleration' whether

record_dimensions

The quantities one data array's records hold.

time_dimensions

The quantities the project's time histories hold, sorted.

spectrum_dimensions

The quantities the project's spectra hold, sorted.

psd_dimensions

The quantities the project's PSDs hold, sorted.

series_dof_quantities

Every quantity the selected data measures at some DOF, sorted.

series_quantity_dofs

Every DOF the selected data measures as quantity.

dof_quantities

Every quantity the project measures at some DOF.

quantity_dofs

Every DOF in the whole project measured as quantity.

dofs_source_options

The data objects a DOFs scene could read its arrows from.

insert_options

The entries of the editor's Result drop-down, as [(kind, label)].

measured_options

The objects a responses-against-prediction block can read against

takes_shapes

Whether this block may name a shape set.

dof_references

References to the excitation and the response DOF figures

dof_sentence

The sentence on the DOF figures, naming only those there are:

instrumentation_text

The instrumentation section's text, naming only what the project holds.

project_expectations

The objects a report of this type draws from, as

missing_expectations

The expectations the project does not satisfy yet.

expectation_satisfiers

Which objects fill each of the type's Basis slots.

quantity_order

The quantities in dims in reading order.

time_data_blocks

The time plots for one history, one figure per quantity it measures.

kurtosis_block

A bar chart of the Pearson kurtosis of every channel of a record.

paired_density_blocks

Two densities overlaid, one figure per quantity they share.

front_matter

The blocks every report opens with, always in the same order.

modal_template

A modal test report from whatever the project holds.

shock_template

A shock test report: the transients, and the SRS against its target.

sine_template

A sine sweep test report: the tones, and the levels they reached.

sysid_template

A system identification report: the measured plant, and its quality.

transient_template

A transient replication report: the target waveform and the measured one.

control_channels_of

The control channels a specification names, as comparison figures label them.

control_channel_labels

The control channels of the specification that token binds.

channel_grid

The grid the random report lays many control channels out on.

prune_unbound

Remove the figures and sections the project cannot fill.

random_template

A random vibration report: the specification, the measurement, and how they differ.

mimo_template

A multiple-input random vibration report: the control matrix, its

Classes

Report

Report(title: str = 'Report', blocks: Sequence[Mapping[str, Any]] | None = None, marking: str = 'UNCLASSIFIED', marking_color: str = 'ink', features: Sequence[str] | None = None, identity: Mapping[str, Any] | None = None)

An ordered list of blocks, each a plain dict.

title is the report's heading. marking is the classification marking drawn as a banner across the top and bottom of the rendered document, 'UNCLASSIFIED' by default; an empty string draws no banner. marking_color is 'ink' (the page's own text color) or 'red'; any other value is read as 'ink'. features holds the keys of the report features enabled on it (visualdynamics.report.features), none by default; with none, the report renders as it always has. identity holds the author's identification fields and the issue record (visualdynamics.report.identity), empty by default; it is drawn only while the report feature report_identity is on. A block of a kind not in BLOCK_KINDS raises ValueError.

Block shapes: {'kind': 'text', 'text': str} (a text block carrying 'section' holds the author's words for one section of the skeleton, and 'by' who interpreted, in the discussion; it is drawn only while the report feature skeleton_sections is on: visualdynamics.report.skeleton. The deviations section's block may also carry 'findings', the findings register, drawn only while the report feature findings is on: visualdynamics.report.findings) {'kind': 'plot', 'source': name, 'mode': curves|cmif|mac|map|stage|scalogram| pair|ratio|overlay, 'select': ''|drive|dim:, 'component': magnitude|real|imag|phase, 'caption': str} (a plot may also carry 'specification', the requirement drawn with the curves; 'channel', the one control channel to open on; 'grid', to lay every control channel out on a grid; 'tone', one tone of a sine level set; 'floor', the ambient density for 'pair' and 'ratio'; 'shapes'; 'octave', to band the data for the comparison; 'averaging': False to leave the averaging frames off a time plot; and 'record_caption': True to draw, under a figure of a measured spectrum, the caption block read off its record, Project.caption_of) {'kind': 'scene', 'geometry': name, 'shapes': name-or-'', 'dofs': quantity, 'dofs_source': name, 'caption': str} ('dofs' + 'dofs_source' make a DOFs scene: labeled arrows at every DOF that one data object measures as that quantity) {'kind': 'table', 'source': name, 'caption': str} {'kind': 'photo', 'source': Photos-name, 'photo': photo-name, 'caption': str} {'kind': 'pairs', 'source': MatchedModes-name, 'caption': str} (the matched-modes table: the committed matches, both sets' parameters side by side) {'kind': 'overlay', 'source': MatchedModes-name, 'caption': str} (the matched pairs animated over each other: both sets' geometries in one scene, resolved through the object's own geometry references, each pair phase-aligned) {'kind': 'bars', 'source': name, 'measured': name, 'mode': error|lines|srs|waveform|kurtosis|sine|snr, 'caption': str} (a bar chart, one bar per channel: 'error' is each control channel's RMS error against the specification in decibels and 'lines' the share of its band outside the abort limits; a shock specification gives SRS deviations and a transient specification waveform errors; 'kurtosis' reads one record with no 'measured'; 'sine' compares every sine level in the project with its tones; 'snr' is the RMS signal-to-noise per channel, with 'floor' naming the ambient Psd) {'kind': 'verdict', 'source': Specification-name, 'measured': Psd-name, 'level_label': str} (the pass/fail box: whether the run passed, read off the octave-band comparison of measured against source (compliance.verdict), with the test level it was judged at beside it under level_label, 'Test level' when absent) {'kind': 'prediction', 'source': Predictions-name, 'measured': Psd-, Spectrum- or Frf-name, 'level': float or None, 'caption': str} (each response channel of measured against its prediction scaled to level, the dB relative to full level it was run at: a figure per channel and a table of peaks; drawn only while the report feature prediction is on: visualdynamics.report.prediction) {'kind': 'text', 'text': '', 'requirement': dict, 'source': Specification-name, 'measured': Psd-name, 'duration_run': float, 'caption': str} (a test requirement bound to a run, its values as core.requirement.TestRequirement.as_record gives them: the requirement in words and a result table, and the pass/fail box for the same pair read from it; drawn only while the report feature test_requirement is on: visualdynamics.report.requirement)

'select' filters a curves plot to the records the GUI's own filters would pick: 'drive' is the response==reference diagonal of an FRF, and 'dim:' keeps only records of that quantity, which is how a time history is split into Time (Acceleration) and Time (Force). 'map' is the coherence map, with frequency across, channel down and the color scale fixed at 0 to 1.

Methods:

Name Description
add

Add a copy of block at index at, or at the end.

remove

Remove the blocks at indices; an index out of range is ignored.

move

Move the blocks at rows, in order, to just before the block at to.

unbound

The indices of blocks whose references the project cannot resolve.

Attributes:

Name Type Description
identity dict[str, Any]

The report's identification and issue record: plain values,

features list[str]

The keys of the report features enabled on this report.

num_blocks int

How many blocks the report holds.

Source code in src/visualdynamics/core/report.py
def __init__(self, title: str = 'Report',
             blocks: Sequence[Mapping[str, Any]] | None = None,
             marking: str = 'UNCLASSIFIED',
             marking_color: str = 'ink',
             features: Sequence[str] | None = None,
             identity: Mapping[str, Any] | None = None) -> None:
    self.title: str = str(title)
    #: the classification marking, carried as a banner across the
    #: top and bottom of the rendered document — one value, two
    #: places, because markings that can disagree are markings that
    #: will. 'UNCLASSIFIED' by default: work that carries markings
    #: is the intended use, and a document that says its level
    #: outright beats one that says nothing. Empty means unmarked —
    #: no banner at all.
    self.marking: str = str(marking)
    #: 'ink' draws the banner in the page's own black-or-white;
    #: 'red' is the conventional color for the levels that demand
    #: attention
    self.marking_color: str = (marking_color
                               if marking_color in ('ink', 'red')
                               else 'ink')
    self.blocks: list[dict[str, Any]] = [
        dict(block) for block in (blocks or [])]
    for block in self.blocks:
        if block.get('kind') not in BLOCK_KINDS:
            raise ValueError(f"unknown block kind {block.get('kind')!r}")
    self.features = features or []
    self.identity = identity or {}
Attributes
identity property writable
identity: dict[str, Any]

The report's identification and issue record: plain values, empty by default.

What the keys mean, and the only way they change in step (the fields entered, the report issued, an amendment made), is visualdynamics.report.identity. Assigning stores a copy and refuses anything but a mapping with TypeError.

features property writable
features: list[str]

The keys of the report features enabled on this report.

Empty by default. Assigning keeps each key once, in the order given, and refuses anything but strings with TypeError. Which keys are known, available and allowed together is checked where features are chosen (visualdynamics.report.features.configure, Project.report_features); a key that is not known does nothing when the report is rendered, so a file written by a later version keeps its choice through this one.

num_blocks property
num_blocks: int

How many blocks the report holds.

Methods:
add
add(block: Mapping[str, Any], at: int | None = None) -> None

Add a copy of block at index at, or at the end.

Raises ValueError for a block kind not in BLOCK_KINDS.

Source code in src/visualdynamics/core/report.py
def add(self, block: Mapping[str, Any], at: int | None = None) -> None:
    """Add a copy of `block` at index `at`, or at the end.

    Raises `ValueError` for a block kind not in `BLOCK_KINDS`.
    """
    if block.get('kind') not in BLOCK_KINDS:
        raise ValueError(f"unknown block kind {block.get('kind')!r}")
    if at is None:
        self.blocks.append(dict(block))
    else:
        self.blocks.insert(int(at), dict(block))
remove
remove(indices: Sequence[int]) -> None

Remove the blocks at indices; an index out of range is ignored.

Source code in src/visualdynamics/core/report.py
def remove(self, indices: Sequence[int]) -> None:
    """Remove the blocks at `indices`; an index out of range is ignored."""
    doomed = {int(i) for i in indices}
    self.blocks = [block for i, block in enumerate(self.blocks)
                   if i not in doomed]
move
move(rows: Sequence[int], to: int) -> None

Move the blocks at rows, in order, to just before the block at to.

A to past the end moves them to the end. This is the drop half of drag-to-reorder.

Source code in src/visualdynamics/core/report.py
def move(self, rows: Sequence[int], to: int) -> None:
    """Move the blocks at `rows`, in order, to just before the block at `to`.

    A `to` past the end moves them to the end. This is the drop
    half of drag-to-reorder.
    """
    rows = sorted({int(r) for r in rows})
    lifted = [self.blocks[r] for r in rows]
    rest = [block for i, block in enumerate(self.blocks)
            if i not in set(rows)]
    to = int(to) - sum(1 for r in rows if r < to)
    to = max(0, min(to, len(rest)))
    self.blocks = rest[:to] + lifted + rest[to:]
unbound
unbound(objects: Mapping[str, Any], object_groups: Sequence[Mapping[str, Any]] | None = None) -> list[int]

The indices of blocks whose references the project cannot resolve.

A reference is unresolved when it is a name that is not in objects, or a symbolic selector with nothing to pick. The 'source', 'geometry', 'dofs_source' and 'shapes' fields are checked.

Source code in src/visualdynamics/core/report.py
def unbound(self, objects: Mapping[str, Any],
            object_groups: Sequence[Mapping[str, Any]] | None = None) -> list[int]:
    """The indices of blocks whose references the project cannot resolve.

    A reference is unresolved when it is a name that is not in
    `objects`, or a symbolic selector with nothing to pick. The
    'source', 'geometry', 'dofs_source' and 'shapes' fields are
    checked.
    """
    out = []
    for i, block in enumerate(self.blocks):
        needed = [block.get(key)
                  for key in ('source', 'geometry', 'dofs_source',
                              'shapes')
                  if block.get(key)]
        if any(resolve_binding(name, objects, object_groups) not in objects
               for name in needed):
            out.append(i)
    return out

Functions:

source_options

source_options(block: Mapping[str, Any], objects: Mapping[str, Any]) -> list[str]

The names of the objects a block of this kind could draw from.

Source code in src/visualdynamics/core/report.py
def source_options(block: Mapping[str, Any], objects: Mapping[str, Any]) -> list[str]:
    """The names of the objects a block of this kind could draw from."""
    wanted = _wanted_classes(block)
    if wanted is None:
        return []
    return [name for name, obj in objects.items()
            if isinstance(obj, wanted)]

binding_tokens

binding_tokens() -> dict[str, tuple[type, bool | None]]

Every symbolic token, as {token: (class, banded)}.

The plain types have banded None, meaning not banded, and the banded tokens ('OctavePsd', 'OctaveSpecification') have True.

Source code in src/visualdynamics/core/report.py
def binding_tokens() -> dict[str, tuple[type, bool | None]]:
    """Every symbolic token, as {token: (class, banded)}.

    The plain types have `banded` None, meaning not banded, and the
    banded tokens ('OctavePsd', 'OctaveSpecification') have True.
    """
    types = binding_types()
    out: dict[str, tuple[type, bool | None]] = {
        token: (cls, None) for token, cls in types.items()}
    for token, plain in BANDED_TOKENS.items():
        out[token] = (types[plain], True)
    return out

is_banded

is_banded(obj: Any) -> bool

Whether the object is on bands (an octave-band PSD or specification) instead of on frequency lines.

Source code in src/visualdynamics/core/report.py
def is_banded(obj: Any) -> bool:
    """Whether the object is on bands (an octave-band PSD or
    specification) instead of on frequency lines."""
    return getattr(obj, 'bandwidth', None) is not None

binding_types

binding_types() -> dict[str, type]

The types a symbolic binding can name, as {token: class}.

Source code in src/visualdynamics/core/report.py
def binding_types() -> dict[str, type]:
    """The types a symbolic binding can name, as {token: class}."""
    from .channel_table import ChannelTable
    from .data import (
        Frf,
        MultipleCoherence,
        Psd,
        ShockSpecification,
        Specification,
        Spectrum,
        Srs,
        TimeHistory,
        TransientSpecification,
        _CoherenceBase,
    )
    from .geometry import Geometry
    from .matches import MatchedModes
    from .photos import Photos
    from .prediction import Predictions
    from .shapes import ShapeSet
    from .sine import SineLevel, SineLevelSet, SineSweepSpecification

    return {'Geometry': Geometry, 'Photos': Photos,
            'ChannelTable': ChannelTable, 'TimeHistory': TimeHistory,
            'Spectrum': Spectrum, 'Psd': Psd, 'Frf': Frf,
            'Specification': Specification,
            'Srs': Srs, 'ShockSpecification': ShockSpecification,
            'TransientSpecification': TransientSpecification,
            'SineSweepSpecification': SineSweepSpecification,
            'SineLevel': SineLevel,
            'SineLevelSet': SineLevelSet,
            'Coherence': _CoherenceBase,
            'MultipleCoherence': MultipleCoherence,
            'ShapeSet': ShapeSet, 'MatchedModes': MatchedModes,
            'Predictions': Predictions}

resolve_binding

resolve_binding(value: str | None, objects: Mapping[str, Any], object_groups: Sequence[Mapping[str, Any]] | None = None) -> str | None

The object name a binding field refers to.

A literal name is returned unchanged (an empty one as None). A symbolic selector such as '@basis:Frf', '@other:ShapeSet' or '@any:MatchedModes' is resolved against the project when the report is rendered, so a report does not depend on what the objects are named: 'basis' is the first object of the type in the Basis object group, 'other' the first outside it, and 'any' the first anywhere. Without a Basis group, 'basis' is the first of the type and 'other' the second, so a small project with no groups still renders.

A specification never answers for the measurement it bounds: '@basis:Psd' never returns a Specification. A banded object answers only to its own token ('@basis:OctavePsd'), and a more specific kind of the same measurement (multiple coherence for 'Coherence') is ranked after the objects of the exact kind. Returns the name, or None when nothing fits or the selector is not one of the known roles and tokens.

Source code in src/visualdynamics/core/report.py
def resolve_binding(value: str | None, objects: Mapping[str, Any],
                    object_groups: Sequence[Mapping[str, Any]] | None = None) -> str | None:
    """The object name a binding field refers to.

    A literal name is returned unchanged (an empty one as None). A
    symbolic selector such as '@basis:Frf', '@other:ShapeSet' or
    '@any:MatchedModes' is resolved against the project when the report
    is rendered, so a report does not depend on what the objects are
    named: 'basis' is the first object of the type in the Basis object
    group, 'other' the first outside it, and 'any' the first anywhere.
    Without a Basis group, 'basis' is the first of the type and 'other'
    the second, so a small project with no groups still renders.

    A specification never answers for the measurement it bounds:
    '@basis:Psd' never returns a Specification. A banded object answers
    only to its own token ('@basis:OctavePsd'), and a more specific
    kind of the same measurement (multiple coherence for 'Coherence')
    is ranked after the objects of the exact kind. Returns the name, or
    None when nothing fits or the selector is not one of the known
    roles and tokens.
    """
    value = str(value or '')
    if not value.startswith('@'):
        return value or None
    role, _, token = value[1:].partition(':')
    cls, banded = binding_tokens().get(token, (None, None))
    if cls is None or role not in ('basis', 'other', 'any'):
        return None
    # A token never returns what a more specific token would claim —
    # with one distinction, learned one half at a time. A *bound* never
    # answers for the measurement it bounds: Specification subclasses
    # Psd, so '@basis:Psd' was answering with the specification, and a
    # report comparing the control PSDs against their specification
    # quietly compared it against itself, drew one curve where there
    # should be two, and reported a perfect match whatever the run had
    # done. But a *flavor* of the same measurement is only preferred
    # against, not refused: multiple coherence is coherence with the
    # references summed over, the skeleton's one Coherence slot takes
    # either kind, and refusing it cost a Rattlesnake test's report its
    # coherence figure with no word said — every figure after it
    # quietly renumbered.
    from .data import Bounded, TransientSpecification

    narrower = [other for other in binding_types().values()
                if other is not cls and issubclass(other, cls)]
    bounds = tuple(other for other in narrower
                   if issubclass(other, (Bounded, TransientSpecification)))
    flavors = tuple(other for other in narrower if other not in bounds)
    # ordered, not filtered: the plain kind first, flavors after, so
    # every role below prefers exact and degrades to the flavor
    # and a banded flavor answers only to its own token: '@basis:Psd'
    # is the narrowband PSD, '@basis:OctavePsd' the banded one
    names = sorted(
        (name for name, obj in objects.items()
         if isinstance(obj, cls)
         and not any(isinstance(obj, kind) for kind in bounds)
         and is_banded(obj) == bool(banded)),
        key=lambda name: isinstance(objects[name], flavors))
    basis = next((set(group['members']) for group in (object_groups or [])
                  if group.get('role') == 'Basis'), None)
    if role == 'basis':
        if basis is None:
            return names[0] if names else None
        inside = [name for name in names if name in basis]
        return inside[0] if inside else None
    if role == 'other':
        if basis is None:
            return names[1] if len(names) > 1 else None
        outside = [name for name in names if name not in basis]
        return outside[0] if outside else None
    return names[0] if names else None

binding_label

binding_label(value: str) -> str

A readable label for a binding, such as 'Basis FRF (auto)' for '@basis:Frf'. A literal name is returned as it is.

Source code in src/visualdynamics/core/report.py
def binding_label(value: str) -> str:
    """A readable label for a binding, such as 'Basis FRF (auto)' for
    '@basis:Frf'. A literal name is returned as it is."""
    value = str(value)
    if not value.startswith('@'):
        return value
    role, _, token = value[1:].partition(':')
    spaced = {'Frf': 'FRF', 'Psd': 'PSD', 'OctavePsd': 'Octave PSD'}.get(
        token, re.sub(r'(?<!^)(?=[A-Z])', ' ', token))
    return f'{role.title()} {spaced} (auto)'

symbolic_options

symbolic_options(block: Mapping[str, Any]) -> list[tuple[str, str]]

The symbolic selectors this block could bind instead of a name.

Returned as [(selector, label)], a basis and an other selector for each token that fits the block. A selector keeps a report independent of what the objects are named.

Source code in src/visualdynamics/core/report.py
def symbolic_options(block: Mapping[str, Any]) -> list[tuple[str, str]]:
    """The symbolic selectors this block could bind instead of a name.

    Returned as [(selector, label)], a basis and an other selector for
    each token that fits the block. A selector keeps a report
    independent of what the objects are named.
    """
    wanted = _wanted_classes(block)
    if wanted is None:
        return []
    classes = wanted if isinstance(wanted, tuple) else (wanted,)
    from .data import DataArray
    if any(cls is DataArray for cls in classes):
        # a plain curves plot could draw from anything; offer the
        # useful few rather than every subclass
        tokens = ['TimeHistory', 'Spectrum', 'Psd', 'OctavePsd', 'Frf']
    else:
        tokens = [token for token, (cls, _banded) in binding_tokens().items()
                  if any(cls is wanted_cls for wanted_cls in classes)]
    return [(f'@{role}:{token}', binding_label(f'@{role}:{token}'))
            for token in tokens for role in ('basis', 'other')]

photo_options

photo_options(block: Mapping[str, Any], objects: Mapping[str, Any], object_groups: Sequence[Mapping[str, Any]] | None = None) -> list[str]

The photo names held by the Photos object a photo block is bound to.

Source code in src/visualdynamics/core/report.py
def photo_options(block: Mapping[str, Any], objects: Mapping[str, Any],
                  object_groups: Sequence[Mapping[str, Any]] | None = None) -> list[str]:
    """The photo names held by the Photos object a photo block is bound to."""
    from .photos import Photos

    owner = objects.get(
        resolve_binding(block.get('source'), objects, object_groups) or '')
    return list(owner.names) if isinstance(owner, Photos) else []

shape_options

shape_options(objects: Mapping[str, Any]) -> list[str]

The names of the shape sets in objects.

Source code in src/visualdynamics/core/report.py
def shape_options(objects: Mapping[str, Any]) -> list[str]:
    """The names of the shape sets in `objects`."""
    from .shapes import ShapeSet

    return [name for name, obj in objects.items()
            if isinstance(obj, ShapeSet)]

base_quantity

base_quantity(dim: str) -> str

The quantity a record's dimension names: 'acceleration' whether the record holds accelerations or their square per hertz.

Source code in src/visualdynamics/core/report.py
def base_quantity(dim: str) -> str:
    """The quantity a record's dimension names: 'acceleration' whether
    the record holds accelerations or their square per hertz."""
    base = dim.partition('/')[0]
    return base.removesuffix('**2')

record_dimensions

record_dimensions(obj: Any) -> set[str]

The quantities one data array's records hold.

Unknown dimensions and cross terms (a CPSD's acceleration*force) are left out.

Source code in src/visualdynamics/core/report.py
def record_dimensions(obj: Any) -> set[str]:
    """The quantities one data array's records hold.

    Unknown dimensions and cross terms (a CPSD's acceleration*force)
    are left out.
    """
    from ..units import UNKNOWN

    dims = {base_quantity(obj.known_dim(i))
            for i in range(obj.num_records)}
    dims.discard(UNKNOWN)
    return {dim for dim in dims if '*' not in dim}

time_dimensions

time_dimensions(objects: Mapping[str, Any]) -> list[str]

The quantities the project's time histories hold, sorted.

These are the insert menu's Time (…) entries.

Source code in src/visualdynamics/core/report.py
def time_dimensions(objects: Mapping[str, Any]) -> list[str]:
    """The quantities the project's time histories hold, sorted.

    These are the insert menu's Time (…) entries.
    """
    from .data import TimeHistory

    return _held_dimensions(objects, TimeHistory)

spectrum_dimensions

spectrum_dimensions(objects: Mapping[str, Any]) -> list[str]

The quantities the project's spectra hold, sorted.

These are the insert menu's Spectra (…) entries.

Source code in src/visualdynamics/core/report.py
def spectrum_dimensions(objects: Mapping[str, Any]) -> list[str]:
    """The quantities the project's spectra hold, sorted.

    These are the insert menu's Spectra (…) entries.
    """
    from .data import Spectrum

    return _held_dimensions(objects, Spectrum)

psd_dimensions

psd_dimensions(objects: Mapping[str, Any]) -> list[str]

The quantities the project's PSDs hold, sorted.

These are the insert menu's PSD (…) entries.

Source code in src/visualdynamics/core/report.py
def psd_dimensions(objects: Mapping[str, Any]) -> list[str]:
    """The quantities the project's PSDs hold, sorted.

    These are the insert menu's PSD (…) entries.
    """
    from .data import Psd

    return _held_dimensions(objects, Psd)

series_dof_quantities

series_dof_quantities(series: Sequence[tuple[str, Any, Sequence[int] | None]]) -> list[str]

Every quantity the selected data measures at some DOF, sorted.

series is [(name, data, records)], with records None for the whole object, the same form the GUI's selection uses. References count too: an FRF per unit force names its force DOFs even when no force channel was kept.

Source code in src/visualdynamics/core/report.py
def series_dof_quantities(
        series: Sequence[tuple[str, Any, Sequence[int] | None]]
        ) -> list[str]:
    """Every quantity the selected data measures at some DOF, sorted.

    `series` is [(name, data, records)], with records None for the
    whole object, the same form the GUI's selection uses. References
    count too: an FRF per unit force names its force DOFs even when no
    force channel was kept.
    """
    from ..units import UNKNOWN

    dims = set()
    for _name, data, records in series:
        wanted = records if records is not None else range(data.num_records)
        for i in wanted:
            dim = data.known_dim(i)
            dims.add(base_quantity(dim))
            denominator = dim.partition('/')[2]
            if denominator and data.reference_dof is not None:
                dims.add(denominator)
    dims -= {UNKNOWN, 'frequency', 'dimensionless'}
    return sorted(dim for dim in dims if '*' not in dim)

series_quantity_dofs

series_quantity_dofs(series: Sequence[tuple[str, Any, Sequence[int] | None]], quantity: str) -> list[str]

Every DOF the selected data measures as quantity.

These are the response DOFs of records holding it and the reference DOFs of records referenced to it, each once, ordered by node. series is as for series_dof_quantities.

Source code in src/visualdynamics/core/report.py
def series_quantity_dofs(
        series: Sequence[tuple[str, Any, Sequence[int] | None]],
        quantity: str) -> list[str]:
    """Every DOF the selected data measures as `quantity`.

    These are the response DOFs of records holding it and the reference
    DOFs of records referenced to it, each once, ordered by node.
    `series` is as for `series_dof_quantities`.
    """
    from .data import parse_dof

    dofs = []
    for _name, data, records in series:
        wanted = records if records is not None else range(data.num_records)
        for i in wanted:
            dim = data.known_dim(i)
            if base_quantity(dim) == quantity:
                dofs.append(data.response_dof[i])
            if (dim.partition('/')[2] == quantity
                    and data.reference_dof is not None):
                dofs.append(data.reference_dof[i])

    def order(dof: str) -> tuple[int, str]:
        node, direction = parse_dof(dof)
        return (node if node is not None else -1, direction)

    return sorted(set(dofs), key=order)

dof_quantities

dof_quantities(objects: Mapping[str, Any]) -> list[str]

Every quantity the project measures at some DOF.

These are the insert menu's DOFs (…) entries.

Source code in src/visualdynamics/core/report.py
def dof_quantities(objects: Mapping[str, Any]) -> list[str]:
    """Every quantity the project measures at some DOF.

    These are the insert menu's DOFs (…) entries.
    """
    return series_dof_quantities(_whole_project_series(objects))

quantity_dofs

quantity_dofs(objects: Mapping[str, Any], quantity: str) -> list[str]

Every DOF in the whole project measured as quantity.

Source code in src/visualdynamics/core/report.py
def quantity_dofs(objects: Mapping[str, Any], quantity: str) -> list[str]:
    """Every DOF in the whole project measured as `quantity`."""
    return series_quantity_dofs(_whole_project_series(objects), quantity)

dofs_source_options

dofs_source_options(block: Mapping[str, Any], objects: Mapping[str, Any]) -> list[str]

The data objects a DOFs scene could read its arrows from.

These are the objects that measure the block's quantity at some DOF.

Source code in src/visualdynamics/core/report.py
def dofs_source_options(block: Mapping[str, Any], objects: Mapping[str, Any]) -> list[str]:
    """The data objects a DOFs scene could read its arrows from.

    These are the objects that measure the block's quantity at some
    DOF.
    """
    from .data import DataArray

    quantity = block.get('dofs', '')
    return [name for name, obj in objects.items()
            if isinstance(obj, DataArray)
            and quantity in series_dof_quantities([(name, obj, None)])]

insert_options

insert_options(objects: Mapping[str, Any]) -> list[tuple[str, str]]

The entries of the editor's Result drop-down, as [(kind, label)].

The DOFs, Time, Spectra and PSD entries are one per quantity, and only for the quantities the project's data holds.

Source code in src/visualdynamics/core/report.py
def insert_options(objects: Mapping[str, Any]) -> list[tuple[str, str]]:
    """The entries of the editor's Result drop-down, as [(kind, label)].

    The DOFs, Time, Spectra and PSD entries are one per quantity, and
    only for the quantities the project's data holds.
    """
    return ([('verdict', 'Pass/Fail Box'),
             ('geometry', 'Geometry'),
             ('modes', 'Mode Shape Animations')]
            + [(f'dofs:{quantity}', f'DOFs ({quantity.title()})')
               for quantity in dof_quantities(objects)]
            + [('photos', 'Photos'),
             ('channel_table', 'Channel Table'),
             ('cmif', 'CMIF')]
            + [(f'time:{dim}', f'Time ({dim.title()})')
               for dim in time_dimensions(objects)]
            + [(f'spectrum:{dim}', f'Spectra ({dim.title()})')
               for dim in spectrum_dimensions(objects)]
            + [(f'psd:{dim}', f'PSD ({dim.title()})')
               for dim in psd_dimensions(objects)]
            + [('coherence', 'Coherence'),
               ('stage', '3-D Stage'),
               ('mac', 'Auto-MAC'),
               ('matches', 'Matched Modes'),
               ('overlay', 'Matched Mode Animations')]
            + [(f'frf_drive:{component}', f'FRF - Drive Point - {label}')
               for component, label in (('magnitude', 'Magnitude'),
                                        ('real', 'Real'),
                                        ('imag', 'Imaginary'),
                                        ('phase', 'Phase'))]
            # read only by the report feature `prediction`, and only
            # once the project holds predictions to read against
            + ([('prediction', 'Responses Against Prediction')]
               if _holds_predictions(objects) else []))

measured_options

measured_options(objects: Mapping[str, Any]) -> list[str]

The objects a responses-against-prediction block can read against predictions: narrowband PSDs, amplitude spectra and sine levels, and FRFs, never a specification or an octave-band PSD.

Source code in src/visualdynamics/core/report.py
def measured_options(objects: Mapping[str, Any]) -> list[str]:
    """The objects a responses-against-prediction block can read against
    predictions: narrowband PSDs, amplitude spectra and sine levels,
    and FRFs, never a specification or an octave-band PSD."""
    from .data import Bounded, Frf, Psd, Spectrum, TransientSpecification

    return [name for name, obj in objects.items()
            if isinstance(obj, (Psd, Spectrum, Frf))
            and not isinstance(obj, (Bounded, TransientSpecification))
            and not is_banded(obj)]

takes_shapes

takes_shapes(block: Mapping[str, Any]) -> bool

Whether this block may name a shape set.

A scene animates one, a CMIF overlays its resynthesis, a MAC is computed against it, a drive point plot marks its frequencies, and the matched-modes table takes one too.

Source code in src/visualdynamics/core/report.py
def takes_shapes(block: Mapping[str, Any]) -> bool:
    """Whether this block may name a shape set.

    A scene animates one, a CMIF overlays its resynthesis, a MAC is
    computed against it, a drive point plot marks its frequencies, and
    the matched-modes table takes one too.
    """
    return (block.get('kind') in ('scene', 'pairs')
            or (block.get('kind') == 'plot'
                and (block.get('mode') in ('cmif', 'mac')
                     or block.get('select') == 'drive')))

dof_references

dof_references(objects: Mapping[str, Any], object_groups: Sequence[Mapping[str, Any]] | None, dofs_source: str) -> tuple[str, str]

References to the excitation and the response DOF figures front_matter builds for dofs_source, '' for a role with none.

A run controlled on accelerometers alone has no force channels and so no excitation figure; a reference to one would print as written. Two excitation figures (force and voltage) are referred to together. A project with no geometry draws no DOF figures at all. With no project behind the template, the references are the generic ones the outline is read with.

Source code in src/visualdynamics/core/report.py
def dof_references(objects: Mapping[str, Any],
                   object_groups: Sequence[Mapping[str, Any]] | None,
                   dofs_source: str) -> tuple[str, str]:
    """References to the excitation and the response DOF figures
    `front_matter` builds for `dofs_source`, '' for a role with none.

    A run controlled on accelerometers alone has no force channels and
    so no excitation figure; a reference to one would print as written.
    Two excitation figures (force and voltage) are referred to
    together. A project with no geometry draws no DOF figures at all.
    With no project behind the template, the references are the
    generic ones the outline is read with.
    """
    # Brandon, 2026-10-27: a random report printed
    # '({{figure:Excitation degrees of freedom (force)}})' for a run with
    # no force channels; the prose named a figure only the scenes knew
    # had not been built. The references now come from the same blocks.
    if not objects:
        return ('{{figure:Excitation degrees of freedom}}',
                '{{figure:Response degrees of freedom}}')
    if resolve_binding('@basis:Geometry', objects,
                       object_groups) not in objects:
        return '', ''
    captions: dict[bool, list[str]] = {True: [], False: []}
    for block in front_matter(objects, object_groups, dofs_source):
        if block['kind'] == 'scene' and block.get('dofs'):
            captions[block['dofs'] in EXCITATION_QUANTITIES].append(
                '{{figure:' + block['caption'] + '}}')
    return ' and '.join(captions[True]), ' and '.join(captions[False])

dof_sentence

dof_sentence(excitation: str, response: str, driven: str, measured: str) -> str

The sentence on the DOF figures, naming only those there are: where and driven for the excitation, measured for the response. Starts with a space so it follows the sentence before it; '' when there is neither.

Source code in src/visualdynamics/core/report.py
def dof_sentence(excitation: str, response: str, driven: str,
                 measured: str) -> str:
    """The sentence on the DOF figures, naming only those there are:
    where and `driven` for the excitation, `measured` for the response.
    Starts with a space so it follows the sentence before it; '' when
    there is neither."""
    clauses = []
    if excitation:
        clauses.append(f'the excitation degrees of freedom ({excitation}) '
                       f'mark where and {driven}')
    if response:
        clauses.append(f'the response degrees of freedom ({response}) '
                       f'mark {measured}')
    if not clauses:
        return ''
    text = '; '.join(clauses)
    return ' ' + text[0].upper() + text[1:] + '.'

instrumentation_text

instrumentation_text(objects: Mapping[str, Any], object_groups: Sequence[Mapping[str, Any]] | None = None, dofs_source: str = '@basis:TimeHistory') -> str

The instrumentation section's text, naming only what the project holds.

The text refers to the geometry, the DOF figures and the channel table. A run imported without a geometry has no geometry or DOF figures, and a paragraph about "the test geometry" above a channel table alone reads as a report with parts missing; the text is then the channel table's sentence, and the photographs' sentence when there are photographs. With a geometry, it names the DOF figures built for dofs_source (dof_references). An empty project keeps the standard text, since there the template itself is being read.

Source code in src/visualdynamics/core/report.py
def instrumentation_text(objects: Mapping[str, Any],
                         object_groups: Sequence[Mapping[str, Any]] | None = None,
                         dofs_source: str = '@basis:TimeHistory') -> str:
    """The instrumentation section's text, naming only what the project holds.

    The text refers to the geometry, the DOF figures and the channel
    table. A run imported without a geometry has no geometry or DOF
    figures, and a paragraph about "the test geometry" above a channel
    table alone reads as a report with parts missing; the text is then
    the channel table's sentence, and the photographs' sentence when
    there are photographs. With a geometry, it names the DOF figures
    built for `dofs_source` (`dof_references`). An empty project keeps
    the standard text, since there the template itself is being read.
    """
    # Brandon, 2026-09-19: no paragraph about a geometry the run does
    # not have.
    if not objects:
        return _INSTRUMENTATION_TEXT
    if resolve_binding('@basis:Geometry', objects, object_groups) in objects:
        excitation, response = dof_references(objects, object_groups,
                                              dofs_source)
        return ('## Test Article and Instrumentation\n\nThe test geometry '
                '({{figure:Test geometry and measurement locations}}) '
                'shows where the article was instrumented.'
                + dof_sentence(excitation, response,
                               'in which direction it was driven',
                               'every measured accelerometer direction')
                + ' The channel table ({{table:Instrumentation}}) lists '
                'the instrumentation at each location.')
    photos = resolve_binding('@basis:Photos', objects, object_groups)
    sentences = []
    if photos in objects and getattr(objects[photos], 'names', None):
        sentences.append('The photographs record the article as it was '
                         'instrumented.')
    sentences.append('The channel table ({{table:Instrumentation}}) '
                     'lists the instrumentation at each location.')
    return ('## Test Article and Instrumentation\n\n'
            + ' '.join(sentences))

project_expectations

project_expectations(project_type: str) -> list[tuple[str, type, str, int, bool, str | None]]

The objects a report of this type draws from, as [(label, class, icon key, count, optional, side)].

These are the gray slots a typed project shows until they are filled. The label is the display name of the class the slot waits for, so a skeleton reads the same in every project type. A type that expects several objects of one class shows the same label several times, and count says which instance (first, second, ...) fills which slot. The side is 'Basis', OTHER_SIDE, or None for a slot on neither side (the report, the matched modes). A project knows its Basis, and everything else is on the other side; the other side is a place in the tree and not a role, so its group has no name and objects are never guessed into it, since a second test is as good a comparison as a model. optional marks the recommended slots a report can be rendered without. An unknown project type gives an empty list.

Every type ends with the report itself. The report is what the other objects exist to produce, and a project that has everything a report needs but no report is not finished, so it is a slot like the rest, gray until it is generated. It is in no object group, because a report reads the whole project. A Random and Sine project has two report slots, one for the random report and one for the sine report.

Source code in src/visualdynamics/core/report.py
def project_expectations(
        project_type: str
        ) -> list[tuple[str, type, str, int, bool, str | None]]:
    """The objects a report of this type draws from, as
    [(label, class, icon key, count, optional, side)].

    These are the gray slots a typed project shows until they are
    filled. The label is the display name of the class the slot waits
    for, so a skeleton reads the same in every project type. A type
    that expects several objects of one class shows the same label
    several times, and `count` says which instance (first, second, ...)
    fills which slot. The side is 'Basis', `OTHER_SIDE`, or None for a
    slot on neither side (the report, the matched modes). A project
    knows its Basis, and everything else is on the other side; the
    other side is a place in the tree and not a role, so its group has
    no name and objects are never guessed into it, since a second test
    is as good a comparison as a model. `optional` marks the
    recommended slots a report can be rendered without. An unknown
    project type gives an empty list.

    Every type ends with the report itself. The report is what the
    other objects exist to produce, and a project that has everything a
    report needs but no report is not finished, so it is a slot like
    the rest, gray until it is generated. It is in no object group,
    because a report reads the whole project. A Random and Sine project
    has two report slots, one for the random report and one for the
    sine report.
    """
    # Every slot named for the object it waits for (Brandon,
    # 2026-09-02). The FEM tag that once named the other side's pair
    # was removed the same day.
    from ..names import display_name

    # Each entry's first field says, for the reader of this list, which
    # object the slot is for ('Octave Band PSD', 'Noise Time History').
    # It is not shown and nothing matches on it: the shown label is the
    # class's display name (the return below), and a slot is filled by
    # class, banding and ordinal (`_expected_narrowly`).
    def raw(project_type):
        from .channel_table import ChannelTable
        from .data import (
            Frf,
            Psd,
            ShockSpecification,
            Specification,
            Srs,
            TimeHistory,
            TransientSpecification,
            _CoherenceBase,
        )
        from .geometry import Geometry
        from .matches import MatchedModes
        from .photos import Photos
        from .shapes import ShapeSet

        if project_type == 'Modal Test':
            return [('Geometry', Geometry, 'Geometry', 1, False,
                     'Basis'),
                    ('Photos', Photos, 'Photos', 1, False, 'Basis'),
                    ('Channel Table', ChannelTable, 'ChannelTable', 1,
                     False, 'Basis'),
                    ('Time Data', TimeHistory, 'TimeHistory', 1, False,
                     'Basis'),
                    ('PSD', Psd, 'Psd', 1, False, 'Basis'),
                    ('FRF', Frf, 'Frf', 1, False, 'Basis'),
                    ('Coherence', _CoherenceBase, 'MultipleCoherence',
                     1, False, 'Basis'),
                    ('Shape Set', ShapeSet, 'ShapeSet', 1, False,
                     'Basis'),
                    # test-analysis correlation is best practice, not
                    # required: the other side's pair to compare against
                    ('Other Geometry', Geometry, 'Geometry', 2, True,
                     OTHER_SIDE),
                    ('Other Shape Set', ShapeSet, 'ShapeSet', 2, True,
                     OTHER_SIDE),
                    # ...and the matched pairs, which sit outside both
                    # groups and after them. A group is one *side* of the
                    # comparison; this object names a set on each side, so
                    # it belongs to neither — the same reasoning as the
                    # report below it. It has to come after the other
                    # side's pair rather than between the two groups,
                    # because a group's rows are drawn as one bracket and
                    # have to stay contiguous.
                    ('Matched Modes', MatchedModes, 'MatchedModes', 1,
                     True, None),
                    ('Report', Report, 'Report', 1, False, None)]
        if project_type == 'Random Vibration':
            return [('Geometry', Geometry, 'Geometry', 1, False,
                     'Basis'),
                    ('Photos', Photos, 'Photos', 1, False, 'Basis'),
                    ('Channel Table', ChannelTable, 'ChannelTable', 1,
                     False, 'Basis'),
                    ('Time Data', TimeHistory, 'TimeHistory', 1, False,
                     'Basis'),
                    ('Specification', Specification, 'Specification',
                     1, False, 'Basis'),
                    # The same requirement on octave bands, limits and
                    # all — `Specification.to_octave` — the *second*
                    # Specification in the group, the way the octave PSD
                    # is the second Psd. The report's octave section
                    # compares the banded measurement against it
                    # (Brandon, 2026-09-18).
                    ('Octave Band Specification', Specification,
                     'OctaveSpecification', 2, False, 'Basis'),
                    ('PSD', Psd, 'Psd', 1, False, 'Basis'),
                    # The sixth-octave view of that PSD, and **required**: a
                    # random vibration report is not finished without it.
                    # The count is 2 because it is the *second* Psd in the
                    # group — `Psd.to_octave` returns a Psd, deliberately,
                    # since banding conserves the area and changes nothing
                    # about what the object is. What tells the two apart is
                    # `bandwidth`, which is also what `object_icon` asks
                    # before drawing bars instead of a filled curve.
                    ('Octave Band PSD', Psd, 'OctavePsd', 2, False,
                     'Basis'),
                    ('Multiple Coherence', _CoherenceBase,
                     'MultipleCoherence', 1, False, 'Basis'),
                    ('Report', Report, 'Report', 1, False, None)]
        if project_type == 'Transient':
            # no coherence slot, for the reason the shock type has none: a
            # transient is one event, and coherence is a statement about a
            # stationary average
            return [('Geometry', Geometry, 'Geometry', 1, False,
                     'Basis'),
                    ('Photos', Photos, 'Photos', 1, False, 'Basis'),
                    ('Channel Table', ChannelTable, 'ChannelTable', 1,
                     False, 'Basis'),
                    ('Time Data', TimeHistory, 'TimeHistory', 1, False,
                     'Basis'),
                    ('Transient Specification', TransientSpecification,
                     'TransientSpecification', 1, False, 'Basis'),
                    # the spectra the report reads the run a second way
                    # with: a target and a measurement. A Specification is
                    # a Psd, so the plain slot asks for the *second* one —
                    # the first is the target's own spectrum and the slot
                    # would otherwise count itself as filled the moment
                    # that arrived. No SRS pair: a shock response spectrum
                    # is how a *shock* test is judged, and reading one into
                    # a replication test blurred the very distinction the
                    # two project types exist to draw.
                    ('PSD Specification', Specification, 'Specification',
                     1, False, 'Basis'),
                    ('PSD', Psd, 'Psd', 2, False, 'Basis'),
                    ('Report', Report, 'Report', 1, False, None)]
        if project_type == 'Sine Sweep':
            # no coherence slot for the same reason the shock type has
            # none only in reverse: coherence is defined for a stationary
            # random average, and a swept tone is neither. The SRS the
            # shock type ends with becomes the sine specification here —
            # the extracted-sweep objects join the list when the arc's
            # Phase D gives them a class.
            from .sine import SineLevelSet, SineSweepSpecification
            return [('Geometry', Geometry, 'Geometry', 1, False,
                     'Basis'),
                    ('Photos', Photos, 'Photos', 1, False, 'Basis'),
                    ('Channel Table', ChannelTable, 'ChannelTable', 1,
                     False, 'Basis'),
                    ('Time Data', TimeHistory, 'TimeHistory', 1, False,
                     'Basis'),
                    ('Sine Specification', SineSweepSpecification,
                     'SineSweepSpecification', 1, False, 'Basis'),
                    ('Sine Levels', SineLevelSet, 'SineLevelSet', 1,
                     False, 'Basis'),
                    ('Report', Report, 'Report', 1, False, None)]
        if project_type == 'Random and Sine':
            # the union of the two halves' slots: the random's whole,
            # then what the sine's adds — its specification and its
            # levels — and the two reports last, the random's then the
            # sine's (`PROJECT_TEMPLATES`; one combined report until
            # 2026-10-05). Composed rather than retyped, so a slot added
            # to either half arrives here too.
            random = raw('Random Vibration')
            have = {icon for _label, _cls, icon, _n, _opt, _side in random}
            added = [slot for slot in raw('Sine Sweep') if slot[2] not in have]
            report = random[-1]
            return (random[:-1] + added
                    + [report, (*report[:3], 2, *report[4:])])
        if project_type == 'System ID':
            # the two save forms fill this differently, and the slots have
            # to serve both: a time-data save brings the channel table and
            # the two streams (ambient, then driven) and computes the
            # rest; a spectral package brings the FRF, the coherence and
            # the signal-to-noise directly and nothing else — so the
            # setup slots are optional, and the three measured readings
            # are what a finished identification must hold. No shape-set
            # slot: fitting the identified plant is a modal project's job,
            # and typing this run Modal Test is one right-click away.
            return [('Geometry', Geometry, 'Geometry', 1, True,
                     'Basis'),
                    ('Photos', Photos, 'Photos', 1, True, 'Basis'),
                    ('Channel Table', ChannelTable, 'ChannelTable', 1,
                     True, 'Basis'),
                    # the two recordings a time-data save streams, the
                    # ambient first, as the import adds them: the first
                    # TimeHistory fills the first slot, the second the
                    # second
                    ('Noise Time History', TimeHistory, 'TimeHistory', 1,
                     True, 'Basis'),
                    ('Excitation Time History', TimeHistory, 'TimeHistory',
                     2, True, 'Basis'),
                    # the ratio is a *reading* of these two, not a third
                    # object (Brandon, 2026-08-25): select both and the
                    # bar offers overlay or ratio-in-dB.
                    ('Noise Time History PSDs', Psd, 'Psd', 1, False,
                     'Basis'),
                    ('Excitation Time History PSDs', Psd, 'Psd', 2, False,
                     'Basis'),
                    ('FRF', Frf, 'Frf', 1, False, 'Basis'),
                    ('Multiple Coherence', _CoherenceBase,
                     'MultipleCoherence', 1, False, 'Basis'),
                    ('Report', Report, 'Report', 1, False, None)]
        if project_type == 'Shock':
            # no coherence slot: a shock is one transient event, and
            # coherence is a statement about a stationary average
            return [('Geometry', Geometry, 'Geometry', 1, False,
                     'Basis'),
                    ('Photos', Photos, 'Photos', 1, False, 'Basis'),
                    ('Channel Table', ChannelTable, 'ChannelTable', 1,
                     False, 'Basis'),
                    ('Time Data', TimeHistory, 'TimeHistory', 1, False,
                     'Basis'),
                    ('Shock Specification', ShockSpecification,
                     'ShockSpecification', 1, False, 'Basis'),
                    ('SRS', Srs, 'Srs', 1, False, 'Basis'),
                    # The recommended workflow, slot by slot (Brandon,
                    # 2026-08-25): the raw record is filtered, and the
                    # SRS and the motion chain are computed from the
                    # *filtered* record — the skeleton is where a user is
                    # reminded of that order, so each step gets a slot to
                    # sit in. Best practice rather than required, because
                    # the report renders without them; each is another
                    # TimeHistory, hence the ordinals. There is no PSD
                    # slot any more (Brandon, 2026-08-29): a density of a
                    # transient recording carries a level set by how much
                    # quiet air was captured, and the report's scalogram
                    # answers the where-in-frequency question with *when*
                    # still attached.
                    ('Filtered Time Data', TimeHistory, 'TimeHistory', 2,
                     True, 'Basis'),
                    ('Velocity', TimeHistory, 'TimeHistory', 3, True,
                     'Basis'),
                    ('Displacement', TimeHistory, 'TimeHistory', 4, True,
                     'Basis'),
                    ('Report', Report, 'Report', 1, False, None)]
        return []


    return [(display_name(icon if cls.__name__.startswith('_') else
                          cls.__name__),
             cls, icon, count, optional, side)
            for _label, cls, icon, count, optional, side in raw(project_type)]

missing_expectations

missing_expectations(project_type: str, objects: Mapping[str, Any], placed: Mapping[str, Sequence[str]] | None = None) -> list[tuple[str, type, str, int, bool, str | None]]

The expectations the project does not satisfy yet.

They are in the form project_expectations gives.

placed is {side: [object names]}, the objects in each side's groups: Project.placed() for the Basis, and under OTHER_SIDE every member of every other group (the window adds that key). A slot is filled only by an object on its own side, never by one anywhere in the project: a modal test whose only geometry is the model's still needs a measured one.

A slot with no side (the report) is filled from the whole project, since that is what a report reads. With placed omitted, every object counts for every slot, for callers that have no groups to offer.

Source code in src/visualdynamics/core/report.py
def missing_expectations(
        project_type: str, objects: Mapping[str, Any],
        placed: Mapping[str, Sequence[str]] | None = None
        ) -> list[tuple[str, type, str, int, bool, str | None]]:
    """The expectations the project does not satisfy yet.

    They are in the form `project_expectations` gives.

    `placed` is {side: [object names]}, the objects in each side's
    groups: `Project.placed()` for the Basis, and under `OTHER_SIDE`
    every member of every other group (the window adds that key). A
    slot is filled only by an object on its own side, never by one
    anywhere in the project: a modal test whose only geometry is the
    model's still needs a measured one.

    A slot with no side (the report) is filled from the whole project,
    since that is what a report reads. With `placed` omitted, every
    object counts for every slot, for callers that have no groups to
    offer.
    """
    # Counting the whole project for every slot made the Basis slots
    # disappear the moment any geometry arrived, leaving a skeleton with
    # nothing in it and no way to see what was wrong.
    expectations = project_expectations(project_type)
    classes = [expectation[1] for expectation in expectations]
    out, ordinals = [], {}
    for expectation in expectations:
        _name, cls, icon, _count, _optional, tag = expectation
        # the n-th slot of a class on a side wants an n-th object of it,
        # so a side expecting two shape sets is not filled by one — and
        # a banded slot counts the banded objects, a plain one the rest
        banded = icon in BANDED_TOKENS
        key = (tag, cls, banded)
        ordinals[key] = ordinal = ordinals.get(key, 0) + 1
        if placed is None or tag is None:
            pool = list(objects.values())
        else:
            pool = [objects[name] for name in placed.get(tag, ())
                    if name in objects]
        if sum(1 for obj in pool
               if _expected_narrowly(obj, cls, classes, banded)) < ordinal:
            out.append(expectation)
    return out

expectation_satisfiers

expectation_satisfiers(project_type: str, objects: Mapping[str, Any], placed: Mapping[str, Sequence[str]] | None = None) -> dict[str, list[str]]

Which objects fill each of the type's Basis slots.

Returned as {object group: [object names]}.

The result is used to link the Basis members together. The n-th instance of a class, in project order, fills the slot whose count is n (a banded slot takes the first banded object). Slots on the other side have no group name and are never filled by a guess: a second geometry and shape set are linked by hand, or by the file that brought them in.

The project order is only a guess, since the first geometry imported could belong to either group and nothing about the object says which. Objects already placed are therefore taken as given: pass placed (from Project.placed()) and each placed object counts only for the group it is in. The groups in placed are returned with the guessed members added.

Source code in src/visualdynamics/core/report.py
def expectation_satisfiers(
        project_type: str, objects: Mapping[str, Any],
        placed: Mapping[str, Sequence[str]] | None = None
        ) -> dict[str, list[str]]:
    """Which objects fill each of the type's Basis slots.

    Returned as {object group: [object names]}.

    The result is used to link the Basis members together. The n-th
    instance of a class, in project order, fills the slot whose count
    is n (a banded slot takes the first banded object). Slots on the
    other side have no group name and are never filled by a guess: a
    second geometry and shape set are linked by hand, or by the file
    that brought them in.

    The project order is only a guess, since the first geometry
    imported could belong to either group and nothing about the object
    says which. Objects already placed are therefore taken as given:
    pass `placed` (from `Project.placed()`) and each placed object
    counts only for the group it is in. The groups in `placed` are
    returned with the guessed members added.
    """
    homes = {name: tag for tag, names in (placed or {}).items()
             for name in names}
    groups = {tag: list(names) for tag, names in (placed or {}).items()}
    expectations = project_expectations(project_type)
    classes = [expectation[1] for expectation in expectations]
    for _name, cls, icon, count, _optional, tag in expectations:
        if tag != 'Basis':
            continue          # the other side is never guessed into
        # The ordinal is over *every* instance in the project, placed or
        # not. Counting only the unplaced ones renumbers them, and the
        # model's geometry becomes the project's first — which is the
        # Basis slot, the very group it was just dragged off.
        banded = icon in BANDED_TOKENS
        instances = [obj_name for obj_name, obj in objects.items()
                     if _expected_narrowly(obj, cls, classes, banded)]
        # a banded slot is the first of its own kind, not the second
        # of the class: the octave PSD is one object, however many
        # narrowband PSDs stand beside it
        wanted = 1 if banded else count
        if len(instances) < wanted:
            continue
        candidate = instances[wanted - 1]
        if homes.get(candidate, tag) != tag:
            continue        # somebody has put it elsewhere
        groups.setdefault(tag, [])
        if candidate not in groups[tag]:
            groups[tag].append(candidate)
    return groups

quantity_order

quantity_order(dims: set[str]) -> list[str]

The quantities in dims in reading order.

Voltage, then force, then acceleration (the order of DOF_SCENE_QUANTITIES, which the DOF scenes follow), then any other quantity in alphabetical order.

Source code in src/visualdynamics/core/report.py
def quantity_order(dims: set[str]) -> list[str]:
    """The quantities in `dims` in reading order.

    Voltage, then force, then acceleration (the order of
    `DOF_SCENE_QUANTITIES`, which the DOF scenes follow), then any
    other quantity in alphabetical order.
    """
    known = [q for q in DOF_SCENE_QUANTITIES if q in dims]
    return known + sorted(dims - set(DOF_SCENE_QUANTITIES))

time_data_blocks

time_data_blocks(source: str, objects: Mapping[str, Any], object_groups: Sequence[Mapping[str, Any]] | None, caption: str, per_quantity: str, mode: str = 'curves', averaging: bool = True) -> list[dict[str, Any]]

The time plots for one history, one figure per quantity it measures.

An axis holds one quantity, so a stream with drive voltages beside response accelerations is drawn as two figures. per_quantity is the caption with a {quantity} placeholder; a source with one quantity, or an unbound one, gets a single figure with the plain caption. The placeholder is filled by str.replace, because a caption may hold a live reference ({{Object.field}}) whose double braces str.format would collapse.

The figures are flat (mode='curves') by default, because time histories are by far the largest data in a report and a 3-D stage figure of them makes the file very large. The flat figure pages its channels two dozen at a time and draws each page as an envelope on one shared time axis. mode='stage' is available for a template that wants depth. averaging=False leaves the averaging frames off the figures, for a reading that averages nothing.

Source code in src/visualdynamics/core/report.py
def time_data_blocks(source: str, objects: Mapping[str, Any],
                     object_groups: Sequence[Mapping[str, Any]] | None,
                     caption: str, per_quantity: str,
                     mode: str = 'curves',
                     averaging: bool = True) -> list[dict[str, Any]]:
    """The time plots for one history, one figure per quantity it measures.

    An axis holds one quantity, so a stream with drive voltages beside
    response accelerations is drawn as two figures. `per_quantity` is
    the caption with a ``{quantity}`` placeholder; a source with one
    quantity, or an unbound one, gets a single figure with the plain
    `caption`. The placeholder is filled by `str.replace`, because a
    caption may hold a live reference (`{{Object.field}}`) whose double
    braces `str.format` would collapse.

    The figures are flat (`mode='curves'`) by default, because time
    histories are by far the largest data in a report and a 3-D stage
    figure of them makes the file very large. The flat figure pages its
    channels two dozen at a time and draws each page as an envelope on
    one shared time axis. `mode='stage'` is available for a template
    that wants depth. `averaging=False` leaves the averaging frames off
    the figures, for a reading that averages nothing.
    """
    # One quantity per axis (Brandon, 2026-08-23).
    #
    # `replace`, never `str.format` (Brandon, 2026-08-25): `format`
    # reads the doubled braces of a live reference as an escape and
    # collapses them, and the reference resolver then does not match.
    # The shock report shipped 'at {Time History.filter_corner}' under
    # two figures exactly that way, and only in the two-quantity case,
    # because the one-quantity path never calls format at all.
    #
    # Flat, not the stage (Brandon, 2026-09-20). A stage figure carries
    # `MAX_FIGURE_POINTS` (two million points): the plate's modal report
    # was 36 MB, 88% of it two such figures, and a 144-channel run at
    # 16 kHz made a 200 MB report.
    name = resolve_binding(source, objects, object_groups)
    obj = objects.get(name)
    dims = record_dimensions(obj) if obj is not None else set()
    # the averaging frames are drawn unless the template says they are
    # not this reading's: the sine report demodulates the whole record
    # and averages nothing (Brandon, 2026-10-02)
    frames = {} if averaging else {'averaging': False}
    if len(dims) < 2:
        return [{'kind': 'plot', 'source': source, 'mode': mode,
                 'caption': caption, **frames}]
    return [{'kind': 'plot', 'source': source, 'mode': mode,
             'select': f'dim:{quantity}',
             'caption': per_quantity.replace('{quantity}', quantity),
             **frames}
            for quantity in quantity_order(dims)]

kurtosis_block

kurtosis_block(source: str = '@basis:TimeHistory', caption: str = '') -> dict[str, Any]

A bar chart of the Pearson kurtosis of every channel of a record.

Kurtosis shows something a spectrum cannot: two records with identical PSDs can be a smooth hiss and a train of rare large peaks, and only the second fatigues an article the way the peaks suggest. All channels go on one chart whatever they measure, since kurtosis is dimensionless. It is a computed result, so templates place it with the other computed results after the spectra, not beside the time traces. caption defaults to a description of the Gaussian band.

Source code in src/visualdynamics/core/report.py
def kurtosis_block(source: str = '@basis:TimeHistory',
                   caption: str = '') -> dict[str, Any]:
    """A bar chart of the Pearson kurtosis of every channel of a record.

    Kurtosis shows something a spectrum cannot: two records with
    identical PSDs can be a smooth hiss and a train of rare large
    peaks, and only the second fatigues an article the way the peaks
    suggest. All channels go on one chart whatever they measure, since
    kurtosis is dimensionless. It is a computed result, so templates
    place it with the other computed results after the spectra, not
    beside the time traces. `caption` defaults to a description of the
    Gaussian band.
    """
    # Brandon, 2026-08-24: kurtosis in the reports.
    return {'kind': 'bars', 'mode': 'kurtosis', 'source': source,
            'caption': caption or
            'Pearson kurtosis by channel — 3 is Gaussian; above the '
            'band the record carries peaks the spectrum does not '
            'predict, below it the record is clipped or not random'}

paired_density_blocks

paired_density_blocks(loud: str, quiet: str, objects: Mapping[str, Any], caption: str, per_quantity: str) -> list[dict[str, Any]]

Two densities overlaid, one figure per quantity they share.

loud is drawn solid and quiet (the ambient) dashed in each channel's own color, with the same one-quantity-per-axis rule and reading order as time_data_blocks. The figures are flat and continue into further figures past a dozen channels. Either name empty gives no blocks.

Source code in src/visualdynamics/core/report.py
def paired_density_blocks(loud: str, quiet: str,
                          objects: Mapping[str, Any],
                          caption: str,
                          per_quantity: str) -> list[dict[str, Any]]:
    """Two densities overlaid, one figure per quantity they share.

    `loud` is drawn solid and `quiet` (the ambient) dashed in each
    channel's own color, with the same one-quantity-per-axis rule and
    reading order as `time_data_blocks`. The figures are flat and
    continue into further figures past a dozen channels. Either name
    empty gives no blocks.
    """
    # One quantity per axis (Brandon, 2026-08-23). Flat because the
    # 3-D stage made the report files too large (Brandon, 2026-10-05:
    # the random, random and sine, and system ID reports are 2-D only).
    if not loud or not quiet:
        return []
    shared = (record_dimensions(objects[loud])
              & record_dimensions(objects[quiet]))
    if len(shared) < 2:
        return [{'kind': 'plot', 'mode': 'pair',
                 'source': loud, 'floor': quiet, 'caption': caption}]
    return [{'kind': 'plot', 'mode': 'pair', 'source': loud,
             'floor': quiet, 'select': f'dim:{quantity}',
             'caption': per_quantity.replace('{quantity}', quantity)}
            for quantity in quantity_order(shared)]

front_matter

front_matter(objects: Mapping[str, Any], object_groups: Sequence[Mapping[str, Any]] | None, dofs_source: str) -> list[dict[str, Any]]

The blocks every report opens with, always in the same order.

The test geometry, then a DOF scene for each quantity dofs_source measures (excitations before responses; every quantity in DOF_SCENE_QUANTITIES when the source is not there yet), then the photographs, then the channel table: what was tested and where it was instrumented, before any data. Every reference is symbolic, and an unbound or empty block is not rendered, so a bare project keeps the outline without blank figures.

Source code in src/visualdynamics/core/report.py
def front_matter(objects: Mapping[str, Any],
                 object_groups: Sequence[Mapping[str, Any]] | None,
                 dofs_source: str) -> list[dict[str, Any]]:
    """The blocks every report opens with, always in the same order.

    The test geometry, then a DOF scene for each quantity `dofs_source`
    measures (excitations before responses; every quantity in
    `DOF_SCENE_QUANTITIES` when the source is not there yet), then the
    photographs, then the channel table: what was tested and where it
    was instrumented, before any data. Every reference is symbolic, and
    an unbound or empty block is not rendered, so a bare project keeps
    the outline without blank figures.
    """
    # One order for every report (Brandon, 2026-08-23).
    geometry = '@basis:Geometry'
    photos = '@basis:Photos'
    photos_name = resolve_binding(photos, objects, object_groups)
    if photos_name in objects and getattr(objects[photos_name],
                                          'names', None):
        photo_blocks = [
            {'kind': 'photo', 'source': photos_name, 'photo': name,
             'caption': f'Test setup — {name}'}
            for name in objects[photos_name].names]
    else:
        photo_blocks = [{'kind': 'photo', 'source': photos, 'photo': '',
                         'caption': 'Test article as instrumented'}]
    blocks: list[dict[str, Any]] = [
        {'kind': 'scene', 'geometry': geometry, 'shapes': '',
         'caption': 'Test geometry and measurement locations'}]
    # a DOF scene per quantity the source actually measures — a run
    # whose excitation is a load cell has no voltage scene to draw, and
    # the empty card it left in the editor read as a fault (Brandon,
    # 2026-09-08). Every quantity when the source is not there to ask:
    # a bare project keeps the outline as slots to fill.
    source_name = resolve_binding(dofs_source, objects, object_groups)
    source = objects.get(source_name) if source_name else None
    scenes = list(DOF_SCENE_QUANTITIES)
    if source is not None and hasattr(source, 'known_dim'):
        held = set(series_dof_quantities([('', source, None)]))
        scenes = [quantity for quantity in scenes if quantity in held]
        drawn_on = objects.get(resolve_binding(geometry, objects,
                                               object_groups) or '')
        if drawn_on is not None and hasattr(drawn_on, 'node_id'):
            # and only where some of them land on the geometry: a scene
            # with no arrow is not drawn, and the prose citing it
            # (`dof_references`) would cite a figure that is not there
            # (2026-10-27: a shock run's voltage channels on nodes the
            # plate does not have)
            scenes = [quantity for quantity in scenes
                      if _lands_on(drawn_on, series_quantity_dofs(
                          [('', source, None)], quantity))]
    for quantity in scenes:
        role = ('Excitation' if quantity in EXCITATION_QUANTITIES
                else 'Response')
        blocks.append(
            {'kind': 'scene', 'geometry': geometry, 'shapes': '',
             'dofs': quantity, 'dofs_source': dofs_source,
             'caption': f'{role} degrees of freedom ({quantity})'})
    blocks += photo_blocks
    blocks.append({'kind': 'table', 'source': '@basis:ChannelTable',
                   'caption': 'Instrumentation'})
    return blocks

modal_template

modal_template(objects: Mapping[str, Any], object_groups: Sequence[Mapping[str, Any]] | None = None) -> Report

A modal test report from whatever the project holds.

The sections are the test summary, the setup and front matter, the measured time data and averaged PSDs, data quality (drive point FRFs and multiple coherence), the modal analysis (the CMIF with its resynthesis, the identified parameters, the auto-MAC and the animated shapes), an optional test-analysis correlation (cross-MAC, matched modes and their overlay), and conclusions. Sources bind symbolically ('@basis:Frf' and the like, resolved against the object groups when the report is rendered), so the report does not depend on what the objects are named. Blocks whose type the project lacks are still added, unbound, so the outline of a full modal report is there to fill in.

Source code in src/visualdynamics/core/report.py
def modal_template(objects: Mapping[str, Any], object_groups: Sequence[Mapping[str, Any]] | None = None) -> Report:
    """A modal test report from whatever the project holds.

    The sections are the test summary, the setup and front matter, the
    measured time data and averaged PSDs, data quality (drive point
    FRFs and multiple coherence), the modal analysis (the CMIF with its
    resynthesis, the identified parameters, the auto-MAC and the
    animated shapes), an optional test-analysis correlation (cross-MAC,
    matched modes and their overlay), and conclusions. Sources bind
    symbolically ('@basis:Frf' and the like, resolved against the
    object groups when the report is rendered), so the report does not
    depend on what the objects are named. Blocks whose type the project
    lacks are still added, unbound, so the outline of a full modal
    report is there to fill in.
    """
    geometry = '@basis:Geometry'
    shapes = '@basis:ShapeSet'
    frf = '@basis:Frf'
    time = '@basis:TimeHistory'
    psd = '@basis:Psd'
    coherence = '@basis:Coherence'

    def ref(name: str, field: str) -> str:
        return '{{' + f'{name}.{field}' + '}}'

    return Report('Modal Test Report', [
        {'kind': 'text', 'text':
            '## Test Summary\n\n'
            'An experimental modal test was performed on the test '
            'article to identify its natural frequencies, damping, and '
            'mode shapes. This report documents the instrumentation, '
            'the measured data and its quality checks, and the modal '
            'parameters identified from it.\n\n'
            f'Time data was acquired on {ref(time, "num_channels")} '
            f'channels at a sample rate of {ref(time, "sample_rate")}, '
            f'with {ref(time, "num_samples")} samples per frame '
            f'({ref(time, "frequency_resolution")} frequency '
            f'resolution), averaged over {ref(time, "num_averages")} '
            f'frames. {ref(shapes, "num_modes")} modes were identified '
            f'between {ref(shapes, "min_frequency")} and '
            f'{ref(shapes, "max_frequency")}.\n\n'
            'The geometry, the instrumentation and the excitation '
            'directions are documented in the figures below, which '
            'are the record of how the article was set up and what '
            'was measured where.'},
        {'kind': 'text', 'text':
            '## Test Setup\n\n'
            'The test geometry ({{figure:Test geometry}}) shows every '
            'measurement location and the wireframe used to visualize '
            'the mode shapes.'
            + dof_sentence(*dof_references(objects, object_groups, frf),
                           'in which direction the structure was driven',
                           'every measured accelerometer direction')
            + ' The channel table ({{table:Instrumentation}}) lists the '
            'instrumentation at each location: sensor, sensitivity, '
            'and calibration status.'},
        *front_matter(objects, object_groups, frf),
        {'kind': 'text', 'text':
            '## Measured Data\n\n'
            'The force and response time histories '
            '({{figure:Excitation force time histories}} and '
            '{{figure:Response acceleration time histories}}) show the '
            'excitation bursts and the structure’s response for '
            'every averaged frame — a check that the excitation was '
            'consistent and the responses died out within each frame. '
            'The averaged auto-power spectral densities '
            '({{figure:Averaged excitation force}} and '
            '{{figure:Averaged response acceleration}}) show how the '
            'excitation energy was distributed over frequency and '
            'which bands the structure responded in.'},
        # flat, for the reason `time_data_blocks` gives: these two were
        # 88% of a 36 MB page (Brandon, 2026-09-20). The spectra below
        # keep the stage — a thousand lines a channel is a figure the
        # depth helps and the file can hold.
        {'kind': 'plot', 'source': time, 'mode': 'curves',
         'select': 'dim:force', 'caption': 'Excitation force time '
         'histories, all frames'},
        {'kind': 'plot', 'source': time, 'mode': 'curves',
         'select': 'dim:acceleration', 'caption': 'Response '
         'acceleration time histories, all frames'},
        {'kind': 'plot', 'source': psd, 'mode': 'stage',
         'select': 'dim:force', 'record_caption': True,
         'caption': 'Averaged excitation force auto-power spectral '
         'density'},
        {'kind': 'plot', 'source': psd, 'mode': 'stage',
         'select': 'dim:acceleration', 'record_caption': True,
         'caption': 'Averaged response acceleration auto-power spectral '
         'densities'},
        {'kind': 'text', 'text':
            '## Data Quality\n\n'
            'The drive point FRF magnitudes '
            '({{figure:Drive point FRF magnitudes}}) should show a '
            'clear resonance-antiresonance alternation, the signature '
            'of a true drive point measurement. The imaginary parts '
            '({{figure:Drive point FRF imaginary}}) should be '
            'single-sided (one sign at every resonance): a sign '
            'flip indicates a sensor or excitation polarity error, and '
            'the peak magnitudes indicate how well each mode was '
            'excited from that drive point. The multiple coherence '
            'map ({{figure:Multiple coherence}}) shows, channel by '
            'channel and frequency by frequency, '
            'how much of each response the excitation accounts for — '
            'dark rows are suspect channels, dark bands are '
            'poorly-excited frequency ranges.'},
        {'kind': 'plot', 'source': frf, 'mode': 'curves',
         'select': 'drive', 'component': 'magnitude',
         'shapes': shapes,
         'caption': 'Drive point FRF magnitudes, the identified mode '
         'frequencies marked'},
        {'kind': 'plot', 'source': frf, 'mode': 'curves',
         'select': 'drive', 'component': 'imag',
         'shapes': shapes,
         'caption': 'Drive point FRF imaginary parts, the identified '
         'mode frequencies marked — single-sided '
         'peaks confirm drive point behavior; peak size shows how '
         'well each mode was excited'},
        {'kind': 'plot', 'source': coherence, 'mode': 'stage',
         'caption': 'Multiple coherence, every channel over frequency '
         '(1.0 means the excitation fully accounts for the response)'},
        {'kind': 'text', 'text':
            '## Modal Analysis\n\n'
            'The Complex Mode Indicator Function '
            '({{figure:Complex Mode Indicator Function}}) condenses '
            'the full FRF matrix into its singular values at every '
            'frequency line; peaks mark the structure’s modes, and '
            'repeated peaks in the lower curves reveal closely spaced '
            'or repeated roots. The dashed curves are the CMIF '
            'resynthesized from the identified modal model — how '
            'closely they track the measurement is the quality of the '
            'fit, and the dashed vertical markers sit at each '
            'identified frequency. The identified parameters are '
            'listed in {{table:Identified modal parameters}}. The '
            'auto-MAC ({{figure:Auto-MAC}}) checks the identified '
            'shapes’ independence: large off-diagonal terms mean '
            'two shapes are not truly distinct. The animated mode '
            'shapes ({{figure:Identified mode shapes}}) follow, '
            'colored by displacement.'},
        {'kind': 'plot', 'source': frf, 'mode': 'cmif', 'shapes': shapes,
         'caption': 'Complex Mode Indicator Function, measured (solid) '
         'against the modal model resynthesis (dashed)'},
        {'kind': 'table', 'source': shapes,
         'caption': 'Identified modal parameters'},
        {'kind': 'plot', 'source': shapes, 'mode': 'mac',
         'caption': 'Auto-MAC of the identified mode shapes — an '
         'identity-like matrix confirms independent shapes'},
        {'kind': 'scene', 'geometry': geometry, 'shapes': shapes,
         'caption': 'Identified mode shapes (pick a mode to animate '
         'it, colored by displacement)'},
        # test-analysis correlation is optional: with no projected FEM
        # shapes in the project this block is an unbound slot and the
        # report renders without it
        {'kind': 'text', 'text':
            '## Test-Analysis Correlation\n\n'
            'Where a finite element model of the same article is in '
            'the project, the identified modes are compared against '
            'it. The cross-MAC ({{figure:Test-analysis correlation}}) '
            'scores every test mode against every model mode: a value '
            'near one says two shapes describe the same motion, and '
            'the pattern of the matrix says whether the model orders '
            'its modes the way the article does. The comparison is '
            'always made in the test\'s own degrees of freedom, '
            'because that is where the measurement exists — a model '
            'has motion everywhere and a test has it where the '
            'accelerometers were.\n\n'
            'The matched pairs are then listed with both sets\' '
            'frequencies and damping side by side ({{table:Matched '
            'modes}}), and animated over each other '
            '({{figure:Matched mode pairs}}) so a pair that scores '
            'well but moves differently is visible as well as '
            'scored. Each shape in the animation is drawn to its own '
            'peak, so what is being compared is shape and not scale — '
            'a scale difference between two sets is almost always a '
            'mass unit or a normalization convention rather than the '
            'structure, and the figure says so in words when it '
            'finds one.\n\n'
            'These figures are unbound and absent when the project '
            'holds no model to compare against, which is the ordinary '
            'case for a test run on its own.'},
        {'kind': 'plot', 'source': shapes, 'mode': 'mac',
         'shapes': _fem_shapes(objects,
                               resolve_binding(shapes, objects, object_groups)),
         'caption': 'Test-analysis correlation: cross-MAC between the '
         'identified test modes and the finite element model '
         'projected onto the test DOFs'},
        # ...as is the matched-modes table: an unbound slot until the
        # user commits matches on the comparison screen.
        #
        # `@any` and not `@basis`: a comparison names a set on each
        # side, so it is linked into neither group and a basis-scoped
        # selector would never find it.
        {'kind': 'pairs', 'source': '@any:MatchedModes',
         'caption': 'Matched modes: both sets’ parameters side by '
         'side, frequency error against the test set, and the MAC of '
         'each matched pair'},
        {'kind': 'overlay', 'source': '@any:MatchedModes',
         'caption': 'Matched mode pairs animated over each other — '
         'the test set in blue, the model in orange, phase-aligned '
         'to move together'},
        # no kurtosis figure: a modal test is driven with burst random,
        # whose kurtosis read whole is about three over its duty and
        # says nothing about the article (Brandon, 2026-09-08: "We
        # don't need it"). The toolbar's reading is still there for a
        # record someone wants to look at.
        {'kind': 'text', 'text':
            '## Conclusions\n\n'
            f'{ref(shapes, "num_modes")} modes were identified between '
            f'{ref(shapes, "min_frequency")} and '
            f'{ref(shapes, "max_frequency")}, and their frequencies, '
            'damping and shapes are listed in '
            '{{table:Identified modal parameters}}.\n\n'
            'The quality of that identification is read off three '
            'figures. The resynthesized CMIF '
            '({{figure:Complex Mode Indicator Function}}) shows how '
            'closely a model built from those modes reproduces the '
            'measurement it was fitted to — where the dashed curve '
            'leaves the solid one, the model does not account for '
            'what was measured. The auto-MAC ({{figure:Auto-MAC}}) '
            'shows whether the identified shapes are independent of '
            'each other; large off-diagonal terms mean two modes are '
            'not truly distinct, which is the usual sign of a '
            'repeated root or an over-fit. The multiple coherence '
            '({{figure:Multiple coherence}}) shows which channels and '
            'which bands the identification had good data in to begin '
            'with.\n\n'
            'A mode identified in a band where the coherence is poor, '
            'or one whose shape correlates strongly with its '
            'neighbor, is one to re-examine before it is used — in a '
            'correlation against a model, or as the basis of anything '
            'downstream.'},
    ])

shock_template

shock_template(objects: Mapping[str, Any], object_groups: Sequence[Mapping[str, Any]] | None = None) -> Report

A shock test report: the transients, and the SRS against its target.

After the front matter, the measured time histories come first. A shock is a single event, and its character (how long it lasted, how many events there were, whether the article was still ringing when the next one arrived) is in the trace and not in the spectrum. Then come the filtered record, velocity and displacement where the project has them, a scalogram of the filtered record, the shock specification on its own, the measured SRS against it, and the SRS deviation bars. There is no kurtosis figure, because a shock record is a transient in a long quiet stretch and never reads near three.

Source code in src/visualdynamics/core/report.py
def shock_template(objects: Mapping[str, Any], object_groups: Sequence[Mapping[str, Any]] | None = None) -> Report:
    """A shock test report: the transients, and the SRS against its target.

    After the front matter, the measured time histories come first. A
    shock is a single event, and its character (how long it lasted, how
    many events there were, whether the article was still ringing when
    the next one arrived) is in the trace and not in the spectrum. Then
    come the filtered record, velocity and displacement where the
    project has them, a scalogram of the filtered record, the shock
    specification on its own, the measured SRS against it, and the SRS
    deviation bars. There is no kurtosis figure, because a shock
    record is a transient in a long quiet stretch and never reads near
    three.
    """
    # Front matter first, like every report (Brandon, 2026-08-23).
    time = '@basis:TimeHistory'
    srs = '@basis:Srs'

    def ref(name: str, field: str) -> str:
        return '{{' + f'{name}.{field}' + '}}'

    # The motion chain, bound by name like the sysid streams — a
    # generated template knows its own project's names. The filtered
    # record is found by provenance (`objects` is the project when the
    # app generates, and provenance is the truth about what was
    # filtered); velocity and displacement are what they measure, so
    # their dimensions are the honest finder whoever named them.
    from .data import TimeHistory as _TimeHistory
    provenance = getattr(objects, 'provenance', {}) or {}
    filtered = next(
        (name for name, record in provenance.items()
         if record.get('verb') == 'filter_data' and name in objects
         and isinstance(objects[name], _TimeHistory)), '')
    raw = next(
        (record['source'] for name, record in provenance.items()
         if name == filtered), '')
    velocity = next(
        (name for name, obj in objects.items()
         if isinstance(obj, _TimeHistory)
         and record_dimensions(obj) == {'velocity'}), '')
    displacement = next(
        (name for name, obj in objects.items()
         if isinstance(obj, _TimeHistory)
         and record_dimensions(obj) == {'length'}), '')
    # The figures first, then the prose that points at them
    # (Brandon, 2026-08-25). A `{{figure:...}}` matches the *start* of
    # a real caption, and a caption written by `time_data_blocks`
    # carries the quantity when a record measures more than one — so
    # a reference guessed from the caption template read
    # 'Low-pass filtered transients' against a figure captioned
    # 'Low-pass filtered voltage transients' and quietly stayed
    # unresolved in the shipped document. Referencing what was
    # actually built cannot drift from it.
    def figures(source: str, caption: str, per_quantity: str
                ) -> list[dict[str, Any]]:
        return (time_data_blocks(source, objects, object_groups, caption,
                                 per_quantity) if source else [])

    filtered_blocks = figures(
        filtered,
        'Filtered transients, '
        f'{ref(raw or filtered, "filter_description")}',
        'Filtered {quantity} transients, '
        f'{ref(raw or filtered, "filter_description")}')
    velocity_blocks = figures(velocity, 'Integrated velocity, drift '
                              'high-passed',
                              'Integrated velocity, drift high-passed')
    displacement_blocks = figures(
        displacement, 'Integrated displacement, drift high-passed',
        'Integrated displacement, drift high-passed')

    def first_reference(blocks: list[dict[str, Any]]) -> str:
        """A reference to the first of a group, by its own caption."""
        if not blocks:
            return ''
        return '{{figure:' + blocks[0]['caption'].split(',')[0] + '}}'

    motion_blocks: list[dict[str, Any]] = []
    if filtered_blocks or velocity_blocks or displacement_blocks:
        named = ' and '.join(
            reference for reference in
            (first_reference(filtered_blocks),
             first_reference(velocity_blocks),
             first_reference(displacement_blocks)) if reference)
        motion_blocks.append({'kind': 'text', 'text':
            '## The Motion\n\n'
            f'The derived motion ({named}). '
            + ('The filtered transients are the measured record '
               'through a zero-phase '
               f'{ref(raw or filtered, "filter_description")}, order '
               f'{ref(raw or filtered, "filter_order")} — zero phase '
               'so every peak stays at its measured instant, where a '
               'causal filter would shift it by the group delay. '
               'Everything judged below is computed from this '
               'filtered record rather than the raw one, so the '
               'spectra, the motion and the response spectrum all '
               'describe the same signal. '
               if filtered_blocks else '')
            + ('Velocity and displacement are '
               if velocity_blocks and displacement_blocks else
               'The velocity is ' if velocity_blocks else
               'The displacement is ' if displacement_blocks else '')
            + ('integrated in the time domain over the whole record, '
               'with the channel mean removed and a gentle zero-phase '
               'high-pass after each integration: a sensor bias '
               'integrates to a ramp that would dwarf the motion, and '
               'nothing below the drift corner can tell bias from '
               'true motion, so the corner is a declared judgment '
               'rather than a detection. The shock windows carry over '
               'unchanged — the events numbered here are the events '
               'numbered on the measured traces. Velocity and '
               'displacement are what the article was actually made '
               'to do: an SRS reads the damage potential, but a '
               'fixture stroke or a clearance is checked against '
               'displacement, and pyroshock severity criteria are '
               'often written in velocity.'
               if velocity_blocks or displacement_blocks else '')})
    motion_blocks += (filtered_blocks + velocity_blocks
                      + displacement_blocks)

    # The scalogram replaced the density here (Brandon, 2026-08-29):
    # a PSD of a transient recording carries a level that depends on
    # how much quiet air was recorded around the events — energy per
    # unit time, over a duration that is an accident of the capture —
    # and nothing in the shock judgment reads it. What the density
    # was kept for (naming the source of an unexpected SRS peak — a
    # fixture resonance, a programmer ringing, a bolt buzzing) the
    # scalogram answers better, with *when* still attached: each
    # event's ring is a streak at its frequency, its decay legible
    # along time, so a resonance that only one event excited is
    # visible as exactly that.
    spectral_blocks: list[dict[str, Any]] = [
        {'kind': 'text', 'text':
            '## Where The Energy Is In Time\n\n'
            'The scalogram ({{figure:Shock scalogram}}) reads the '
            'filtered record against time *and* frequency at once — '
            'time across, frequency receding, amplitude standing up '
            'out of the floor. The SRS above says what each event '
            'would do to a structure; this says where in frequency '
            'the energy that did it actually sat, and *when* — which '
            'is what names the source of an unexpected SRS peak: a '
            'fixture resonance rings at one frequency across every '
            'event, a programmer rings only while it is struck, a '
            'loose bolt buzzes between them. A density would average '
            'all of that away, and its level would depend on how '
            'much quiet air the recording happened to hold. One '
            'channel is drawn, the way the application\'s wavelet '
            'view reads one at a time; the lowest rows lean on the '
            'record\'s ends for the span the caption gives, because '
            'a low-frequency wavelet is long, and inside that span '
            'the picture is shaped by where the record was cut '
            'rather than by the event.'},
        {'kind': 'plot',
         'source': filtered or '@basis:TimeHistory',
         'mode': 'scalogram', 'select': 'dim:acceleration',
         'caption': 'Shock scalogram'}]

    excitation, response = dof_references(objects, object_groups,
                                          '@basis:TimeHistory')
    return Report('Shock Test Report', [
        {'kind': 'text', 'text':
            '## Test Summary\n\n'
            'A shock test was run to subject the article to a required '
            'response spectrum. A shock specification names the '
            'response, not the waveform: any transient whose shock '
            'response spectrum lands inside the tolerance band meets '
            'it, which is what separates a shock test from a transient '
            'replication. This report documents the instrumentation, '
            'the events as they were recorded, the spectrum each '
            'produced, and how far each sits from what was '
            'required.\n\n'
            f'{ref(time, "num_shocks")} events were recorded across '
            f'{ref(time, "num_channels")} channels at '
            f'{ref(time, "sample_rate")} over '
            f'{ref(time, "duration")} of record. The spectra are '
            f'computed from {ref(srs, "min_frequency")} to '
            f'{ref(srs, "max_frequency")}.'},
        {'kind': 'text', 'text':
            '## Test Article and Instrumentation\n\n'
            'The test geometry ({{figure:Test geometry}}) shows where '
            'the article was instrumented.'
            + dof_sentence(excitation, response,
                           'in which direction the shock was applied',
                           'every measured direction')
            + (' A shock is judged where it was measured, and a channel '
               'not on these figures is a channel this report says '
               'nothing about.' if excitation or response else '')
            + ' The photographs record the article as it was '
            'actually mounted, which for a shock test is the account '
            'of the fixture and the programmer that no number carries. '
            'The channel table ({{table:Instrumentation}}) lists the '
            'instrumentation at each location, including the '
            'measurement range — a shock is where an under-ranged '
            'accelerometer clips, and the table is where that is '
            'caught.'},
        *front_matter(objects, object_groups, '@basis:TimeHistory'),
        {'kind': 'text', 'text':
            '## The Events\n\n'
            'The measured transients ({{figure:Measured}}), with the '
            'window each spectrum is computed over marked across every '
            'channel and numbered. A shock is a single event and its '
            'whole character is in the trace: how long it lasted, how '
            'hard it hit, whether the article was still ringing when '
            'the next one landed, and whether any channel clipped. '
            'None of that survives into a spectrum, which is why the '
            'traces come first and are not decoration.'},
        *time_data_blocks(
            '@basis:TimeHistory', objects, object_groups,
            'Measured shock transients',
            'Measured {quantity} shock transients'),
        *motion_blocks,
        *spectral_blocks,
        {'kind': 'text', 'text':
            '## What Was Required\n\n'
            'The required response spectrum and the band around it '
            '({{figure:Shock specification}}). This is the whole of '
            'what the test had to achieve: a transient meets it by '
            'producing a shock response spectrum inside the band, '
            'however the machine and programmer arrived at that '
            'transient. It is shown alone before anything is compared '
            'against it, because it is the requirement rather than a '
            'result — and reading it first is what makes the '
            'comparison that follows a judgment instead of a '
            'picture.'},
        {'kind': 'plot', 'source': '@basis:ShockSpecification',
         'mode': 'curves', 'caption': 'Shock specification, with its '
         'tolerance band'},
        {'kind': 'text', 'text':
            '## What The Article Saw\n\n'
            'The measured shock response spectra against the '
            'requirement ({{figure:Measured shock response spectra}}), '
            'one curve per channel per event. The shock response '
            'spectrum is the peak response a single-degree-of-freedom '
            'oscillator would have reached at each natural frequency, '
            'so it says what the event did to a structure rather than '
            'what the acceleration did in time — which is why a '
            'requirement is written in these terms at all.\n\n'
            'The deviation bars ({{figure:SRS deviation}}) are the '
            'same comparison as a number: RMS across the band in '
            'decibels, one bar per channel per event, signed. Over '
            'the band the event was too hard and under it the event '
            'under-tested, and those are different faults — an '
            'over-test may have damaged the article, an under-test '
            'proves less than it claims. RMS across the band rather '
            'than a linear error because a spectrum spans decades on '
            'log axes: a linear reading follows whichever bands are '
            'loudest and goes blind to a large ratio error where the '
            'requirement is small.'},
        {'kind': 'plot', 'source': '@basis:Srs', 'mode': 'curves',
         'specification': '@basis:ShockSpecification',
         'record_caption': True,
         'caption': 'Measured shock response spectra against the '
         'specification'},
        # the judgment, not just the picture: RMS deviation across the
        # band in decibels, one bar per channel. This block lived in
        # the transient template until the SRS pair left that type —
        # the SRS is how a *shock* is judged, so its deviation reads
        # here and nowhere else.
        {'kind': 'bars', 'mode': 'srs',
         'source': '@basis:ShockSpecification', 'measured': '@basis:Srs',
         'caption': 'SRS deviation by channel'},
        # no kurtosis here (Brandon, 2026-08-24): a shock record is a
        # transient in a long quiet stretch and never reads near
        # three — the plate's own reads 16 to 27 over its windows —
        # so a band drawn at 2 to 4 would mark every channel of every
        # shock test in red and say nothing anyone could act on. The
        # toolbar toggle still offers the reading to anyone who wants
        # to look; what a *report* asserts is the judgment.
        {'kind': 'text', 'text':
            '## Conclusions\n\n'
            f'{ref(time, "num_shocks")} events were delivered and are '
            'documented above. Whether each was accepted is read off '
            '{{figure:Measured shock response spectra}} and '
            '{{figure:SRS deviation}} together: a channel whose '
            'spectrum sits inside the band met the requirement at '
            'that channel, and the deviation bars say by how much and '
            'in which direction where it did not. The traces in '
            '{{figure:Measured}} are what a disputed channel is '
            'settled against — a spectrum computed over a window that '
            'caught a neighboring event, or over a channel that '
            'clipped, will sit wrong in a way no spectral figure can '
            'reveal on its own.'},
    ])

sine_template

sine_template(objects: Mapping[str, Any], object_groups: Sequence[Mapping[str, Any]] | None = None) -> Report

A sine sweep test report: the tones, and the levels they reached.

After the front matter and the measured time histories (drawn without averaging frames, since nothing is averaged), each tone gets a figure of its extracted level against the requirement, and one bar chart gives the signed deviation of every tone at every control channel against a threshold of 3 dB, as the random and shock reports do. A kurtosis chart and conclusions follow. A Random and Sine project gets this report and a random report (PROJECT_TEMPLATES); when the project holds a random specification, this report says that the random is judged in its own report.

Source code in src/visualdynamics/core/report.py
def sine_template(objects: Mapping[str, Any],
                  object_groups: Sequence[Mapping[str, Any]] | None = None) -> Report:
    """A sine sweep test report: the tones, and the levels they reached.

    After the front matter and the measured time histories (drawn
    without averaging frames, since nothing is averaged), each tone
    gets a figure of its extracted level against the requirement, and
    one bar chart gives the signed deviation of every tone at every
    control channel against a threshold of 3 dB, as the random and
    shock reports do. A kurtosis chart and conclusions follow. A Random
    and Sine project gets this report and a random report
    (`PROJECT_TEMPLATES`); when the project holds a random
    specification, this report says that the random is judged in its
    own report.
    """
    time = '@basis:TimeHistory'

    def ref(name: str, field: str) -> str:
        return '{{' + f'{name}.{field}' + '}}'

    from .data import Specification

    # the sweep ran under a random environment: its own report judges
    # the random, and this one says so where the run is described
    # (2026-10-05, when the combined report retired)
    under_random = any(type(obj) is Specification
                       for obj in objects.values())

    blocks: list[dict[str, Any]] = [
        {'kind': 'text', 'text':
            '## Test Summary\n\n'
            'A sine sweep test was run: one or more tones swept across '
            'a frequency range at a controlled amplitude, so that the '
            'article sees each frequency in turn at a required level '
            'rather than all of them at once. '
            + ('The sweep ran under a random environment on the same '
               'shakers; the random is judged in its own report, and '
               'this one reads each tone with the random treated as the '
               'noise it is extracted from. '
               if under_random else '')
            + 'This report documents '
            'the instrumentation, the recording, the level each tone '
            'actually reached against what it was required to reach, '
            'and how far apart those two are.\n\n'
            f'The run was recorded on {ref(time, "num_channels")} '
            f'channels at {ref(time, "sample_rate")} over '
            f'{ref(time, "duration")}.'},
        {'kind': 'text', 'text':
            '## Test Article and Instrumentation\n\n'
            'The test geometry ({{figure:Test geometry}}) shows where '
            'the article was instrumented.'
            + dof_sentence(*dof_references(objects, object_groups,
                                           '@basis:TimeHistory'),
                           'in which direction it was driven',
                           'every measured direction')
            + ' The photographs '
            'record the article as it was actually instrumented, and '
            'the channel table ({{table:Instrumentation}}) lists what '
            'was at each location — for a swept test the control '
            'channels are the ones the levels below are read from, '
            'and the table is where they are identified.'},
        *front_matter(objects, object_groups, '@basis:TimeHistory'),
        {'kind': 'text', 'text':
            '## The Recording\n\n'
            'The measured time histories ({{figure:Measured}}). A '
            'sweep is read in the time domain first because its faults '
            'live there: a tone that lost control, a dwell that sat '
            'longer than intended, an amplitude that stepped rather '
            'than swept, or a channel that dropped out partway. The '
            'levels extracted below are demodulated from exactly this '
            'record, so anything visible here is inherited by every '
            'figure after it.'},
        *time_data_blocks(
            '@basis:TimeHistory', objects, object_groups,
            'Measured time histories',
            'Measured {quantity} time histories', averaging=False),
    ]
    blocks += _sine_level_blocks(objects)
    blocks += [
        {'kind': 'text', 'text':
            '## The Shape Of The Recording\n\n'
            'Pearson kurtosis ({{figure:Pearson kurtosis}}) reads the '
            'distribution of each channel rather than its level — '
            'three is Gaussian. A swept sine is not Gaussian and is '
            'not meant to be: a pure tone reads 1.5, and a sweep '
            'mixed with a random background sits between. What the '
            'figure is watched for is a channel far from its '
            'neighbors, which is a channel that clipped or rattled '
            'while the tracked level was extracted from it '
            'regardless.'},
        kurtosis_block(),
        {'kind': 'text', 'text':
            '## Conclusions\n\n'
            'Which tones were run and how much of each required range '
            'was covered is read off the per-tone figures above, where '
            'the extracted level spans exactly the frequencies the '
            'sweep reached. How closely each was held is read off '
            '{{figure:Sine level deviation}}, tone by tone and channel '
            'by channel. Where a tone falls short of its range or a '
            'channel sits outside its band, the recording in '
            '{{figure:Measured}} is what says whether the cause was '
            'the article, the control, or a run that stopped — and '
            'the kurtosis figure is what says whether the recording '
            'those levels were extracted from was clean enough to '
            'trust.'},
    ]
    return Report('Sine Sweep Test Report', blocks)

sysid_template

sysid_template(objects: Mapping[str, Any], object_groups: Sequence[Mapping[str, Any]] | None = None) -> Report

A system identification report: the measured plant, and its quality.

The order is the one every report follows: the front matter (geometry, DOF scenes, photographs, channel table), then the time data, then the spectra, then the computed results. The time data is the ambient (noise) recording first and the driven one after it, each drawn with its own averaging frames; a spectral package has no recordings, and the block stays an unbound slot that is not rendered. The spectra are the driven and ambient densities overlaid per channel and the magnitude of every FRF. The computed results are the multiple coherence map, which shows where the drives account for the responses, the signal-to-noise ratio per line and its RMS per channel, which show how far the driven measurement is above the noise floor, and the kurtosis of both recordings.

The two densities and the two recordings are bound by name, the louder as the driven one and the quieter as the ambient one, since no symbolic selector picks "the second PSD".

Source code in src/visualdynamics/core/report.py
def sysid_template(objects: Mapping[str, Any],
                   object_groups: Sequence[Mapping[str, Any]] | None = None) -> Report:
    """A system identification report: the measured plant, and its quality.

    The order is the one every report follows: the front matter
    (geometry, DOF scenes, photographs, channel table), then the time
    data, then the spectra, then the computed results. The time data is
    the ambient (noise) recording first and the driven one after it,
    each drawn with its own averaging frames; a spectral package has no
    recordings, and the block stays an unbound slot that is not
    rendered. The spectra are the driven and ambient densities overlaid
    per channel and the magnitude of every FRF. The computed results
    are the multiple coherence map, which shows where the drives
    account for the responses, the signal-to-noise ratio per line and
    its RMS per channel, which show how far the driven measurement is
    above the noise floor, and the kurtosis of both recordings.

    The two densities and the two recordings are bound by name, the
    louder as the driven one and the quieter as the ambient one, since
    no symbolic selector picks "the second PSD".
    """
    import numpy as np

    from .data import Psd, Specification, TimeHistory
    densities = [(name, obj) for name, obj in objects.items()
                 if isinstance(obj, Psd)
                 and not isinstance(obj, Specification)]
    # the ratio binds the pair by name, louder over quieter — there is
    # no symbolic selector for "the second Psd", and a generated
    # template knows its own project's names
    loud_density = quiet_density = ''
    if len(densities) >= 2:
        ranked = sorted(
            densities,
            key=lambda pair: -float(np.nanmean(np.real(
                np.asarray(pair[1].ordinate)))))
        loud_density, quiet_density = ranked[0][0], ranked[1][0]
    # the two streams bind by name the same way: the quiet one is the
    # ambient (noise) recording, the loud one the driven, and each
    # draws with its own averaging frames so the figure says which
    # part of each stream the densities came from
    histories = [(name, obj) for name, obj in objects.items()
                 if isinstance(obj, TimeHistory)]
    if len(histories) >= 2:
        by_level = sorted(
            histories,
            key=lambda pair: float(np.nanmean(np.abs(
                np.asarray(pair[1].ordinate)))))
        # both streams are read for shape, not just the driven one
        # (Brandon, 2026-08-24): an ambient recording that is not
        # Gaussian is a noisy room or a rattling fixture, and a drive
        # that is not is a different fault entirely — the two are
        # worth telling apart, and the pair of figures is how
        kurtosis_blocks = [
            kurtosis_block(
                by_level[0][0],
                'Pearson kurtosis of the ambient recording, by channel '
                '— 3 is Gaussian; a noise floor that is not says the '
                'room or the fixture, not the article'),
            kurtosis_block(
                by_level[-1][0],
                'Pearson kurtosis of the excitation, by channel — 3 is '
                'Gaussian; above the band the drive carries peaks the '
                'spectrum does not predict, below it the drive is '
                'clipped')]
        time_blocks: list[dict[str, Any]] = [
            *time_data_blocks(
                by_level[0][0], objects, object_groups,
                'Ambient (noise) time history, with the frames '
                'the noise densities are averaged over',
                'Ambient (noise) {quantity} time histories, with the '
                'frames the noise densities are averaged over'),
            *time_data_blocks(
                by_level[-1][0], objects, object_groups,
                'Excitation time history, with the frames the '
                'driven densities are averaged over',
                'Excitation {quantity} time histories, with the '
                'frames the driven densities are averaged over')]
    else:
        # a spectral package has no streams; the slots keep the outline
        kurtosis_blocks = [kurtosis_block()]
        time_blocks = [
            {'kind': 'plot', 'source': '@basis:TimeHistory',
             'mode': 'curves',
             'caption': 'Measured time histories — the ambient stream, '
             'then the driven one'}]
    time = '@basis:TimeHistory'
    frf = '@basis:Frf'

    def ref(name: str, field: str) -> str:
        return '{{' + f'{name}.{field}' + '}}'

    dofs_source = ('@basis:TimeHistory'
                   if any(isinstance(obj, TimeHistory)
                          for obj in objects.values())
                   else '@basis:Frf')
    excitation, response = dof_references(objects, object_groups,
                                          dofs_source)
    return Report('System ID Report', [
        {'kind': 'text', 'text':
            '## Test Summary\n\n'
            'A system identification was run to measure the plant — '
            'the frequency response of each control channel to each '
            'drive — that a subsequent test will be controlled '
            'through. This report documents the instrumentation, the '
            'two recordings the identification was made from, the '
            'plant it produced, and the three readings that say '
            'whether to believe it.\n\n'
            f'The identification measured {ref(frf, "num_records")} '
            f'frequency response functions over '
            f'{ref(frf, "num_channels")} response channels, at a '
            f'resolution of {ref(frf, "frequency_resolution")} out to '
            f'{ref(frf, "max_frequency")}. The recordings were taken '
            f'at {ref(time, "sample_rate")} and each analyzed over '
            f'{ref(time, "num_frames")} frames of '
            f'{ref(time, "frame_length")} samples with a '
            f'{ref(time, "window")} window at {ref(time, "overlap")} '
            'overlap.'},
        {'kind': 'text', 'text':
            '## Test Article and Instrumentation\n\n'
            'The test geometry ({{figure:Test geometry}}) shows every '
            'measurement location and the wireframe the article is '
            'drawn on.'
            + dof_sentence(excitation, response,
                           'in which direction the article was driven',
                           'every measured direction')
            + (' Together they are the identification\'s reach, since '
               'a plant is only measured where something was driven '
               'and something was listening.' if excitation and response
               else '')
            + ' The photographs record the article as it '
            'was actually instrumented, which is the only account of '
            'cable routing, fixture condition and sensor mounting '
            'that survives the test. The channel table '
            '({{table:Instrumentation}}) lists what was at each '
            'location: sensor, sensitivity, and calibration '
            'status.'},
        *front_matter(objects, object_groups, dofs_source),
        {'kind': 'text', 'text':
            '## The Recordings\n\n'
            'A system identification is made from two recordings, and '
            'both are shown because the answer is a ratio of them. '
            'The ambient recording is taken with the shakers still: '
            'it is the noise floor of the whole measurement chain — '
            'the room, the amplifiers, the cabling and the '
            'instrumentation — and it sets the level below which the '
            'driven measurement means nothing. The excitation '
            'recording is the same channels with the drives running. '
            'Each is drawn one figure per quantity, with the frames '
            'its spectra are averaged over marked on it, so the '
            'figures say which stretch of each recording the rest of '
            'this report is computed from.'},
        *time_blocks,
        {'kind': 'text', 'text':
            '## The Measured Plant\n\n'
            'The densities of the two recordings are drawn together '
            'per channel, the driven level solid and the ambient one '
            'dashed in the same color: the distance between them at '
            'any frequency is the margin the identification had to '
            'work with there, and where they meet there was nothing '
            'to measure. The plant itself follows, the magnitude of '
            'every frequency response function, which is the '
            'deliverable of the run and what a controller will '
            'invert.'},
        *paired_density_blocks(
            loud_density, quiet_density, objects,
            'Excitation and ambient densities overlaid, per channel',
            'Excitation and ambient {quantity} densities, channel by '
            'channel — the driven level solid, the ambient one dashed'),
        # flat, as every figure in this report is (Brandon,
        # 2026-10-05: the 3-D stage made the files too large)
        {'kind': 'plot', 'source': '@basis:Frf', 'mode': 'curves',
         'caption': 'The measured plant: every FRF magnitude'},
        {'kind': 'text', 'text':
            '## Whether To Believe It\n\n'
            'Two readings judge the identification, and they fail in '
            'different ways. Multiple coherence '
            '({{figure:Multiple coherence}}) says how much of each '
            'response the drives account for: at one, the channel '
            'moved because the shakers moved it; well below one, '
            'something else did, and the frequency response measured '
            'there is a coincidence rather than a plant. A dark row '
            'is a suspect channel and a dark band a poorly-driven '
            'frequency range.\n\n'
            'Signal to noise ({{figure:Signal to noise}}) says '
            'whether there was a measurement at all. The driven '
            'recording holds the signal and the same noise the '
            'ambient one recorded, so the signal\'s power is the '
            'driven density less the ambient, and the figure is that '
            'over the ambient, in decibels: the margin above the '
            'noise floor, channel by channel and line by line. A line '
            'with nothing above the floor is not drawn. The RMS '
            'signal to noise ({{figure:RMS signal to noise}}) is the '
            'same ratio taken once per channel over the whole band, '
            'from the area under each density, with the channels '
            'under the threshold marked. It ranks the channels; the '
            'figure before it shows where in the band a channel was '
            'weak. Where either approaches zero the identification is '
            'measuring the room. The coherence and the '
            'signal-to-noise are worth reading together. A channel '
            'can be well above its noise floor and still incoherent, '
            'which points at a rattle or a loose sensor rather than '
            'at the level.'},
        # the map (the app's spectrogram reading): frequency across,
        # channel down, coherence as color (Brandon, 2026-10-05)
        {'kind': 'plot', 'source': '@basis:MultipleCoherence',
         'mode': 'map',
         'caption': 'Multiple coherence, every channel: frequency '
         'across, channel down'},
        {'kind': 'plot', 'mode': 'ratio',
         'source': loud_density, 'floor': quiet_density,
         'caption': 'Signal to noise: the driven density less the '
         'ambient, over the ambient, per channel, in decibels'},
        {'kind': 'bars', 'mode': 'snr',
         'source': loud_density, 'floor': quiet_density,
         'caption': 'RMS signal to noise: each channel\'s driven power '
         'less its ambient, over the ambient, across the whole band, '
         'in decibels'},
        {'kind': 'text', 'text':
            '## The Shape Of The Recordings\n\n'
            'A spectrum says nothing about the shape of the '
            'distribution that produced it: two recordings with '
            'identical densities can be a smooth hiss and a train of '
            'rare hard peaks. Pearson kurtosis is the number that '
            'tells them apart — three for a Gaussian — and both '
            'recordings are read for it, because they fail '
            'differently. An ambient recording that is not Gaussian '
            'is a noisy room, a rattling fixture or an intermittent '
            'connection, none of which the level alone would show. A '
            'drive that is not Gaussian is a drive carrying peaks its '
            'own spectrum does not predict, or one being clipped by '
            'the amplifier — and a clipped drive makes a plant that '
            'is not the article\'s.'},
        *kurtosis_blocks,
        {'kind': 'text', 'text':
            '## Conclusions\n\n'
            'What this identification is good for is read off the '
            'three judgments above. The band it covers is the band '
            'the plant was measured over, out to '
            f'{ref(frf, "max_frequency")} at '
            f'{ref(frf, "frequency_resolution")}; within that band it '
            'resolves the channels whose coherence sits near one and '
            'whose signal-to-noise stands clear of zero, and it does '
            'not resolve the rest. A controller inverting this plant '
            'will be as good as the channels it was measured on, so '
            'any channel marked out by the coherence, the '
            'signal-to-noise or the kurtosis figures is one to fix '
            'before running the test this identification was made '
            'for, rather than one to control through.'},
    ])

transient_template

transient_template(objects: Mapping[str, Any], object_groups: Sequence[Mapping[str, Any]] | None = None) -> Report

A transient replication report: the target waveform and the measured one.

A transient test has a target waveform, a measured one, and the difference between them, so the report is built around comparing the two. After the front matter come the transient specification, the measured control response, an overlay of the first playing on the target waveform channel by channel (compared by eye, sample against sample), the drive forces, and a scalogram of the control response. Two numerical readings follow: the control PSDs against the PSD specification with the RMS error per control channel, and the waveform error per control channel and per playing. There is no accepted tolerance for how closely a replicated transient must follow its target, so the threshold on the waveform chart is a visual reference, not a pass mark. A kurtosis chart and conclusions end the report.

Source code in src/visualdynamics/core/report.py
def transient_template(objects: Mapping[str, Any],
                       object_groups: Sequence[Mapping[str, Any]] | None = None) -> Report:
    """A transient replication report: the target waveform and the measured one.

    A transient test has a target waveform, a measured one, and the
    difference between them, so the report is built around comparing
    the two. After the front matter come the transient specification,
    the measured control response, an overlay of the first playing on
    the target waveform channel by channel (compared by eye, sample
    against sample), the drive forces, and a scalogram of the control
    response. Two numerical readings follow: the control PSDs against
    the PSD specification with the RMS error per control channel, and
    the waveform error per control channel and per playing. There is no
    accepted tolerance for how closely a replicated transient must
    follow its target, so the threshold on the waveform chart is a
    visual reference, not a pass mark. A kurtosis chart and conclusions
    end the report.
    """
    time = '@basis:TimeHistory'

    def ref(name: str, field: str) -> str:
        return '{{' + f'{name}.{field}' + '}}'

    return Report('Transient Test Report', [
        {'kind': 'text', 'text':
            '## Test Summary\n\n'
            'A transient replication test was run: the controller was '
            'given a waveform and asked to reproduce it at the control '
            'points, sample by sample, by inverting the structure\'s '
            'own response. That is what separates it from a shock '
            'test, which names a required response spectrum and leaves '
            'the waveform to the machine. This report documents the '
            'instrumentation, the waveform that was asked for, what '
            'the article actually saw, and the two readings of how '
            'far apart those are.\n\n'
            f'The run was recorded on {ref(time, "num_channels")} '
            f'channels at {ref(time, "sample_rate")} over '
            f'{ref(time, "duration")}.'},
        {'kind': 'text', 'text': instrumentation_text(objects, object_groups,
                                                      time)},
        *front_matter(objects, object_groups, time),
        {'kind': 'text', 'text':
            '## Specification\n\nThe waveform the controller was asked '
            'to reproduce at each control point '
            '({{figure:Transient specification}}). A transient test '
            'names the motion itself, sample by sample — where a shock '
            'test names a response spectrum and leaves the waveform to '
            'the controller.'},
        {'kind': 'plot', 'source': '@basis:TransientSpecification',
         'mode': 'curves', 'caption': 'Transient specification'},
        {'kind': 'text', 'text':
            '## Control\n\nWhat the article actually did '
            '({{figure:Measured control response}}), against what was '
            'asked of it. The controller played the specification over '
            'and over, so the record is cut back into those repeats '
            'before anything is compared — the frame is the '
            "specification's own length, the repeats run back to back, "
            'and the window is rectangular, because a taper would '
            'distort the very waveform being replicated.\n\n'
            'The overlay ({{figure:First playing against the target '
            'waveform}}) puts one playing directly over the waveform '
            'it was aiming at, channel by channel. It is the only '
            'figure here that shows the comparison the way it is '
            'actually judged — by eye, sample against sample — and '
            'amplitude, phase and shape errors each look different on '
            'it in a way no single number distinguishes.\n\n'
            'The drive forces ({{figure:Drive forces}}) are what the '
            'controller had to produce to get that response. They are '
            'reported because a replication that succeeded by driving '
            'the shakers to their limit is one that will not repeat '
            'on a heavier article or a colder day, and because a '
            'drive that clipped is visible here and nowhere else.'},
        {'kind': 'plot', 'source': '@basis:TimeHistory', 'mode': 'curves',
         'select': 'dim:acceleration',
         'caption': 'Measured control response'},
        # the comparison itself: a playing over the waveform it was
        # aiming at, channel by channel — the app's overlay view
        {'kind': 'plot', 'mode': 'overlay',
         'source': '@basis:TimeHistory',
         'specification': '@basis:TransientSpecification',
         'caption': 'First playing against the target waveform'},
        {'kind': 'plot', 'source': '@basis:TimeHistory', 'mode': 'curves',
         'select': 'dim:force',
         'caption': 'Drive forces the controller produced'},
        {'kind': 'text', 'text':
            '## Where The Energy Is In Time\n\n'
            'The scalogram ({{figure:Control scalogram}}) reads the '
            'control response against time *and* frequency at once — '
            'time across, frequency receding, amplitude standing up '
            'out of the floor. A transient is the reading\'s natural '
            'subject: the spectra below say what frequencies the event '
            'contained and average away *when*, and for a shock\'s '
            'ring-down or a resonance the event excited on its way '
            'through, when is the story. One channel is drawn, the '
            'way the application\'s wavelet view reads one at a '
            'time; the lowest rows lean on the record\'s ends for '
            'the span the caption gives, because a low-frequency '
            'wavelet is long, and inside that span the picture is '
            'shaped by where the record was cut rather than by the '
            'event.'},
        {'kind': 'plot', 'source': '@basis:TimeHistory',
         'mode': 'scalogram', 'select': 'dim:acceleration',
         'caption': 'Control scalogram'},
        {'kind': 'text', 'text':
            '## Level\n\nThe comparison read as spectra '
            '({{figure:Control spectra against the specification}}). A '
            'PSD is already an average over the playings — it is taken '
            'over the very frames the events are — so there is one '
            'curve per channel here however many times the waveform '
            'was played.\n\nThis is the scale with shape and phase '
            'divided out ({{figure:RMS error by control channel}}): a '
            'channel can hold the right level and still follow the '
            'waveform badly, which is why the waveform reading follows '
            'and neither stands in for the other.'},
        {'kind': 'plot', 'source': '@basis:Psd',
         'specification': '@basis:Specification', 'mode': 'curves',
         'record_caption': True,
         'caption': 'Control spectra against the specification'},
        {'kind': 'bars', 'mode': 'error', 'source': '@basis:Specification',
         'measured': '@basis:Psd',
         'caption': 'RMS error by control channel'},
        {'kind': 'text', 'text':
            '## Replication\n\nHow far each control channel is from '
            'the waveform it was asked for '
            '({{figure:Waveform error by control channel}}): the '
            'difference between the two waveforms against the size of '
            'the target. Amplitude, phase and shape all move it, and '
            'it is what the controller is minimizing — the level above '
            'is the scale alone; this is everything at once.\n\nOne '
            'bar per channel per playing of the waveform, and none of '
            'them singled out — which repeat was the bad one depends '
            'on the reading you care about and on what the article is '
            'for, so they are all here. The threshold is a line for '
            'the eye and nothing more: there is no accepted tolerance '
            'on how closely a replicated transient must follow its '
            'target.'},
        {'kind': 'bars', 'mode': 'waveform',
         'source': '@basis:TransientSpecification',
         'measured': '@basis:TimeHistory',
         'caption': 'Waveform error by control channel'},
        kurtosis_block(),
        {'kind': 'text', 'text':
            '## The Shape Of The Record\n\n'
            'Pearson kurtosis ({{figure:Pearson kurtosis}}) reads the '
            'record\'s distribution rather than its level or its '
            'shape in time — three is Gaussian. A replicated transient '
            'is not Gaussian and is not meant to be: it is a waveform '
            'played over and over, so the reading sits well above '
            'three and is expected to. What it is watched for is '
            'change: a channel far from its neighbors, or a run far '
            'from the last one, is a channel where something clipped '
            'or rattled rather than reproduced.'},
        {'kind': 'text', 'text':
            '## Conclusions\n\n'
            'How closely the run reproduced its specification is read '
            'two ways, and both are here because they answer '
            'different questions. {{figure:RMS error by control '
            'channel}} is the level with shape and phase divided out: '
            'a channel can hold exactly the right level and still '
            'follow the waveform badly. {{figure:Waveform error by '
            'control channel}} is everything at once — amplitude, '
            'phase and shape — which is what the controller was '
            'minimizing, and it is reported per playing because which '
            'repeat was the bad one depends on what the article is '
            'for.\n\n'
            'There is no accepted tolerance on how closely a '
            'replicated transient must follow its target, so no line '
            'here is a pass mark: the threshold on the waveform chart '
            'is a line for the eye. What the figures support is a '
            'comparison — between channels, between playings, and '
            'against whatever was accepted the last time this article '
            'was run.'},
    ])

control_channels_of

control_channels_of(spec: Any) -> list[str]

The control channels a specification names, as comparison figures label them.

These are the channels of its autospectra, in its own order; a cross term has no measured response to compare with. A sine sweep specification's control channels are its response DOFs. Empty for anything that is not a specification.

Source code in src/visualdynamics/core/report.py
def control_channels_of(spec: Any) -> list[str]:
    """The control channels a specification names, as comparison figures label them.

    These are the channels of its autospectra, in its own order; a
    cross term has no measured response to compare with. A sine sweep
    specification's control channels are its response DOFs. Empty for
    anything that is not a specification.
    """
    from .sine import SineSweepSpecification

    if isinstance(spec, SineSweepSpecification):
        # a sweep's control channels are named outright
        return list(dict.fromkeys(str(dof) for dof in spec.response_dof))
    if spec is None or not hasattr(spec, 'record_pair'):
        return []
    labels: list[str] = []
    for i in range(spec.num_records):
        response, reference = spec.record_pair(i)
        if response == reference and response not in labels:
            labels.append(response)
    return labels

control_channel_labels

control_channel_labels(objects: Mapping[str, Any], object_groups: Sequence[Mapping[str, Any]] | None, token: str = '@basis:Specification') -> list[str]

The control channels of the specification that token binds.

Labeled as the comparison figure labels them (control_channels_of); each gets its own figure in the random report. Empty when the token binds nothing yet, in which case the template writes one figure with a channel selector.

Source code in src/visualdynamics/core/report.py
def control_channel_labels(objects: Mapping[str, Any],
                           object_groups: Sequence[Mapping[str, Any]] | None,
                           token: str = '@basis:Specification') -> list[str]:
    """The control channels of the specification that `token` binds.

    Labeled as the comparison figure labels them (`control_channels_of`);
    each gets its own figure in the random report. Empty when the token
    binds nothing yet, in which case the template writes one figure
    with a channel selector.
    """
    # Brandon, 2026-09-18: one figure per control channel rather than
    # one figure with a drop-down.
    name = resolve_binding(token, objects, object_groups)
    return control_channels_of(objects[name] if name and name in objects
                               else None)

channel_grid

channel_grid(labels: Sequence[str], geometry: Any = None) -> dict[str, Any]

The grid the random report lays many control channels out on.

There is a row per node, in the order the channels first name them, and a column per direction. With a geometry that can place a channel (Geometry.dof_direction), the columns are the global axes ('Global X', 'Global Y', 'Global Z'), a channel goes under the axis its measured direction is nearest, and the angle it is off that axis is noted when it is half a degree or more. Without a geometry, or for a node the geometry cannot place, the columns are the DOF's own direction letters, and a channel with no direction at all (a node number alone) goes in a 'Channel' column. Only the columns in use appear, in that order. When no channel has a direction, the channels are laid across one row, a column each, headed by their own names.

Parameters:

Name Type Description Default
labels sequence of str

The control channels, as control_channels_of lists them.

required
geometry Geometry

The geometry the channels are measured on.

None

Returns:

Type Description
dict

'columns', the column headings; 'rows', one dict per node with 'label' and 'cells', a list per column of the channels in it, each {'channel': str, 'note': str}.

Source code in src/visualdynamics/core/report.py
def channel_grid(labels: Sequence[str],
                 geometry: Any = None) -> dict[str, Any]:
    """The grid the random report lays many control channels out on.

    There is a row per node, in the order the channels first name
    them, and a column per direction. With a geometry that can place a
    channel (`Geometry.dof_direction`), the columns are the global
    axes ('Global X', 'Global Y', 'Global Z'), a channel goes under the
    axis its measured direction is nearest, and the angle it is off
    that axis is noted when it is half a degree or more. Without a
    geometry, or for a node the geometry cannot place, the columns are
    the DOF's own direction letters, and a channel with no direction at
    all (a node number alone) goes in a 'Channel' column. Only the
    columns in use appear, in that order. When no channel has a
    direction, the channels are laid across one row, a column each,
    headed by their own names.

    Parameters
    ----------
    labels : sequence of str
        The control channels, as `control_channels_of` lists them.
    geometry : Geometry, optional
        The geometry the channels are measured on.

    Returns
    -------
    dict
        'columns', the column headings; 'rows', one dict per node with
        'label' and 'cells', a list per column of the channels in it,
        each {'channel': str, 'note': str}.
    """
    # Brandon, 2026-09-19: global axes when the geometry can place a
    # channel.
    import numpy as np

    from .data import parse_dof

    placed: list[tuple[str, tuple[int, int], str, str]] = []
    for label in labels:
        node, direction = parse_dof(label)
        row = f'Node {node}' if node is not None else str(label)
        letter = str(direction).upper().lstrip('R')[:1]
        vector = geometry.dof_direction(label) if geometry is not None else None
        note = ''
        if vector is not None:
            axis = int(np.argmax(np.abs(vector)))
            angle = float(np.degrees(np.arccos(min(1.0, abs(float(vector[axis]))))))
            column = (0, axis)
            if angle >= 0.5:
                note = f'{angle:.0f}° off global {GRID_AXES[axis]}'
        elif letter in GRID_AXES:
            column = (1, GRID_AXES.index(letter))
        else:
            column = (2, 0)
        placed.append((row, column, str(label), note))
    columns = sorted({column for _row, column, _label, _note in placed})
    if columns == [(2, 0)]:
        # channels with no direction at all — the virtual responses of
        # a transformation, recorded as '1', '2', '3' — have no node to
        # stack by and no axis to sort into: they lie across one row, a
        # column each, named by themselves (Brandon, 2026-10-02: nine
        # sweeps on three such channels made twenty-seven figures the
        # page's width, wanted as nine rows of three)
        return {'columns': [label for _row, _col, label, _note in placed],
                'rows': [{'label': '', 'cells': [[{'channel': label, 'note': note}]
                                                 for _row, _col, label, note in placed]}]}
    names = {0: 'Global {}', 1: '{}', 2: 'Channel'}
    headings = [names[kind].format(GRID_AXES[axis] if kind < 2 else '')
                for kind, axis in columns]
    rows: list[dict[str, Any]] = []
    for row, column, label, note in placed:
        entry = next((r for r in rows if r['label'] == row), None)
        if entry is None:
            entry = {'label': row, 'cells': [[] for _ in columns]}
            rows.append(entry)
        entry['cells'][columns.index(column)].append(
            {'channel': label, 'note': note})
    return {'columns': headings, 'rows': rows}

prune_unbound

prune_unbound(report: Report, objects: Mapping[str, Any], object_groups: Sequence[Mapping[str, Any]] | None = None) -> Report

Remove the figures and sections the project cannot fill.

Every unbound figure is removed, and so is every section with no bound figure.

A section is a text block opening with a ## heading and what follows it up to the next heading; its figures are the blocks in it that are not text. When none of them can be resolved (the section's objects are not in the project), the whole section is removed, heading and text included, so there is no heading over nothing. When some can, only the ones that cannot are removed, so the editor shows no unbound cards for a run that has no geometry. A section with no figures (the summary, the conclusions) stays, and so does a skeleton section's own text block (one carrying 'section', report feature skeleton_sections) wherever it sits. An empty project keeps the whole outline, since there the template itself is being read. report is changed in place and returned.

Source code in src/visualdynamics/core/report.py
def prune_unbound(report: Report, objects: Mapping[str, Any],
                  object_groups: Sequence[Mapping[str, Any]] | None = None
                  ) -> Report:
    """Remove the figures and sections the project cannot fill.

    Every unbound figure is removed, and so is every section with no
    bound figure.

    A section is a text block opening with a `## ` heading and what
    follows it up to the next heading; its figures are the blocks in it
    that are not text. When none of them can be resolved (the
    section's objects are not in the project), the whole section is
    removed, heading and text included, so there is no heading over
    nothing. When some can, only the ones that cannot are removed, so
    the editor shows no unbound cards for a run that has no geometry.
    A section with no figures (the summary, the conclusions) stays, and
    so does a skeleton section's own text block (one carrying
    'section', report feature `skeleton_sections`) wherever it sits. An
    empty project keeps the whole outline, since there the template
    itself is being read. `report` is changed in place and returned.
    """
    # Brandon, 2026-09-19: once the project holds anything, what it
    # cannot fill is removed.
    if not objects:
        return report
    keys = ('source', 'geometry', 'dofs_source', 'shapes', 'measured',
            'specification')

    def bound(block):
        needed = [block.get(key) for key in keys if block.get(key)]
        return all(resolve_binding(name, objects, object_groups) in objects
                   for name in needed)

    def heading(block):
        return (block.get('kind') == 'text'
                and block.get('text', '').lstrip().startswith('## '))

    sections: list[list[int]] = []
    for i, block in enumerate(report.blocks):
        if heading(block) or not sections:
            sections.append([])
        sections[-1].append(i)
    doomed = []
    for section in sections:
        figures = [i for i in section
                   if report.blocks[i].get('kind') != 'text']
        if figures and not any(bound(report.blocks[i]) for i in figures):
            # a skeleton section's own words are the author's, and
            # never go with the figures around them
            doomed.extend(i for i in section
                          if 'section' not in report.blocks[i])
        else:
            doomed.extend(i for i in figures if not bound(report.blocks[i]))
    report.remove(doomed)
    return report

random_template

random_template(objects: Mapping[str, Any], object_groups: Sequence[Mapping[str, Any]] | None = None) -> Report

A random vibration report: the specification, the measurement, and how they differ.

The pass/fail box comes first, under the title, read off the octave-band comparison. Then the order every report follows: the front matter (geometry, the DOF scenes with excitations first, photographs, the channel table), the time data, the spectra, and the computed results. The specification is not drawn on its own: it is the target and the shaded limits behind each control comparison, one figure per control channel (a grid past GRID_ABOVE channels). The RMS error and the band outside the abort limits follow as bar charts, then the same comparison on octave bands. The data quality checks, multiple coherence and kurtosis, come last, because they are how a real exceedance is told from a bad channel once the comparison has raised the question.

Sources bind symbolically, like the modal template's, and the report is passed through prune_unbound, so a run with no geometry still renders without empty figures.

A Random and Sine project gets this report and a sine report (PROJECT_TEMPLATES). When the project holds a sine sweep specification, this report adds what a reader of the random half needs to know: the control spectra include the sweep's power across the band it swept, so a channel reading high there may be the tone, and a kurtosis under three comes from the sweep, not from clipping.

Source code in src/visualdynamics/core/report.py
def random_template(objects: Mapping[str, Any], object_groups: Sequence[Mapping[str, Any]] | None = None) -> Report:
    """A random vibration report: the specification, the measurement, and how they differ.

    The pass/fail box comes first, under the title, read off the
    octave-band comparison. Then the order every report follows: the
    front matter (geometry, the DOF scenes with excitations first,
    photographs, the channel table), the time data, the spectra, and
    the computed results. The specification is not drawn on its own:
    it is the target and the shaded limits behind each control
    comparison, one figure per control channel (a grid past
    `GRID_ABOVE` channels). The RMS error and the band outside the
    abort limits follow as bar charts, then the same comparison on
    octave bands. The data quality checks, multiple coherence and
    kurtosis, come last, because they are how a real exceedance is
    told from a bad channel once the comparison has raised the
    question.

    Sources bind symbolically, like the modal template's, and the
    report is passed through `prune_unbound`, so a run with no
    geometry still renders without empty figures.

    A Random and Sine project gets this report and a sine report
    (`PROJECT_TEMPLATES`). When the project holds a sine sweep
    specification, this report adds what a reader of the random half
    needs to know: the control spectra include the sweep's power across
    the band it swept, so a channel reading high there may be the tone,
    and a kurtosis under three comes from the sweep, not from clipping.
    """
    # The standing order (Brandon, 2026-08-23). One combined random and
    # sine report until 2026-10-05.
    from .sine import SineSweepSpecification

    time = '@basis:TimeHistory'
    psd = '@basis:Psd'
    coherence = '@basis:MultipleCoherence'
    # the run carried a sweep under the random: said where it changes
    # how a figure reads, and the level beside the verdict named as the
    # random half's, as the combined report named it (2026-09-26)
    swept = any(isinstance(obj, SineSweepSpecification)
                for obj in objects.values())

    def ref(name: str, field: str) -> str:
        return '{{' + f'{name}.{field}' + '}}'

    def present(token: str) -> bool:
        """Whether the prose may speak of what a token binds: yes in
        an empty project (the outline), otherwise only when it binds.
        A reference to what is not there stays on the page as written
        — the page's rule for an unmatched reference, right for an
        author and wrong for a reader (Brandon, 2026-09-19) — so the
        prose says only what the project holds."""
        return not objects or resolve_binding(token, objects, object_groups) in objects

    return prune_unbound(Report('Random Vibration Test Report', [
        # the verdict first, under the title: whether the environment
        # passed, read off the octave-band comparison, so a reader
        # opening the report knows before reading a word (Brandon,
        # 2026-09-20)
        {'kind': 'verdict', 'source': '@basis:OctaveSpecification',
         'measured': '@basis:OctavePsd',
         **({'level_label': 'Random test level'} if swept else {})},
        {'kind': 'text', 'text':
            '## Test Summary\n\n'
            'A random vibration test was run: the article was driven '
            'with a broadband random excitation held to a required '
            'power spectral density at the control channels'
            + ('. A sine sweep ran under it on the same shakers; the '
               'sweep is judged in its own report, and this one judges '
               'the random. This '
               if swept else '. This ')
            + 'report documents the instrumentation, the record the '
            'analysis was made from, the specification the run was '
            'controlled to, how closely each control channel held it, '
            'and the checks that say whether those numbers can be '
            'trusted.\n\n'
            f'The run was recorded on {ref(time, "num_channels")} '
            f'channels at {ref(time, "sample_rate")} over '
            f'{ref(time, "duration")}. The control spectra are '
            f'averaged over {ref(time, "num_frames")} frames of '
            f'{ref(time, "frame_length")} samples with a '
            f'{ref(time, "window")} window at {ref(time, "overlap")} '
            'overlap'
            + (f', giving {ref(psd, "frequency_resolution")} resolution '
               f'out to {ref(psd, "max_frequency")}.' if present(psd)
               else '.')},
        {'kind': 'text', 'text': instrumentation_text(objects, object_groups,
                                                      time)},
        *front_matter(objects, object_groups, time),
        {'kind': 'text', 'text':
            '## Measured Data\n\nThe measured time histories, one '
            'figure per quantity, with the frames a PSD is averaged '
            'over marked on them ({{figure:Measured}}) — which part '
            'of the run was analyzed, and under what window.'},
        *time_data_blocks(
            time, objects, object_groups,
            'Measured time histories, with the frames the spectra '
            'are averaged over',
            'Measured {quantity} time histories, with the frames '
            'the spectra are averaged over'),
        *_random_control_blocks(objects, object_groups),
        *([{'kind': 'text', 'text':
            '## The Sweep Under the Random\n\n'
            'The control spectra above are of the whole recording, '
            'sweep included: a tone adds its power across the band it '
            'swept, so a control channel reading high there, and its RMS '
            'error and band outside abort with it, may be the tone '
            'rather than the random. The tone itself is judged in the '
            'sine report, along its own trajectory, with the random '
            'treated as the noise it is extracted from.'}]
          if swept else []),
        {'kind': 'text', 'text':
            '## Data Quality\n\n'
            + ('Two checks stand behind every number above, and a '
               'channel that fails either is one whose exceedance may '
               'be the measurement rather than the article.\n\n'
               if present(coherence) else
               'One check stands behind every number above, and a '
               'channel that fails it is one whose exceedance may be '
               'the measurement rather than the article.\n\n')
            + ('Multiple coherence ({{figure:Multiple coherence}}) says '
               'how much of each response the drives account for. At '
               'one the channel moved because the shakers moved it; '
               'well below one something else did, and a control '
               'channel in that state was being held to a level it '
               'was not wholly responsible for.\n\n'
               if present(coherence) else '')
            + ('Pearson kurtosis ({{figure:Pearson kurtosis}}) reads '
               'the distribution of each channel rather than its level. '
               'Three is Gaussian, which the random alone would be; a '
               'pure tone reads 1.5, and a sweep mixed with a random '
               'background sits between, lower the louder the tone. So '
               'a channel under three is the sweep, by design, and what '
               'the figure is watched for is a channel far from its '
               'neighbors — one that clipped or rattled — and a channel '
               'well above three, which carried peaks neither '
               'requirement asked for.'
               if swept else
               'Pearson kurtosis ({{figure:Pearson kurtosis}}) says '
               'whether the excitation had the shape a random test '
               'assumes. A spectrum cannot answer this: two records with '
               'identical densities can be a smooth hiss and a train of '
               'rare hard peaks, and the second fatigues an article in a '
               'way the first does not. Three is Gaussian. Above the band '
               'the record carried peaks the specification never asked '
               'for; below it the record was clipped, or was not random. '
               'Either way the article saw something other than the '
               'specified test while the spectrum looked correct.')},
        # the map, not curves: a dozen channels of coherence stacked
        # on one 2-D axis is a thicket. Frequency across, channel down,
        # coherence as color — the app's spectrogram reading. The
        # stage that replaced it on 2026-08-23 made the report files
        # too large (Brandon, 2026-10-05: 2-D only)
        {'kind': 'plot', 'source': '@basis:MultipleCoherence',
         'mode': 'map', 'caption': 'Multiple coherence'},
        kurtosis_block(),
        # no figure references here (Brandon, 2026-09-19): the
        # conclusions stand whatever the project holds, and a reference
        # to a figure a thin project does not have would stay on the
        # page as written — the page's rule for an unmatched reference,
        # right for an author and wrong for a reader
        {'kind': 'text', 'text':
            '## Conclusions\n\n'
            'Whether the run met its specification is read off the '
            'control comparison and the two compliance charts under '
            'it: the RMS error says how far each channel sat from the '
            'level it was asked for, and the band outside abort how '
            'much of each channel\'s band fell outside tolerance. A '
            'channel can pass one and fail the other — a channel 2 dB '
            'low everywhere may never cross an abort limit, and one at '
            'exactly the right level may be out across half its band — '
            'which is why both are there. The octave-band figures read '
            'the same run the way a requirement is usually written and '
            'usually argued.'
            + (' Where a control channel is out only across the band '
               'the sweep passed through, the tone is the first suspect.'
               if swept else '')
            + '\n\n'
            'Where a channel is out, the two data-quality figures are '
            'what decide whether the article or the measurement is at '
            'fault'
            + (', read knowing that a kurtosis under three is the sweep'
               if swept else '')
            + '. A channel clean on both and still outside '
            'tolerance is a real exceedance.'},
    ]), objects, object_groups)

mimo_template

mimo_template(objects: Mapping[str, Any], object_groups: Sequence[Mapping[str, Any]] | None = None) -> Report

A multiple-input random vibration report: the control matrix, its specification, and what stood between them.

A run controlled to a cross-spectral density matrix is judged on the matrix, not channel by channel: the diagonal holds each control channel's level, and off it each pair's coherence and phase. The report follows the random report's order (random_template): the front matter and the record first, then what was asked for and whether the rig could give it (the transformation, the specification's realizability, the plant's conditioning and the drives), then the result (the diagonal against its specification, the matrix grid and the cross-term verdicts), the data quality, and the conclusions.

Nothing here computes. Each figure is a block the report already draws: the diagonal comparisons with their record captions and the two compliance bar charts, as in the random report; the singular values of the FRF matrix as the CMIF, the matrix the Conditioning reading reads; the multiple coherence map and the kurtosis. A section whose reading no block draws (the transformation, realizability, the condition number, the drive prediction, the matrix grid, the cross-term verdicts, the reference and partial coherence) is written as owed, OWED and a line saying what belongs there and which reading gives it, and a section that needs an object the project does not hold says which. The cross terms of a run or a specification that holds none, or of a project without one of the two, are written as not judged and why, never as a pass. There is no pass/fail box: the octave-band verdict reads the diagonal alone.

Sources bind symbolically, and the report is passed through prune_unbound, so a run with no geometry still renders without empty figures; an empty project keeps the whole outline.

Parameters:

Name Type Description Default
objects mapping of str to object

The project's objects by name.

required
object_groups sequence of mapping

The project's object groups, which the symbolic sources resolve against.

None

Returns:

Type Description
Report
Source code in src/visualdynamics/core/report.py
def mimo_template(objects: Mapping[str, Any],
                  object_groups: Sequence[Mapping[str, Any]] | None = None
                  ) -> Report:
    """A multiple-input random vibration report: the control matrix, its
    specification, and what stood between them.

    A run controlled to a cross-spectral density matrix is judged on
    the matrix, not channel by channel: the diagonal holds each control
    channel's level, and off it each pair's coherence and phase. The
    report follows the random report's order (`random_template`): the
    front matter and the record first, then what was asked for and
    whether the rig could give it (the transformation, the
    specification's realizability, the plant's conditioning and the
    drives), then the result (the diagonal against its specification,
    the matrix grid and the cross-term verdicts), the data quality, and
    the conclusions.

    Nothing here computes. Each figure is a block the report already
    draws: the diagonal comparisons with their record captions and the
    two compliance bar charts, as in the random report; the singular
    values of the FRF matrix as the CMIF, the matrix the Conditioning
    reading reads; the multiple coherence map and the kurtosis. A
    section whose reading no block draws (the transformation,
    realizability, the condition number, the drive prediction, the
    matrix grid, the cross-term verdicts, the reference and partial
    coherence) is written as owed, `OWED` and a line saying what
    belongs there and which reading gives it, and a section that needs
    an object the project does not hold says which. The cross terms
    of a run or a specification that holds none, or of a project
    without one of the two, are written as not judged and why, never
    as a pass. There is no pass/fail box: the octave-band verdict reads
    the diagonal alone.

    Sources bind symbolically, and the report is passed through
    `prune_unbound`, so a run with no geometry still renders without
    empty figures; an empty project keeps the whole outline.

    Parameters
    ----------
    objects : mapping of str to object
        The project's objects by name.
    object_groups : sequence of mapping, optional
        The project's object groups, which the symbolic sources resolve
        against.

    Returns
    -------
    Report
    """
    time = '@basis:TimeHistory'
    psd = '@basis:Psd'
    frf = '@basis:Frf'
    spec = '@basis:Specification'
    coherence = '@basis:MultipleCoherence'

    def ref(name: str, field: str) -> str:
        return '{{' + f'{name}.{field}' + '}}'

    def present(token: str) -> bool:
        """Whether the prose may speak of what a token binds: yes in an
        empty project (the outline), otherwise only when it binds."""
        return (not objects
                or resolve_binding(token, objects, object_groups) in objects)

    def lacking(*tokens: tuple[str, str]) -> str:
        """The sentence naming what the project lacks of `tokens`,
        (token, words) pairs; '' when it holds them all."""
        missing = [words for token, words in tokens if not present(token)]
        if not missing:
            return ''
        return (f'The project holds no {" and no ".join(missing)} to read '
                'it from.')

    controls = control_channel_labels(objects, object_groups)
    refusal = _matrix_refusal(objects, object_groups)
    specified = _holds_cross_terms(
        resolve_binding(spec, objects, object_groups), objects)
    if present(frf):
        conditioning = [
            {'kind': 'text', 'text':
                '## Conditioning\n\n'
                'The singular values of the FRF matrix at every line '
                '({{figure:Singular values of the FRF matrix}}), drawn as '
                'the CMIF: one curve per drive, largest first, from the '
                'matrix the Conditioning reading reads. Where the '
                'smallest falls far under the largest, two drives reach '
                'the control channels in nearly the same proportions, and '
                "an inverse of the plant there, the controller's and the "
                "drive prediction's alike, is mostly noise. The ratio of "
                'the largest to the smallest is the condition number.\n\n'
                f'**{OWED}:** the condition number at every line against '
                'its limit, the effective rank, and the bands over the '
                'limit, read with the Conditioning reading '
                '(`Frf.conditioning`).'},
            {'kind': 'plot', 'source': frf, 'mode': 'cmif',
             'caption': 'Singular values of the FRF matrix (CMIF)'}]
    else:
        conditioning = [_owed(
            'Conditioning',
            'the singular values and the condition number of the FRF '
            'matrix at every line, and the bands over its limit, read '
            'with the Conditioning reading (`Frf.conditioning`).',
            lacking((frf, 'FRF from the drives to the control channels')))]
    if specified is False:
        realizability = {'kind': 'text', 'text':
            '## Specification Realizability\n\n'
            '**Not judged:** the specification holds no cross terms, so '
            'there are no coherences and phases to be inconsistent.'}
    else:
        realizability = _owed(
            'Specification Realizability',
            "whether any set of signals has the specification's "
            'coherences and phases together: the eigenvalues of its '
            'coherence matrix at every line, and the lines and pairs '
            'where one is negative, read with the Realizability reading '
            '(`Psd.realizability`).',
            lacking((spec, 'specification')))
    if refusal:
        grid = {'kind': 'text',
                'text': f'## Matrix Grid\n\n**Not drawn:** {refusal}.'}
        verdicts = {'kind': 'text', 'text':
                    f'## Cross-Term Verdicts\n\n**Not judged:** {refusal}. '
                    'No cross term is counted as passing.'}
    else:
        grid = _owed('Matrix Grid',
                     'the control matrix against its specification as one '
                     'grid, a cell per pair of control channels: the level '
                     'on the diagonal, the coherence above it and the '
                     'phase below it, each cell marked by its verdict, '
                     'read with the Matrix reading '
                     '(`core.matrix_comparison.compare_matrix`).')
        verdicts = _owed('Cross-Term Verdicts',
                         "each cross term's coherence and phase against "
                         'the specification, with the tolerances it was '
                         'judged by and the share of its lines out, and '
                         'the verdict on the matrix as a whole, read with '
                         'the Matrix reading '
                         '(`core.matrix_comparison.compare_matrix`).')
    return prune_unbound(Report('MIMO Random Vibration Test Report', [
        {'kind': 'text', 'text':
            '## Test Summary\n\n'
            'A multiple-input multiple-output (MIMO) random vibration test '
            'was run: several drives excited the article at once, '
            'controlled to a cross-spectral density matrix at the control '
            "channels, which asks for each control channel's level on its "
            'diagonal and, off it, how each pair of channels moves '
            'together. This report documents the instrumentation, the '
            'record the analysis was made from, the transformation and the '
            'specification the run was controlled to, whether the plant '
            'and the specification allowed it, how closely the control '
            'matrix held it, and the checks that say whether those numbers '
            f'can be trusted. A section marked {OWED} is read in the app '
            'and written in by the author.\n\n'
            f'The run was recorded on {ref(time, "num_channels")} '
            f'channels at {ref(time, "sample_rate")} over '
            f'{ref(time, "duration")}. The control spectra are averaged '
            f'over {ref(time, "num_frames")} frames of '
            f'{ref(time, "frame_length")} samples with a '
            f'{ref(time, "window")} window at {ref(time, "overlap")} '
            'overlap'
            + (f', giving {ref(psd, "frequency_resolution")} resolution '
               f'out to {ref(psd, "max_frequency")}.' if present(psd)
               else '.')},
        {'kind': 'text', 'text': instrumentation_text(objects, object_groups,
                                                      time)},
        *front_matter(objects, object_groups, time),
        _owed('Transformation',
              'the transformation from the control channels to the '
              'degrees of freedom controlled: its matrix, its '
              "conditioning, each channel's weight, and up to what "
              'frequency the rigid-body motion explains the measured '
              'power, read with `Project.transformation_of` and the '
              'Rigid-Body Residual reading (`Project.read_transformation`). '
              'A run controlled on its channels as measured says so here.'),
        {'kind': 'text', 'text':
            '## Measured Data\n\nThe measured time histories, one '
            'figure per quantity, with the frames the spectra are '
            'averaged over marked on them ({{figure:Measured}}): which '
            'part of the run was analyzed, and under what window.'},
        *time_data_blocks(
            time, objects, object_groups,
            'Measured time histories, with the frames the spectra '
            'are averaged over',
            'Measured {quantity} time histories, with the frames '
            'the spectra are averaged over'),
        realizability,
        *conditioning,
        _owed('Drives',
              'the drives the specification asks for through the FRF, '
              "open loop: each drive's autospectrum, its rms and expected "
              'peak, and the drive, force, velocity and travel against the '
              'amplifier and shaker limits, with the first to run out and '
              'where, read with the Drive Prediction reading '
              '(`core.drive_prediction.predict_drives`).',
              lacking((frf, 'FRF'), (spec, 'specification'))),
        {'kind': 'text', 'text':
            '## Matrix Comparison\n\nThe diagonal of the control matrix '
            'against the diagonal of its specification, '
            + _figures_text(
                controls,
                'one figure per control channel '
                '({{figure:Control against specification}} and '
                'following). ',
                'laid out by control channel, a row per node and a column '
                'per direction ({{figure:Control against '
                'specification}}). ')
            + "Each is a control channel's autospectrum, its level, "
            'judged exactly as a single channel is: the target drawn '
            'through the measurement with its warning and abort limits '
            'shaded around it. **RMS error** '
            '({{figure:RMS error by control channel}}) is how far each '
            'channel sits from the level asked for, and **lines outside '
            'abort** ({{figure:Band outside the abort limits, by control '
            'channel}}) how much of its band is out of tolerance. A cross '
            'term is not a level and is not read here: its coherence and '
            'phase are judged in the matrix grid and the cross-term '
            'verdicts below.'},
        *_comparisons(psd, spec, 'Control against specification', objects,
                      object_groups),
        {'kind': 'bars', 'mode': 'error', 'source': spec,
         'measured': psd, 'caption': 'RMS error by control channel'},
        {'kind': 'bars', 'mode': 'lines', 'source': spec,
         'measured': psd,
         'caption': 'Band outside the abort limits, by control channel'},
        grid,
        verdicts,
        {'kind': 'text', 'text':
            '## Data Quality\n\n'
            + ('Multiple coherence ({{figure:Multiple coherence}}) says '
               'how much of each response the drives account for '
               'together. At one the channel moved because the drives '
               'moved it; well below one something else did, and a '
               'control channel in that state was held to a level it was '
               'not wholly responsible for.\n\n'
               if present(coherence) else '')
            + 'Pearson kurtosis ({{figure:Pearson kurtosis}}) says '
            'whether the excitation had the shape a random test assumes. '
            'Three is Gaussian. Above the band the record carried peaks '
            'the specification never asked for; below it the record was '
            'clipped, or was not random.'},
        {'kind': 'plot', 'source': coherence, 'mode': 'map',
         'caption': 'Multiple coherence'},
        kurtosis_block(),
        _owed('Reference and Partial Coherence',
              'how alike the drives were, the coherence between each pair '
              'of references, and which drive explains each response once '
              'the others are removed, the partial coherence, read with '
              'the Partial Coherence reading '
              '(`Project.read_partial_coherence`).',
              lacking((coherence, 'multiple coherence'))),
        {'kind': 'text', 'text':
            '## Conclusions\n\n'
            'Whether the run met its specification is read off the matrix '
            'in two parts. The diagonal comparisons and the two '
            "compliance charts under them judge each control channel's "
            'level. The cross-term verdicts judge how each pair moved '
            'together, and a run can hold every level in its band and '
            'still not be the test asked for, if two channels that were '
            'to move together moved independently or in opposition. A '
            'cross term not judged is not a cross term passed.\n\n'
            'Where a cross term is out, the sections before the result '
            'are the first place to look: a specification no set of '
            'signals can produce, a plant whose drives reach the control '
            'channels in nearly the same proportions, and a drive that '
            'runs out of amplifier or shaker each make a matrix no '
            'controller can hold. Where a channel is out, the data '
            'quality figures decide whether the article or the '
            'measurement is at fault.'},
    ]), objects, object_groups)