Skip to content

visualdynamics.core.multi_point

multi_point

Multi-point control: several control channels combined into one, and the two judgments a test run that way is accepted on.

A random test on one shaker is often controlled at more than one point. The controller combines the control channels' spectra, line by line, into one composite and holds that to the reference; how they are combined is the strategy:

  • 'average', the mean of the channels' densities, each counted alike;
  • 'weighted', the mean with weights chosen for the channels;
  • 'maximum', the highest channel at each line, so nothing sensed exceeds the reference and every other point is under it;
  • 'minimum', the lowest channel at each line, so every point sensed meets the reference and every other point is over it.

The average is taken on the densities themselves, not on their decibels, because that is the arithmetic a controller does. With two channels, one at four times the reference and one at a quarter, the average is just over twice the reference: the hot spot shows, far smaller than it is.

So a run controlled this way has two results, and a report carries both: the composite against the composite band, and each control channel against its own band, which is wider. The second exists because the first can pass with a channel far out. A channel reading near zero inside an average (a dead or loose sensor) leaves the composite in tolerance while the controller drives the live channels up to make up for it, and nothing on the composite says so. The reading here lists, for each channel, the lines where it is outside its own band while the composite is inside its band, which is the fact the composite hides.

Every judgment is compliance.compare and compliance.exceedances, the comparison every other random reading makes: the composite and each channel are compared cell by cell, as areas, against a band around the reference, at the comparison's one scale. Nothing here judges a line by its own rule.

Classes:

Name Description
ChannelReading

One control channel of a multi-point reading, against its own

MultiPointReading

What multi_point read: the composite, its judgment, and each

Functions:

Name Description
composite

The composite of several channels' densities, line by line.

multi_point

Read a multi-point control run: the composite of its control

control_channels

The measured records a multi-point reading combines, by the rule

Classes

ChannelReading dataclass

ChannelReading(label: str, record: int, weight: float, result: dict[str, Any], over: ndarray, under: ndarray, hidden: ndarray, held_lines: int | None = None)

One control channel of a multi-point reading, against its own band.

Attributes:

Name Type Description
outside ndarray

Per line, outside the individual band either way.

outside_lines int

How many lines are outside the individual band.

judged_lines int

How many lines were judged against the individual band: none

hidden_lines int

How many lines are outside the individual band while the

Attributes
outside property
outside: ndarray

Per line, outside the individual band either way.

outside_lines property
outside_lines: int

How many lines are outside the individual band.

judged_lines property
judged_lines: int

How many lines were judged against the individual band: none for a channel with no finite line, which was not judged at all and is never read as inside its band.

hidden_lines property
hidden_lines: int

How many lines are outside the individual band while the composite is inside its own.

MultiPointReading dataclass

MultiPointReading(strategy: str, composite: Psd, spec_record: int, composite_band: Specification, individual_band: Specification, composite_text: str, individual_text: str, result: dict[str, Any], over: ndarray, under: ndarray, channels: tuple[ChannelReading, ...], scale_db: float, notes: tuple[str, ...] = (), chance_share: float | None = None)

What multi_point read: the composite, its judgment, and each channel's.

Attributes:

Name Type Description
outside_lines int

How many lines of the composite are outside its band.

judged_lines int

How many lines of the composite were judged: none when it was

hiding tuple[ChannelReading, ...]

The channels outside their own band at a line where the

Attributes
outside_lines property
outside_lines: int

How many lines of the composite are outside its band.

judged_lines property
judged_lines: int

How many lines of the composite were judged: none when it was formed at no line (a channel with no finite line), in which case nothing was judged and the reading is never a pass.

hiding property
hiding: tuple[ChannelReading, ...]

The channels outside their own band at a line where the composite is inside its band.

Functions:

composite

composite(values: ArrayLike, strategy: str = 'average', weights: ArrayLike | None = None) -> ndarray

The composite of several channels' densities, line by line.

values is (channels, lines), real densities on one set of lines. 'average' and 'weighted' take the weighted mean of the densities (weights normalized to sum to one, equal under 'average'), 'maximum' and 'minimum' the highest and lowest channel at each line.

A line where any channel is not a finite, non-negative number has no composite and is NaN: the composite of these channels was not formed there, and averaging the rest would be a composite of other channels. A channel reading zero is a number and is counted, as a controller counts it, which is what lets a dead channel be found.

Parameters:

Name Type Description Default
values array_like

The densities, one row per channel.

required
strategy str

One of STRATEGIES.

'average'
weights array_like

One per channel, finite and not negative, under 'weighted' only; normalized here.

None

Returns:

Type Description
ndarray

One value per line.

Raises:

Type Description
ValueError

For a strategy not in STRATEGIES, weights under a strategy that takes none or none under 'weighted', or weights that are not one finite, non-negative number per channel with a positive sum.

Source code in src/visualdynamics/core/multi_point.py
def composite(values: ArrayLike, strategy: str = 'average',
              weights: ArrayLike | None = None) -> np.ndarray:
    """The composite of several channels' densities, line by line.

    `values` is (channels, lines), real densities on one set of lines.
    'average' and 'weighted' take the weighted mean of the densities
    (weights normalized to sum to one, equal under 'average'),
    'maximum' and 'minimum' the highest and lowest channel at each line.

    A line where any channel is not a finite, non-negative number has no
    composite and is NaN: the composite of these channels was not formed
    there, and averaging the rest would be a composite of other
    channels. A channel reading zero is a number and is counted, as a
    controller counts it, which is what lets a dead channel be found.

    Parameters
    ----------
    values : array_like
        The densities, one row per channel.
    strategy : str, default 'average'
        One of `STRATEGIES`.
    weights : array_like, optional
        One per channel, finite and not negative, under 'weighted'
        only; normalized here.

    Returns
    -------
    numpy.ndarray
        One value per line.

    Raises
    ------
    ValueError
        For a strategy not in `STRATEGIES`, weights under a strategy
        that takes none or none under 'weighted', or weights that are
        not one finite, non-negative number per channel with a
        positive sum.
    """
    values = np.atleast_2d(np.asarray(np.real(values), dtype=float))
    share = _weights(strategy, weights, values.shape[0])
    with np.errstate(invalid='ignore'):
        usable = np.isfinite(values) & (values >= 0.0)
    formed = usable.all(axis=0)
    out = np.full(values.shape[1], np.nan)
    if not formed.any():
        return out
    held = values[:, formed]
    if strategy == 'maximum':
        out[formed] = held.max(axis=0)
    elif strategy == 'minimum':
        out[formed] = held.min(axis=0)
    else:
        out[formed] = share @ held
    return out

multi_point

multi_point(specification: Specification, measured: Psd, channels: Sequence[int | str] | None = None, strategy: str = 'average', weights: ArrayLike | None = None, composite_band: str | tuple[float, float] = 'warning', individual_band: str | tuple[float, float] = INDIVIDUAL_DB, spec_record: int | None = None, scale_db: float | None = None, table: Any = None) -> MultiPointReading

Read a multi-point control run: the composite of its control channels against the composite band, and each channel against its own.

The composite is formed line by line (composite) from the control channels' densities and compared with the reference by compliance.compare; each channel is compared the same way against the individual band. A band is a pair of the specification's own limits ('warning' or 'abort'), or a pair of dB below and above the reference, ten log ten on a density (an infinite edge is no edge on that side). For each channel the lines outside its own band are found, and among them those where the composite is inside its band: the lines the composite hides.

The reference is one record of the specification: spec_record when given, the only record when there is one, and otherwise the record each channel pairs with (compliance.matched_records), which must all ask for the same thing, since a composite is held to one reference. The scale is resolved once for every channel, as compliance.compare_all resolves it.

Parameters:

Name Type Description Default
specification Specification

The reference and, where its limits are used, the bands.

required
measured Psd

The run, holding the control channels' autospectra.

required
channels sequence of int or str

The control channels, by record or by response DOF; by default those control_channels finds (the channel table's control channels, else the autospectra that pair with the specification's records). Two or more.

None
strategy str

One of STRATEGIES.

'average'
weights array_like

One per channel, under 'weighted' only.

None
composite_band str or tuple of float

'warning' or 'abort' for that pair of the specification's limits, or (below, above) in dB.

'warning'
individual_band str or tuple of float

'warning' or 'abort' for that pair of the specification's limits, or (below, above) in dB.

'warning'
spec_record int

The reference record, chosen as above when omitted.

None
scale_db float

The comparison's scale; resolved through compliance.comparison_scale_db when omitted.

None
table ChannelTable

The channel table whose Control flags name the control channels when channels is omitted (control_channels).

None

Returns:

Type Description
MultiPointReading

The composite and its judgment, each channel's, and the notes.

Raises:

Type Description
ValueError

With the reason, for anything the reading cannot be made from: a strategy or weights composite refuses, fewer than two channels, a channel named twice, not found, ambiguous or a cross term, channels of different quantities, channels whose specification records differ, a band the specification does not carry or edges on the wrong side of the reference, or a pair compliance.compare refuses (bands of another fraction, another quantity, units declared on one side only).

Source code in src/visualdynamics/core/multi_point.py
def multi_point(specification: Specification, measured: Psd,
                channels: Sequence[int | str] | None = None,
                strategy: str = 'average',
                weights: ArrayLike | None = None,
                composite_band: str | tuple[float, float] = 'warning',
                individual_band: str | tuple[float, float] = INDIVIDUAL_DB,
                spec_record: int | None = None,
                scale_db: float | None = None,
                table: Any = None) -> MultiPointReading:
    """Read a multi-point control run: the composite of its control
    channels against the composite band, and each channel against its
    own.

    The composite is formed line by line (`composite`) from the control
    channels' densities and compared with the reference by
    `compliance.compare`; each channel is compared the same way against
    the individual band. A band is a pair of the specification's own
    limits ('warning' or 'abort'), or a pair of dB below and above the
    reference, ten log ten on a density (an infinite edge is no edge on
    that side). For each channel the lines outside its own band are
    found, and among them those where the composite is inside its band:
    the lines the composite hides.

    The reference is one record of the specification: `spec_record`
    when given, the only record when there is one, and otherwise the
    record each channel pairs with (`compliance.matched_records`),
    which must all ask for the same thing, since a composite is held to
    one reference. The scale is resolved once for every channel, as
    `compliance.compare_all` resolves it.

    Parameters
    ----------
    specification : Specification
        The reference and, where its limits are used, the bands.
    measured : Psd
        The run, holding the control channels' autospectra.
    channels : sequence of int or str, optional
        The control channels, by record or by response DOF; by default
        those `control_channels` finds (the channel table's control
        channels, else the autospectra that pair with the
        specification's records). Two or more.
    strategy : str, default 'average'
        One of `STRATEGIES`.
    weights : array_like, optional
        One per channel, under 'weighted' only.
    composite_band, individual_band : str or tuple of float
        'warning' or 'abort' for that pair of the specification's
        limits, or (below, above) in dB.
    spec_record : int, optional
        The reference record, chosen as above when omitted.
    scale_db : float, optional
        The comparison's scale; resolved through
        `compliance.comparison_scale_db` when omitted.
    table : ChannelTable, optional
        The channel table whose Control flags name the control channels
        when `channels` is omitted (`control_channels`).

    Returns
    -------
    MultiPointReading
        The composite and its judgment, each channel's, and the notes.

    Raises
    ------
    ValueError
        With the reason, for anything the reading cannot be made from:
        a strategy or weights `composite` refuses, fewer than two
        channels, a channel named twice, not found, ambiguous or a
        cross term, channels of different quantities, channels whose
        specification records differ, a band the specification does not
        carry or edges on the wrong side of the reference, or a pair
        `compliance.compare` refuses (bands of another fraction,
        another quantity, units declared on one side only).
    """
    from .compliance import (
        comparison_scale_db,
        units_check,
    )
    from .data import Psd, Specification

    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 control channels are a measured PSD')
    records = control_channels(specification, measured, channels, table)
    labels = [str(measured.response_dof[i]) for i in records]
    _weights(strategy, weights, len(records))       # refused before work
    # one quantity across the channels, by the rule every comparison
    # uses: an average of an acceleration and a velocity is no quantity
    first = records[0]
    raw_note = None
    for i, label in zip(records[1:], labels[1:], strict=True):
        refused, note = units_check(measured.ordinate_dim[first],
                                    measured.ordinate_dim[i],
                                    wanted_name=f'channel {labels[0]}',
                                    held_name=f'channel {label}')
        if refused is not None:
            raise ValueError(refused.replace('cannot be compared',
                                             'cannot be combined'))
        raw_note = raw_note or note
    j = _reference(specification, measured, records, spec_record)
    composite_spec, composite_text = _band(specification, j, composite_band,
                                           'composite')
    individual_spec, individual_text = _band(specification, j,
                                             individual_band, 'individual')
    if scale_db is None:
        scale_db = comparison_scale_db(specification, measured, None,
                                       records, labels)
        detected = (scale_db
                    and getattr(measured, 'scale_db', None) is None)
    else:
        detected = False
    scale_db = float(scale_db)

    values = np.real(np.asarray(measured.ordinate))[records]
    formed = composite(values, strategy, weights)
    summed = Psd(measured.abscissa, formed[None],
                 response_dof=[''],
                 ordinate_dim=[measured.ordinate_dim[first]],
                 ordinate_unit=[measured.ordinate_unit[first]],
                 dimension_hint=[measured.dimension_hint[first]],
                 comment=[f'{strategy} of {", ".join(labels)}'],
                 bandwidth=measured.bandwidth)
    summed.interpolation = measured.interpolation

    result, over, under = _judged(composite_spec, summed, 0, scale_db)
    composite_in = ~(over | under) & np.isfinite(formed)
    share = _weights(strategy, weights, len(records))
    held = _held(values, formed, strategy)
    readings = []
    for k, (i, label) in enumerate(zip(records, labels, strict=True)):
        found, c_over, c_under = _judged(individual_spec, measured, i,
                                         scale_db)
        readings.append(ChannelReading(
            label=label, record=int(i),
            weight=float('nan') if share is None else float(share[k]),
            result=found, over=c_over, under=c_under,
            hidden=(c_over | c_under) & composite_in,
            held_lines=None if held is None else int(held[k])))

    notes = _notes(values, labels, share, formed)
    chance = _chance_share(measured, records, individual_band)
    if chance is not None and chance[0] >= 0.001:
        notes.append(f'at {chance[1]:.4g} degrees of freedom about '
                     f'{100.0 * chance[0]:.2g} % of a working channel\'s '
                     f'lines fall outside {individual_text} by chance alone, '
                     'and are counted among the lines the composite hides')
    if detected:
        notes.append(f'the measurement was scaled by {scale_db:+g} dB, '
                     f'detected from the data as a run at {-scale_db:+g} '
                     'dB')
    if raw_note is not None or result.get('note'):
        notes.append(raw_note or result['note'])
    return MultiPointReading(
        strategy=strategy, composite=summed, spec_record=j,
        composite_band=composite_spec, individual_band=individual_spec,
        composite_text=composite_text, individual_text=individual_text,
        result=result, over=over, under=under, channels=tuple(readings),
        scale_db=scale_db, notes=tuple(notes),
        chance_share=None if chance is None else chance[0])

control_channels

control_channels(specification: Specification, measured: Psd, channels: Sequence[int | str] | None = None, table: Any = None) -> list[int]

The measured records a multi-point reading combines, by the rule multi_point uses: the records named, by index or by response DOF; by default the autospectra at the channels a channel table flags as the specification's loop's control channels (ChannelTable.controls_for), where it flags any; and otherwise the autospectra that pair with the specification's records (compliance.matched_records). The window asks this before it offers the reading, so the reading is offered exactly where it can be made.

Parameters:

Name Type Description Default
specification Specification

The reference the channels pair with.

required
measured Psd

The run.

required
channels sequence of int or str

The channels named; found as above when omitted.

None
table ChannelTable

The channel table whose Control flags say which channels the controller held.

None

Returns:

Type Description
list of int

Two or more records of measured, in the order named (or the table's order).

Raises:

Type Description
ValueError

With the reason, for fewer than two channels, or a channel named twice, not found, ambiguous or a cross term.

Source code in src/visualdynamics/core/multi_point.py
def control_channels(specification: Specification, measured: Psd,
                     channels: Sequence[int | str] | None = None,
                     table: Any = None) -> list[int]:
    """The measured records a multi-point reading combines, by the rule
    `multi_point` uses: the records named, by index or by response
    DOF; by default the autospectra at the channels a channel `table`
    flags as the specification's loop's control channels
    (`ChannelTable.controls_for`), where it flags any; and otherwise the autospectra that pair with the
    specification's records (`compliance.matched_records`). The window
    asks this before it offers the reading, so the reading is offered
    exactly where it can be made.

    Parameters
    ----------
    specification : Specification
        The reference the channels pair with.
    measured : Psd
        The run.
    channels : sequence of int or str, optional
        The channels named; found as above when omitted.
    table : ChannelTable, optional
        The channel table whose Control flags say which channels the
        controller held.

    Returns
    -------
    list of int
        Two or more records of `measured`, in the order named (or the
        table's order).

    Raises
    ------
    ValueError
        With the reason, for fewer than two channels, or a channel
        named twice, not found, ambiguous or a cross term.
    """
    from .compliance import matched_records

    autos = [i for i in range(measured.num_records)
             if measured.reference_dof is None
             or str(measured.reference_dof[i]) == str(measured.response_dof[i])]
    flagged = (np.asarray(table.controls_for(specification.response_dof),
                          dtype=bool)
               if table is not None and channels is None
               else np.zeros(0, dtype=bool))
    if flagged.any():
        # the control channels are a fact the channel table keeps
        found = []
        dofs, types = table.dof_strings(), table.types()
        for k in np.flatnonzero(flagged):
            at = [i for i in autos if str(measured.response_dof[i]) == dofs[k]]
            if len(at) > 1 and types[k]:
                # a DOF can carry two quantities (a force and an
                # acceleration at a drive point): the table's row names
                # which, and a density of it is that quantity squared
                at = [i for i in at
                      if str(measured.ordinate_dim[i]).split('**')[0]
                      == types[k]]
            if len(at) > 1:
                raise ValueError(f'the measurement has {len(at)} autospectra '
                                 f'at {dofs[k]}, a control channel of the '
                                 'channel table; name the one meant by its '
                                 'record')
            found += [i for i in at if i not in found]
        if not found:
            raise ValueError('the channel table flags control channels at '
                             f'{", ".join(dofs[k] for k in np.flatnonzero(flagged))}'
                             ', and the measurement holds no autospectrum '
                             'at any of them')
    elif channels is None:
        found = []
        for _label, _j, i in matched_records(specification, measured):
            if i in autos and i not in found:
                found.append(i)
    elif not len(channels):
        raise ValueError('channels names none; a composite needs two or more '
                         'control channels')
    else:
        found = []
        for name in channels:
            if isinstance(name, (int, np.integer)):
                i = int(name)
                if not 0 <= i < measured.num_records:
                    raise ValueError(f'there is no record {i}; the '
                                     f'measurement has '
                                     f'{measured.num_records}')
            else:
                at = [k for k in range(measured.num_records)
                      if str(measured.response_dof[k]) == str(name)
                      and k in autos]
                if not at:
                    raise ValueError(f'the measurement has no autospectrum '
                                     f'at {name}')
                if len(at) > 1:
                    raise ValueError(f'the measurement has {len(at)} '
                                     f'autospectra at {name}; name the one '
                                     'meant by its record')
                i = at[0]
            if i not in autos:
                raise ValueError(
                    f'record {i} is a cross term, '
                    f'{measured.response_dof[i]}/{measured.reference_dof[i]}; '
                    'a composite is of autospectra')
            if i in found:
                raise ValueError(f'{measured.response_dof[i]} is named '
                                 'twice; each channel counts once')
            found.append(i)
    if len(found) < 2:
        raise ValueError(
            'a composite needs two or more control channels; '
            + ('one is a single-point comparison, which the comparison '
               'itself reads' if found else
               'none of the measurement\'s autospectra pairs with the '
               'specification, so name them'))
    return found