Skip to content

visualdynamics.core.requirement

requirement

A random test's requirement, and its result criterion by criterion.

A test is run to a requirement the plan copied from its governing document: the control band, each control channel's band when several are averaged, the overall rms, the duration, the degrees of freedom and the line spacing the control must have, the cross-axis limits, and the rule a miss is decided by. "Passed" without those is not a result. A TestRequirement holds them as the plan states them, each number with where it came from (the laboratory, the customer, or derived from another), and evaluate reads a run against it, one row per criterion, each worded met, not met or not judged.

  • The control band. Under a single control channel, that channel against the band; under a multi-point strategy, the composite the controller held (core.multi_point) against the composite band, and each control channel against its own wider band. A band is a pair of dB below and above the reference over a frequency range, so a band that steps (plus or minus 3 dB below 500 Hz and plus or minus 6 dB above) is two bands, each its own row. Every line is judged by compliance.judge, the comparison every other random reading makes, on a one-record specification holding the band as its abort limits.
  • The decision rule. Either any line outside its band is a miss, or N adjacent lines are combined, their powers added and judged against the band's power over the same stretch at the group's degrees of freedom (core.chance.compare_chance), so the scatter of the estimate shrinks and a real excursion across neighboring lines remains. Some test documents allow the second; which one applies is the plan's choice, and the requirement names it.
  • Each miss is given by its band edges in hertz, how far past the band it went at worst in dB, and how many lines (or groups) it holds, so the row can be checked against the figure.
  • The overall rms of the composite (or the one channel) against the reference's over the band, in percent (compliance.compare).
  • The duration at level against its nominal with a tolerance that may differ either side (minus 0, plus 10 percent in some documents). It is the controller's counter, typed for the run, since a PSD does not hold how long the run was at level.
  • The degrees of freedom carried by the control channels' PSDs, the fewest of them, and the line spacing, each against the requirement.
  • Cross-axis (cross_axis_rows): each cross-axis channel named, with the in-axis control channel it is read against, as a percentage of that channel's ASD line by line over each limit's band, at the same degrees of freedom, the worst percentage and its frequency against the limit.
  • Limit channels (limit_rows): for each response channel held under a limit profile, the band and depth over which it held its limit while the control fell below the reference, the as-run notch, each notch against the notches the plan names. A notch the plan does not name is a departure from it, and its row is not met.

The measurement's own uncertainty is held with the requirement too, since the tolerance it is read against is the requirement's: each control channel's contributions (the accelerometer's calibration from its certificate, the conditioner and converter, transverse sensitivity times the cross-axis share, the mount and the chain's response), each an expanded uncertainty in percent on the acceleration with its coverage factor. uncertainty_budget divides each by its own coverage factor, combines them as the root of the sum of their squares, expands the result at COVERAGE, and states it in percent on the acceleration, in percent on the ASD and in dB, against one third of the tolerance (the tightest band edge in dB, and the overall rms tolerance in percent), the share some test documents allow the measurement. It is not a criterion of the run, and a channel with nothing entered reads not judged, never met.

A row that judged nothing says so and why, and is never met: a band the data do not reach, a channel with no finite line, a measurement that does not carry its degrees of freedom. A row with some lines not judged (a sample that is not a number) and none outside is not judged either, and its words say how many lines were judged. The results are met only when every row is.

Classes:

Name Description
Band

A band about the reference over a frequency range: below_db and

CrossAxisLimit

A cross-axis limit: the cross-axis response at most percent of

Source

Where a number of the requirement came from: origin (one of

Contribution

One contribution to a control channel's measurement uncertainty:

LimitChannel

A limit channel: the response at channel (its response DOF)

TestRequirement

What a random test's run is accepted on, as the plan states it.

Excursion

One stretch of adjacent lines outside a band on one side.

Offer

The fact a row of the result table offers a findings register:

Criterion

One row of the result table.

Results

What evaluate read: one row per criterion, and the notes.

Budget

One control channel's measurement uncertainty against one third

Functions:

Name Description
duration_text

'3 min 4 s', '45.5 s', '1 h 2 min'.

evaluate

Read a run against its requirement, one row per criterion.

cross_axis_rows

The rows of the requirement's cross-axis limits: one per limit

limit_rows

The rows of the requirement's limit channels: for each, the

within

One record of specification cut to low to high Hz.

coverage_reach

(low, high) Hz of what one-record specification writes.

unread

How many lines of target's record inside what band writes are

combined_percent

The contributions' combined uncertainty, expanded at COVERAGE,

uncertainty_db

An uncertainty in percent on the acceleration in dB on the ASD,

uncertainty_budget

Each control channel's uncertainty against one third of the

Classes

Band dataclass

Band(low: float | None, high: float | None, below_db: float, above_db: float)

A band about the reference over a frequency range: below_db and above_db (ten log ten, on a density) from low to high Hz.

low or high None reaches the specification's own end. An edge may be infinite (no limit on that side), but not both.

Attributes:

Name Type Description
db_text str

'−3/+3 dB', an open edge written 'down' or 'up'.

text str

'−3/+3 dB from 20 to 500 Hz'.

Attributes
db_text property
db_text: str

'−3/+3 dB', an open edge written 'down' or 'up'.

text property
text: str

'−3/+3 dB from 20 to 500 Hz'.

CrossAxisLimit dataclass

CrossAxisLimit(low: float | None, high: float | None, percent: float)

A cross-axis limit: the cross-axis response at most percent of the in-axis response from low to high Hz.

Source dataclass

Source(origin: str, owner: str = '')

Where a number of the requirement came from: origin (one of ORIGINS) and its owner, the person or organization answerable for it.

Contribution dataclass

Contribution(name: str, percent: float, coverage: float = COVERAGE, source: str = '')

One contribution to a control channel's measurement uncertainty: its name, its expanded uncertainty percent on the acceleration at coverage factor coverage, and where the number came from (source: a calibration certificate, a data sheet).

A contribution is a percentage above 0 and under 100: a chain with no uncertainty, or one as large as the signal, is not a budget.

Attributes:

Name Type Description
standard float

The standard uncertainty, percent: percent over coverage.

text str

'calibration 2 % (k = 2, certificate C-1)'.

Attributes
standard property
standard: float

The standard uncertainty, percent: percent over coverage.

text property
text: str

'calibration 2 % (k = 2, certificate C-1)'.

LimitChannel dataclass

LimitChannel(channel: str, profile: str = '', notches: tuple[tuple[float, float], ...] = ())

A limit channel: the response at channel (its response DOF) held under its limit profile, an autospectrum at that DOF in the object named profile ('' for the specification's own record there), and the notches the plan names for it (notches, each (low, high) Hz).

Methods:

Name Description
planned

Whether notch lies inside a notch the plan names.

Attributes:

Name Type Description
text str

What the row requires, in words.

Attributes
text property
text: str

What the row requires, in words.

Methods:
planned
planned(notch: Excursion) -> bool

Whether notch lies inside a notch the plan names.

Source code in src/visualdynamics/core/requirement.py
def planned(self, notch: Excursion) -> bool:
    """Whether `notch` lies inside a notch the plan names."""
    return any(low * (1.0 - _TOUCHING) <= notch.low
               and notch.high <= high * (1.0 + _TOUCHING)
               for low, high in self.notches)

TestRequirement dataclass

TestRequirement(document: str, composite_bands: tuple[Band, ...], revision: str = '', clause: str = '', strategy: str = 'single', channels: tuple[str, ...] = (), weights: tuple[float, ...] | None = None, individual_bands: tuple[Band, ...] = (), grms_percent: float | None = None, duration: float | None = None, duration_tolerance: tuple[float, float] | None = None, min_dof: float | None = None, max_line_spacing: float | None = None, cross_axis: tuple[CrossAxisLimit, ...] = (), rule: str = 'any_line', combine: int = 1, sources: Mapping[str, Source] = dict(), uncertainty: Mapping[str, tuple[Contribution, ...]] = dict(), cross_axis_channels: Mapping[str, str] = dict(), limit_channels: tuple[LimitChannel, ...] = ())

What a random test's run is accepted on, as the plan states it.

Parameters:

Name Type Description Default
document str

The governing document, as the plan names it.

required
composite_bands sequence of Band or (low, high, below_db, above_db)

The control band: under a single control channel, that channel's; under a multi-point strategy, the composite's. One band per frequency range; ranges may not overlap.

required
revision str

The document's revision.

''
clause str

The tolerance clause, as the plan copied it.

''
strategy str

One of STRATEGIES.

'single'
channels sequence of str

The control channels by response DOF. Empty takes them from the channel table's Control flags, or the records that pair with the specification (core.multi_point.control_channels).

()
weights sequence of float

One per channel, under 'weighted' only.

None
individual_bands sequence of Band or tuple

Each control channel's band, under a multi-point strategy (and required there); none under 'single'.

()
grms_percent float

The overall rms tolerance, plus or minus, in percent of the reference's.

None
duration float

The nominal duration at level, seconds.

None
duration_tolerance (float, float)

The duration's tolerance below and above its nominal, in percent; required with duration.

None
min_dof float

The fewest statistical degrees of freedom the control may have.

None
max_line_spacing float

The widest the control's line spacing may be, Hz.

None
cross_axis sequence of CrossAxisLimit or (low, high, percent)

The cross-axis limits by band.

()
cross_axis_channels mapping of str to str

Each cross-axis channel's response DOF, to the in-axis control channel it is read against.

dict()
limit_channels sequence of LimitChannel or (channel, profile, notches)

The response channels held under a limit profile, each with the object its profile is in ('' for the specification) and the notches the plan names for it.

()
rule str

One of RULES.

'any_line'
combine int

How many adjacent lines are combined under 'combined' (two or more); 1 under 'any_line'.

1
sources mapping of str to Source or (origin, owner)

Where each number came from, by field (NUMBERS).

dict()
uncertainty mapping of str to sequence of Contribution or tuple

Each control channel's uncertainty contributions, by response DOF, each (name, percent, coverage, source): an expanded uncertainty in percent on the acceleration and its coverage factor (uncertainty_budget).

dict()

Raises:

Type Description
ValueError

For anything the plan could not have meant: no document, no control band, overlapping bands, a band not about the reference, a strategy not in STRATEGIES, individual bands or channels a strategy cannot have, weights under a strategy that takes none, a tolerance that is not a positive number, a duration without its tolerance, a rule not in RULES, a combination the rule does not take, a source for a number the requirement does not hold, and an uncertainty contribution that is not a percentage above 0 and under 100, is named twice for a channel, or is entered for a channel the requirement does not name; cross-axis channels without cross-axis limits, or with an in-axis channel that is not a control channel; and a limit channel named twice, that is a control channel, or whose planned notches do not each run from a low edge to a higher one.

Methods:

Name Description
as_record

The requirement as plain values, for a file (from_record).

from_record

A requirement from as_record's values; refused as the

describe

The requirement in words, a line each: the document and its

Attributes:

Name Type Description
document_text str

'Test plan TP-12, revision B'.

rule_text str

The decision rule in words.

Attributes
document_text property
document_text: str

'Test plan TP-12, revision B'.

rule_text property
rule_text: str

The decision rule in words.

Methods:
as_record
as_record() -> dict[str, Any]

The requirement as plain values, for a file (from_record).

Source code in src/visualdynamics/core/requirement.py
def as_record(self) -> dict[str, Any]:
    """The requirement as plain values, for a file (`from_record`)."""
    return {
        'document': self.document, 'revision': self.revision,
        'clause': self.clause, 'strategy': self.strategy,
        'channels': list(self.channels),
        'weights': None if self.weights is None else list(self.weights),
        'composite_bands': [b.as_record() for b in self.composite_bands],
        'individual_bands': [b.as_record()
                             for b in self.individual_bands],
        'grms_percent': self.grms_percent, 'duration': self.duration,
        'duration_tolerance': (None if self.duration_tolerance is None
                               else list(self.duration_tolerance)),
        'min_dof': self.min_dof,
        'max_line_spacing': self.max_line_spacing,
        'cross_axis': [c.as_record() for c in self.cross_axis],
        'rule': self.rule, 'combine': self.combine,
        'sources': {name: [s.origin, s.owner]
                    for name, s in self.sources.items()},
        'uncertainty': {channel: [c.as_record() for c in held]
                        for channel, held in self.uncertainty.items()},
        'cross_axis_channels': dict(self.cross_axis_channels),
        'limit_channels': [c.as_record() for c in self.limit_channels]}
from_record classmethod
from_record(record: Mapping[str, Any]) -> TestRequirement

A requirement from as_record's values; refused as the constructor refuses, and for a field it does not take.

Source code in src/visualdynamics/core/requirement.py
@classmethod
def from_record(cls, record: Mapping[str, Any]) -> TestRequirement:
    """A requirement from `as_record`'s values; refused as the
    constructor refuses, and for a field it does not take."""
    if not isinstance(record, Mapping):
        raise TypeError('a requirement is stored as a mapping of its '
                         'fields')
    known = set(cls.__dataclass_fields__)
    unknown = sorted(set(record) - known)
    if unknown:
        raise ValueError(f'a requirement has no field '
                         f'{", ".join(map(repr, unknown))}')
    if 'document' not in record or 'composite_bands' not in record:
        raise ValueError('a requirement names its document and its '
                         'control band')
    return cls(**record)
describe
describe() -> list[str]

The requirement in words, a line each: the document and its clause, the strategy, each band, each tolerance, the decision rule, and where each number came from, with the sentence for a number the customer supplied.

Source code in src/visualdynamics/core/requirement.py
def describe(self) -> list[str]:
    """The requirement in words, a line each: the document and its
    clause, the strategy, each band, each tolerance, the decision
    rule, and where each number came from, with the sentence for a
    number the customer supplied."""
    lines = [f'Governing document: {self.document_text}.']
    if self.clause.strip():
        lines.append(f'Tolerance clause: "{self.clause.strip()}"')
    named = ', '.join(self.channels)
    if self.strategy == 'single':
        lines.append('Control strategy: single control channel'
                     + (f', {named}.' if named else '.'))
    else:
        weights = ''
        if self.weights is not None:
            weights = ', weights ' + ', '.join(f'{w:g}'
                                               for w in self.weights)
        lines.append(f'Control strategy: {self.strategy} of the control '
                     'channels'
                     + (f' {named}' if named else '') + weights + '.')
    noun = 'Composite band' if self.strategy != 'single' else \
        'Control band'
    lines.append(f'{noun}: '
                 + '; '.join(b.text for b in self.composite_bands)
                 + self._from('composite_bands') + '.')
    if self.individual_bands:
        lines.append('Individual band, each control channel: '
                     + '; '.join(b.text for b in self.individual_bands)
                     + self._from('individual_bands') + '.')
    if self.grms_percent is not None:
        lines.append(f'Overall rms: within ±{self.grms_percent:g} % of '
                     f'the reference{self._from("grms_percent")}.')
    if self.duration is not None:
        minus, plus = self.duration_tolerance
        lines.append(f'Duration at level: {duration_text(self.duration)}, '
                     f'−{minus:g}/+{plus:g} %{self._from("duration")}.')
    if self.min_dof is not None:
        lines.append(f'Degrees of freedom: at least {self.min_dof:g}'
                     f'{self._from("min_dof")}.')
    if self.max_line_spacing is not None:
        lines.append(f'Line spacing: at most {self.max_line_spacing:g} Hz'
                     f'{self._from("max_line_spacing")}.')
    if self.cross_axis:
        lines.append('Cross-axis: at most '
                     + '; '.join(c.text for c in self.cross_axis)
                     + self._from('cross_axis') + '.')
    if self.cross_axis_channels:
        lines.append('Cross-axis channels: ' + ', '.join(
            f'{cross} against {in_axis}'
            for cross, in_axis in self.cross_axis_channels.items()) + '.')
    if self.limit_channels:
        lines.append('Limit channels: ' + '; '.join(
            f'{c.channel}, {c.text}' for c in self.limit_channels)
            + self._from('limit_channels') + '.')
    lines.append(f'Decision rule: {self.rule_text}{self._from("rule")}.')
    if self.uncertainty:
        lines.append('Measurement uncertainty: entered for '
                     + ', '.join(self.uncertainty)
                     + ', held against one third of the tolerance'
                     + self._from('uncertainty') + '.')
    return lines

Excursion dataclass

Excursion(side: str, low: float, high: float, worst_db: float, lines: int)

One stretch of adjacent lines outside a band on one side.

Attributes:

Name Type Description
text str

'above by up to 1.4 dB from 617.5 to 660 Hz (18 lines)'.

Attributes
text property
text: str

'above by up to 1.4 dB from 617.5 to 660 Hz (18 lines)'.

Offer dataclass

Offer(text: str, channel: str, band: tuple[float, float])

The fact a row of the result table offers a findings register: what was measured, in words, and the channel and band it concerns.

A row offers one where the run departed from the plan: a notch the plan does not name, and a cross-axis channel over its limit. The report builds a fact draft from it, naming the record, and it reaches a finding only through the register's one entry point for drafts (report.findings.offer), which leaves it for the author to accept, edit or discard.

Criterion dataclass

Criterion(key: str, name: str, required: str, measured: str, state: str, excursions: tuple[Excursion, ...] = (), channel: str = '', band: tuple[float, float] | None = None, finding: Offer | None = None)

One row of the result table.

Results dataclass

Results(requirement: TestRequirement, rows: tuple[Criterion, ...], scale_db: float, channels: tuple[str, ...], notes: tuple[str, ...] = ())

What evaluate read: one row per criterion, and the notes.

Attributes:

Name Type Description
state str

NOT_MET when any row is not met; NOT_JUDGED when none is

met int

How many rows are met.

Attributes
state property
state: str

NOT_MET when any row is not met; NOT_JUDGED when none is not met and any was not judged; MET only when every row is.

met property
met: int

How many rows are met.

Budget dataclass

Budget(channel: str, contributions: tuple[Contribution, ...], acceleration_percent: float, asd_percent: float, db: float, band_limit_db: float, band_db: float, grms_limit_percent: float | None, state: str, words: str)

One control channel's measurement uncertainty against one third of the tolerance (uncertainty_budget).

Functions:

duration_text

duration_text(seconds: float) -> str

'3 min 4 s', '45.5 s', '1 h 2 min'.

Source code in src/visualdynamics/core/requirement.py
def duration_text(seconds: float) -> str:
    """'3 min 4 s', '45.5 s', '1 h 2 min'."""
    hours, rest = divmod(float(seconds), 3600.0)
    minutes, secs = divmod(rest, 60.0)
    parts = [f'{int(hours)} h'] if hours else []
    if minutes:
        parts.append(f'{int(minutes)} min')
    if secs or not parts:
        parts.append(f'{secs:.4g} s')
    return ' '.join(parts)

evaluate

evaluate(requirement: TestRequirement, specification: Specification, measured: Psd, *, table: Any = None, duration_run: float | None = None, spec_record: int | None = None, scale_db: float | None = None, profiles: Mapping[str, Any] | None = None) -> Results

Read a run against its requirement, one row per criterion.

The control channels are the requirement's, else the channel table's Control flags, else the autospectra that pair with the specification (core.multi_point.control_channels, and for a single channel the one record that pairs). The reference is one record of the specification, chosen as core.multi_point chooses it, and the scale is resolved once for the run (compliance.comparison_scale_db) and used for every row.

Parameters:

Name Type Description Default
requirement TestRequirement

What the run is accepted on.

required
specification Specification

The reference.

required
measured Psd

The run's autospectra.

required
table ChannelTable

The channel table, whose Control flags name the control channels when the requirement names none.

None
duration_run float

How long the run was at level, seconds: the controller's counter. Without it the duration row is not judged.

None
spec_record int

The reference record.

None
scale_db float

The comparison's scale; resolved as above when omitted.

None
profiles mapping of str to Psd

The objects the limit channels name for their profiles, by name.

None

Returns:

Type Description
Results

The rows and the notes.

Raises:

Type Description
TypeError

When the reference is not a random specification or the run not a measured PSD.

ValueError

With the reason, for a run the requirement cannot be read against at all: control channels not found, ambiguous or a cross term, channels of different quantities, a weight per channel that does not fit them, or a duration that is not a positive number. A criterion that cannot be judged is a row saying why, not a refusal.

Source code in src/visualdynamics/core/requirement.py
def evaluate(requirement: TestRequirement, specification: Specification,
             measured: Psd, *, table: Any = None,
             duration_run: float | None = None,
             spec_record: int | None = None,
             scale_db: float | None = None,
             profiles: Mapping[str, Any] | None = None) -> Results:
    """Read a run against its requirement, one row per criterion.

    The control channels are the requirement's, else the channel
    table's Control flags, else the autospectra that pair with the
    specification (`core.multi_point.control_channels`, and for a
    single channel the one record that pairs). The reference is one
    record of the specification, chosen as `core.multi_point` chooses
    it, and the scale is resolved once for the run
    (`compliance.comparison_scale_db`) and used for every row.

    Parameters
    ----------
    requirement : TestRequirement
        What the run is accepted on.
    specification : Specification
        The reference.
    measured : Psd
        The run's autospectra.
    table : ChannelTable, optional
        The channel table, whose Control flags name the control channels
        when the requirement names none.
    duration_run : float, optional
        How long the run was at level, seconds: the controller's
        counter. Without it the duration row is not judged.
    spec_record : int, optional
        The reference record.
    scale_db : float, optional
        The comparison's scale; resolved as above when omitted.
    profiles : mapping of str to Psd, optional
        The objects the limit channels name for their profiles, by name.

    Returns
    -------
    Results
        The rows and the notes.

    Raises
    ------
    TypeError
        When the reference is not a random specification or the run not
        a measured PSD.
    ValueError
        With the reason, for a run the requirement cannot be read
        against at all: control channels not found, ambiguous or a cross
        term, channels of different quantities, a weight per channel
        that does not fit them, or a duration that is not a positive
        number. A criterion that cannot be judged is a row saying why,
        not a refusal.
    """
    from .compliance import comparison_scale_db
    from .data import Psd, Specification
    from .multi_point import _reference, multi_point

    if not isinstance(requirement, TestRequirement):
        raise TypeError('the requirement is a TestRequirement')
    if not isinstance(specification, Specification):
        raise TypeError('the reference is a random specification')
    if not isinstance(measured, Psd) or isinstance(measured, Specification):
        raise TypeError('the run is a measured PSD')
    if duration_run is not None:
        duration_run = _number(duration_run, 'the duration at level')
        if not 0.0 < duration_run < math.inf:
            raise ValueError('the duration at level is a number of seconds '
                             f'above 0, not {duration_run!r}')
    records = _records(requirement, specification, measured, table)
    labels = [str(measured.response_dof[i]) for i in records]
    j = _reference(specification, measured, records, spec_record)
    notes: list[str] = []
    if scale_db is None:
        scale_db = comparison_scale_db(specification, measured, None,
                                       records, labels)
        if scale_db and getattr(measured, 'scale_db', None) is None:
            notes.append(f'the measurement was scaled by {scale_db:+g} dB, '
                         f'detected from the data as a run at '
                         f'{-scale_db:+g} dB')
    scale_db = float(scale_db)
    dofs = _dofs(measured, records)

    rows: list[Criterion] = []
    if requirement.strategy == 'single':
        target, target_record = measured, records[0]
        name = f'Channel {labels[0]}'
    else:
        first = requirement.individual_bands[0]
        reading = multi_point(
            specification, measured, records, requirement.strategy,
            requirement.weights,
            composite_band=(requirement.composite_bands[0].below_db,
                            requirement.composite_bands[0].above_db),
            individual_band=(first.below_db, first.above_db),
            spec_record=j, scale_db=scale_db)
        # what the reading noticed of the channels (a dead one, lines not
        # formed, a zero weight), but not its band's chance share, which
        # is of one band of several, nor its scale, said above
        notes.extend(note for note in reading.notes
                     if 'by chance alone' not in note
                     and not note.startswith('the measurement was scaled'))
        target, target_record = reading.composite, 0
        name = f'Composite ({requirement.strategy} of {", ".join(labels)})'
    for band in requirement.composite_bands:
        rows.append(_band_row('composite', name, '', band, specification, j,
                              target, target_record, scale_db, requirement,
                              dofs))
    for band in requirement.individual_bands:
        for record, label in zip(records, labels, strict=True):
            rows.append(_band_row('channel', f'Channel {label}', label, band,
                                  specification, j, measured, record,
                                  scale_db, requirement, _dofs(measured,
                                                               [record])))
    if requirement.grms_percent is not None:
        rows.append(_grms_row(requirement, specification, j, target,
                              target_record, scale_db,
                              'composite' if requirement.strategy != 'single'
                              else labels[0]))
    if requirement.duration is not None:
        rows.append(_duration_row(requirement, duration_run))
    if requirement.min_dof is not None:
        rows.append(_dof_row(requirement, dofs, labels))
    if requirement.max_line_spacing is not None:
        rows.append(_spacing_row(requirement, measured))
    rows.extend(cross_axis_rows(requirement, specification, measured, table,
                                scale_db, spec_record=j))
    rows.extend(limit_rows(requirement, specification, j, target,
                           target_record, measured, scale_db, profiles))
    if requirement.strategy == 'single':
        # a multi-point reading names a dead channel itself (above)
        zero = sum(e.lines for row in rows if row.key == 'composite'
                   for e in row.excursions if math.isinf(e.worst_db))
        if zero:
            notes.append(f'{labels[0]} reads zero at {zero} '
                         f'line{"s" * (zero != 1)}, as a dead or '
                         'disconnected sensor does')
    return Results(requirement, tuple(rows), scale_db, tuple(labels),
                   tuple(dict.fromkeys(notes)))

cross_axis_rows

cross_axis_rows(requirement: TestRequirement, specification: Specification, measured: Psd, table: Any = None, scale_db: float = 0.0, *, spec_record: int = 0) -> list[Criterion]

The rows of the requirement's cross-axis limits: one per limit and cross-axis channel.

Each cross-axis channel's autospectrum is read as a percentage of its in-axis control channel's, line by line (a ratio of two densities, so the comparison's scale cancels), over the limit's band, which a limit left open at an end takes to the end the specification writes. The row gives the worst percentage and its frequency, how many lines were over the limit, and the degrees of freedom the two were read at, which must be the same for the ratio to be of like estimates. Met when no judged line is over. A line where either density is not a finite number, or the in-axis one is zero, is not judged; a row with such lines and none over is not judged, as a band row is, and so is a cross-axis channel that reads zero at every line, as a dead sensor does. A row with lines over offers their reach to a findings register (Criterion.finding). With no cross-axis channel named, each limit is one row saying so. This is the one place the rows are made.

Source code in src/visualdynamics/core/requirement.py
def cross_axis_rows(requirement: TestRequirement,
                    specification: Specification, measured: Psd,
                    table: Any = None, scale_db: float = 0.0, *,
                    spec_record: int = 0) -> list[Criterion]:
    """The rows of the requirement's cross-axis limits: one per limit
    and cross-axis channel.

    Each cross-axis channel's autospectrum is read as a percentage of
    its in-axis control channel's, line by line (a ratio of two
    densities, so the comparison's scale cancels), over the limit's
    band, which a limit left open at an end takes to the end the
    specification writes. The row gives the worst percentage and its
    frequency, how many lines were over the limit, and the degrees of
    freedom the two were read at, which must be the same for the ratio
    to be of like estimates. Met when no judged line is over. A line
    where either density is not a finite number, or the in-axis one is
    zero, is not judged; a row with such lines and none over is not
    judged, as a band row is, and so is a cross-axis channel that
    reads zero at every line, as a dead sensor does. A row with lines
    over offers their reach to a findings register (`Criterion.finding`).
    With no cross-axis channel named, each limit is one row saying so.
    This is the one place the rows are made.
    """
    rows = []
    if not requirement.cross_axis_channels:
        return [Criterion('cross_axis', 'Cross-axis', limit.text,
                          'not judged: no cross-axis channel is named',
                          NOT_JUDGED, band=_band_of(limit))
                for limit in requirement.cross_axis]
    for limit in requirement.cross_axis:
        for cross, in_axis in requirement.cross_axis_channels.items():
            rows.append(_cross_axis_row(limit, cross, in_axis,
                                        specification, spec_record,
                                        measured))
    return rows

limit_rows

limit_rows(requirement: TestRequirement, specification: Specification, j: int, target: Psd, target_record: int, measured: Psd, scale_db: float, profiles: Mapping[str, Any] | None = None) -> list[Criterion]

The rows of the requirement's limit channels: for each, the notches it cut, the as-run notch.

A controller holds its control to the reference except where that would put a limit channel over its profile; there it lowers the drive until the limit channel sits on its profile, and the control falls below the reference. So a line is notched where the limit channel is at its profile (no more than HELD_DB under it) while the control is more than HELD_DB under the reference, every curve read over the measurement's lines the way core.limit_prediction reads them (limit_prediction._density) and the measurement at the comparison's scale. A run of at least limit_prediction.NARROW_LINES adjacent notched lines is a notch, since a controller cuts no narrower and a line or two of scatter is not one; it is given by its band, how far under the reference the control went at worst, and its line count (an Excursion below). The row is not met when a notch lies outside every notch the plan names, and offers those notches to a findings register (Criterion.finding); its words say how close the limit channel came to its profile. A limit channel, profile or reference not read, and a limit channel reading zero, are a row saying so.

profiles holds the objects a limit channel names for its profile, by name; a limit channel naming none takes the specification's own record at its DOF, as core.limit_prediction.predict_limits does.

Source code in src/visualdynamics/core/requirement.py
def limit_rows(requirement: TestRequirement, specification: Specification,
               j: int, target: Psd, target_record: int, measured: Psd,
               scale_db: float, profiles: Mapping[str, Any] | None = None
               ) -> list[Criterion]:
    """The rows of the requirement's limit channels: for each, the
    notches it cut, the as-run notch.

    A controller holds its control to the reference except where that
    would put a limit channel over its profile; there it lowers the drive
    until the limit channel sits on its profile, and the control falls
    below the reference. So a line is notched where the limit channel is
    at its profile (no more than `HELD_DB` under it) while the control is
    more than `HELD_DB` under the reference, every curve read over the
    measurement's lines the way `core.limit_prediction` reads them
    (`limit_prediction._density`) and the measurement at the comparison's
    scale. A run of at least `limit_prediction.NARROW_LINES` adjacent
    notched lines is a notch, since a controller cuts no narrower and a
    line or two of scatter is not one; it is given by its band, how far
    under the reference the control went at worst, and its line count
    (an `Excursion` below). The row is not met when a notch lies outside
    every notch the plan names, and offers those notches to a findings
    register (`Criterion.finding`); its words say how close the limit
    channel came to its profile. A limit channel, profile or reference
    not read, and a limit channel reading zero, are a row saying so.

    `profiles` holds the objects a limit channel names for its profile,
    by name; a limit channel naming none takes the specification's own
    record at its DOF, as `core.limit_prediction.predict_limits` does.
    """
    from .limit_prediction import _density

    rows = []
    if not requirement.limit_channels:
        return rows
    left, right = target.bin_bounds()
    reference = _density(specification, j, left, right)
    factor = 10.0 ** (scale_db / 10.0)
    control = factor * np.real(np.asarray(target.ordinate[target_record],
                                          dtype=float))
    for channel in requirement.limit_channels:
        rows.append(_limit_row(channel, specification, measured, reference,
                               control, factor, left, right,
                               dict(profiles or {})))
    return rows

within

within(specification: Specification, record: int, low: float | None, high: float | None) -> Specification

One record of specification cut to low to high Hz.

A specification of breakpoints gains a breakpoint at each edge, interpolated log-log as it is read (compliance.log_interpolate), so a line across an edge is judged in each range over its own part, as compliance.cells cuts a cell to what is written. One on lines or bands keeps the points from low to high. Raises ValueError when the range holds nothing the specification writes.

Source code in src/visualdynamics/core/requirement.py
def within(specification: Specification, record: int, low: float | None,
           high: float | None) -> Specification:
    """One record of `specification` cut to `low` to `high` Hz.

    A specification of breakpoints gains a breakpoint at each edge,
    interpolated log-log as it is read (`compliance.log_interpolate`),
    so a line across an edge is judged in each range over its own part,
    as `compliance.cells` cuts a cell to what is written. One on lines
    or bands keeps the points from `low` to `high`. Raises `ValueError`
    when the range holds nothing the specification writes.
    """
    from .compliance import coverage, log_interpolate
    from .data import Specification

    x = np.asarray(specification.abscissa, dtype=float)
    values = np.real(np.asarray(specification.ordinate[record], dtype=float))
    written = coverage(specification, record)
    start = min(a for a, _b in written) if written else math.nan
    stop = max(b for _a, b in written) if written else math.nan
    lo = start if low is None else max(low, start)
    hi = stop if high is None else min(high, stop)
    if not written or not lo < hi:
        raise ValueError(f'the band {_range_text(low, high)} holds nothing '
                         'the specification writes (it runs from '
                         f'{_hz(start)} to {_hz(stop)} Hz)')
    bandwidth = getattr(specification, 'bandwidth', None)
    if getattr(specification, 'interpolation', 'bin') == 'log_log':
        inner = x[(x > lo) & (x < hi)]
        abscissa = np.concatenate([[lo], inner, [hi]])
        held = log_interpolate(abscissa, x, values)
        kept_bandwidth = None
    else:
        keep = (x >= lo) & (x <= hi)
        if not keep.any():
            raise ValueError(f'the band {_range_text(low, high)} holds no '
                             'line of the specification')
        abscissa, held = x[keep], values[keep]
        kept_bandwidth = (None if bandwidth is None
                          else np.asarray(bandwidth)[keep])
    out = Specification(
        abscissa, held[None],
        response_dof=[specification.response_dof[record]],
        ordinate_dim=[specification.ordinate_dim[record]],
        ordinate_unit=[specification.ordinate_unit[record]],
        dimension_hint=[specification.dimension_hint[record]],
        bandwidth=kept_bandwidth)
    out.interpolation = specification.interpolation
    return out

coverage_reach

coverage_reach(specification: Specification) -> tuple[float, float]

(low, high) Hz of what one-record specification writes.

Source code in src/visualdynamics/core/requirement.py
def coverage_reach(specification: Specification) -> tuple[float, float]:
    """(low, high) Hz of what one-record `specification` writes."""
    from .compliance import coverage

    written = coverage(specification, 0)
    return (min(a for a, _b in written), max(b for _a, b in written))

unread

unread(band: Specification, target: Psd, record: int) -> int

How many lines of target's record inside what band writes are not a finite density, and so were not judged: a sample that is not a number, or a line of a composite not formed.

Source code in src/visualdynamics/core/requirement.py
def unread(band: Specification, target: Psd, record: int) -> int:
    """How many lines of `target`'s record inside what `band` writes are
    not a finite density, and so were not judged: a sample that is not a
    number, or a line of a composite not formed."""
    x = np.asarray(target.abscissa, dtype=float)
    values = np.real(np.asarray(target.ordinate[record], dtype=float))
    low, high = coverage_reach(band)
    inside = (x >= low) & (x <= high)
    with np.errstate(invalid='ignore'):
        return int((inside & ~(np.isfinite(values) & (values >= 0.0))).sum())

combined_percent

combined_percent(contributions: Sequence[Contribution]) -> float

The contributions' combined uncertainty, expanded at COVERAGE, in percent on the acceleration: each divided by its own coverage factor, combined as the root of the sum of their squares, times COVERAGE. NaN for none.

Source code in src/visualdynamics/core/requirement.py
def combined_percent(contributions: Sequence[Contribution]) -> float:
    """The contributions' combined uncertainty, expanded at `COVERAGE`,
    in percent on the acceleration: each divided by its own coverage
    factor, combined as the root of the sum of their squares, times
    `COVERAGE`. NaN for none."""
    if not contributions:
        return math.nan
    return COVERAGE * math.sqrt(sum(c.standard ** 2 for c in contributions))

uncertainty_db

uncertainty_db(percent: float) -> float

An uncertainty in percent on the acceleration in dB on the ASD, taken on the side it is larger, below: −20 log10(1 − percent/100). Infinite at 100 percent or more.

Source code in src/visualdynamics/core/requirement.py
def uncertainty_db(percent: float) -> float:
    """An uncertainty in percent on the acceleration in dB on the ASD,
    taken on the side it is larger, below: `−20 log10(1 − percent/100)`.
    Infinite at 100 percent or more."""
    if not percent < 100.0:
        return math.inf
    return -20.0 * math.log10(1.0 - percent / 100.0)

uncertainty_budget

uncertainty_budget(requirement: TestRequirement, channels: Sequence[str] | None = None) -> tuple[Budget, ...]

Each control channel's uncertainty against one third of the requirement's tolerance.

The combined uncertainty is combined_percent of the channel's contributions, on the acceleration; on the ASD it is the same decibels (uncertainty_db) as a power ratio less one. It is held against one third of the tightest band edge of the requirement, in dB (composite and individual bands alike, since every control channel feeds the composite), and, with an overall rms tolerance, against one third of it in percent on the acceleration. Met when it is within both; a channel with nothing entered is not judged.

Parameters:

Name Type Description Default
requirement TestRequirement

The requirement, with its contributions.

required
channels sequence of str

The control channels, by response DOF: those evaluate found for the run. Left out, those the requirement names, else those it holds contributions for.

None

Returns:

Type Description
tuple of Budget

One per channel, in order.

Source code in src/visualdynamics/core/requirement.py
def uncertainty_budget(requirement: TestRequirement,
                       channels: Sequence[str] | None = None
                       ) -> tuple[Budget, ...]:
    """Each control channel's uncertainty against one third of the
    requirement's tolerance.

    The combined uncertainty is `combined_percent` of the channel's
    contributions, on the acceleration; on the ASD it is the same
    decibels (`uncertainty_db`) as a power ratio less one. It is held
    against one third of the tightest band edge of the requirement, in
    dB (composite and individual bands alike, since every control
    channel feeds the composite), and, with an overall rms tolerance,
    against one third of it in percent on the acceleration. Met when it
    is within both; a channel with nothing entered is not judged.

    Parameters
    ----------
    requirement : TestRequirement
        The requirement, with its contributions.
    channels : sequence of str, optional
        The control channels, by response DOF: those `evaluate` found
        for the run. Left out, those the requirement names, else those
        it holds contributions for.

    Returns
    -------
    tuple of Budget
        One per channel, in order.
    """
    if channels is None:
        channels = requirement.channels or tuple(requirement.uncertainty)
    edges = [abs(edge) for band in (*requirement.composite_bands,
                                    *requirement.individual_bands)
             for edge in (band.below_db, band.above_db)
             if math.isfinite(edge)]
    # a band has a finite edge on one side at least (`Band`)
    band_db = min(edges)
    band_limit = SHARE * band_db
    grms_limit = (None if requirement.grms_percent is None
                  else SHARE * requirement.grms_percent)
    out = []
    for channel in channels:
        channel = str(channel)
        held = requirement.uncertainty.get(channel, ())
        percent = combined_percent(held)
        db = uncertainty_db(percent) if held else math.nan
        asd = 100.0 * (10.0 ** (db / 10.0) - 1.0) if held else math.nan
        missed = []
        if not held:
            state, words = NOT_JUDGED, 'not judged: no contributions are entered'
        else:
            if not db <= band_limit * (1.0 + 1e-12):
                missed.append(f'{_db(db)} dB over one third of the '
                              f'{band_db:g} dB band edge, {band_limit:.2g} dB')
            if grms_limit is not None and \
                    not percent <= grms_limit * (1.0 + 1e-12):
                missed.append(f'{percent:.2g} % over one third of the '
                              f'{requirement.grms_percent:g} % rms '
                              f'tolerance, {grms_limit:.2g} %')
            state = NOT_MET if missed else MET
            words = '; '.join(missed)
        out.append(Budget(channel, held, percent, asd, db, band_limit,
                          band_db, grms_limit, state, words))
    return tuple(out)