Skip to content

visualdynamics.core.stroke

stroke

Whether a shock pulse fits on the shaker: its stroke, velocity and force against the shaker's shock ratings.

A shaker can deliver a pulse only if it can deliver three things at once: the force for the peak, the velocity the pulse leaves the table with, and the travel the whole event needs, compensation included. The three are spent by different parts of the pulse. The peak acceleration costs force (the moving mass times the peak). The duration costs stroke, since displacement grows with the peak times the duration squared. The velocity sits between them. So the check reads all three off the pulse and sets each against its rating.

The pulse is a record or a nominal one. compensated_half_sine builds the nominal pulse a stroke is usually first estimated on: a half-sine with a half-sine pre-pulse before it and a post-pulse after it, each at a share of the peak (a fifth by default, COMPENSATION_SHARE) and opposite in sign, each lasting long enough to carry half the main pulse's velocity change back. The table then starts and ends at rest: the pre-pulse takes it to minus half the velocity change, the main pulse to plus half, and the post-pulse back to zero, so its peak velocity is half the velocity change. At a fifth of the peak the whole event lasts six durations; at a tenth, eleven.

read_stroke integrates the acceleration twice with nothing removed, the cumulative trapezoid rule of filters.integrate called with drift_corner=None, from rest at the record's first sample. It is the raw path on purpose. The displacement of a compensated pulse is content at a few hertz, exactly where the default 2 Hz drift high-pass stops being transparent, and with it the 50 g, 11 ms pulse's stroke reads about 4 % high and no longer starts and ends at rest. A stroke estimate is integrated raw, and a record whose velocity does not come back to rest is a record to fix before its stroke is believed.

The reading is the peak acceleration, the peak velocity, the velocity change, and the displacement each way from rest with its peak to peak. The travel, the velocity and the force are judged by the rule in core.shaker, the one the replication fit judges by, and three corrections are carried with it:

  • The shaker body. The ratings are the armature's motion relative to the body, and a body on isolation mounts moves the other way by the ratio of the moving mass to its own, so the travel that reaches the stops, and the velocity the coil moves at, are the table's times one plus that ratio. The compensation is low in frequency, where the body moves most. Given the body mass with the moving mass, both are counted that way.
  • The stops each way. The stroke is peak to peak, and the armature rests at the middle of it, held there by its suspension, so the stops are half the stroke away on each side. The symmetric arrangement above travels one way only, to its full peak to peak and back, so it can be inside the stroke peak to peak and still reach a stop. The stroke share is therefore the larger travel each way from center over half the stroke, what the stops see, and the peak to peak is reported as a number without a verdict. When the peak to peak fits and the share is over, the reading says so: centering the travel is the controller's compensation, an asymmetric pre- and post-pulse.
  • The first check. The peak to peak of the nominal arrangement is close to A T^2 / 2 for a half-sine of peak A and duration T, which in the units shock is specified in is about 193 A T^2 inches with A in g and T in seconds (rule_stroke). The reading reports it beside the integrated value, computed for the half-sine of the record's own peak and velocity change, so on the nominal pulse the rule reads 0.16 % high (the same on every pulse, since the ratio is the arrangement's) and on any other pulse the difference is what its shape and its compensation are worth.

A controller that shapes its compensation for least displacement does better than the symmetric arrangement, sometimes by a useful margin, so the nominal reading is a first check: a pulse it passes with room fits, and one it puts near a rating sends the operator to the controller's own prediction and to a measurement.

References

The double integration and the compensated pulse are standard; these are where the compensation, and why the table must end at rest, are set out.

  1. Department of Defense (2019). MIL-STD-810H, Environmental Engineering Considerations and Laboratory Tests. Method 516.8, paragraph 2.3.2.3.2: a classical pulse on a vibration exciter compensated, what the compensation costs the pulse's spectrum, and the band that limits it; Figures 516.8-3 to 516.8-5: the acceleration before and after the pulse, where its compensation goes, held within a fifth of the peak over 2.5 durations either side.
  2. NASA-STD-7003A (2011), Pyroshock Test Criteria. Paragraph 4.4: a shock on a shaker changes velocity by nothing over the whole event, or the armature reaches its stops.

Classes:

Name Description
StrokeReading

One channel's stroke, velocity and force against the ratings

Functions:

Name Description
compensated_half_sine

A half-sine with symmetric half-sine compensation, as a record.

rule_stroke

The half-sine rule's peak to peak, in m: RULE times A T^2.

read_stroke

Integrate one acceleration channel twice, raw, and read it

amount

value, in SI, as a number and a unit in unit_system: three

Classes

StrokeReading dataclass

StrokeReading(dof: str, sample_rate: float, start: float, acceleration: ndarray, velocity: ndarray, displacement: ndarray, rating: ShakerRating)

One channel's stroke, velocity and force against the ratings given.

The arrays are the acceleration, and the velocity and displacement integrated from it raw, in SI (m/s^2, m/s and m), from rest at the record's first sample. rating holds the shaker's shock ratings and the masses, in SI, with None where none was given; a rating that is None is not judged. Its stroke is the shaker's shock stroke, peak to peak, the travel of the armature relative to the body.

Methods:

Name Description
with_ratings

This reading against other ratings, the arrays kept: the

shares

Each demand as a share of its rating, by core.shaker's rule:

to_the_stop

The larger travel from center against the half stroke to its

describe

The reading in lines, the numbers in unit_system

Attributes:

Name Type Description
time ndarray

Each sample's time on the record's clock, in seconds.

duration float

How long the record lasts, first sample to last, in s.

peak_acceleration float

The largest acceleration either way, in m/s^2.

peak_velocity float

The table's largest velocity either way, in m/s.

relative_velocity float

The armature's largest velocity against the body, in m/s:

velocity_change float

The span from the lowest velocity to the highest, in m/s

lowest float

The table's furthest travel below where it started, in m:

highest float

The table's furthest travel above where it started, in m:

peak_to_peak float

The table's displacement, highest less lowest, in m.

counts_body bool

Whether the body's motion is counted: the body mass and the

travel float

The armature's peak-to-peak travel relative to the body, in

travel_down float

The relative travel below center, where the armature rests,

travel_up float

The relative travel above center, where the armature rests,

equivalent_duration float

The duration of the half-sine with this record's peak and

rule float

The half-sine rule's peak to peak for this record's peak and

force float | None

The force the coil must make, the moving mass times the peak

fits bool | None

Whether every rating given holds the pulse; None when no

fits_stroke bool | None

Whether the travel stays inside half the stroke each way from

Attributes
time property
time: ndarray

Each sample's time on the record's clock, in seconds.

duration property
duration: float

How long the record lasts, first sample to last, in s.

peak_acceleration property
peak_acceleration: float

The largest acceleration either way, in m/s^2.

peak_velocity property
peak_velocity: float

The table's largest velocity either way, in m/s.

relative_velocity property
relative_velocity: float

The armature's largest velocity against the body, in m/s: peak_velocity with the body counted, what the velocity rating is judged against.

velocity_change property
velocity_change: float

The span from the lowest velocity to the highest, in m/s (core.shock_check.velocity_span): for a one-signed pulse with compensation either side, the main pulse's change.

lowest property
lowest: float

The table's furthest travel below where it started, in m: zero or negative.

highest property
highest: float

The table's furthest travel above where it started, in m: zero or positive.

peak_to_peak property
peak_to_peak: float

The table's displacement, highest less lowest, in m.

counts_body property
counts_body: bool

Whether the body's motion is counted: the body mass and the moving mass were both given.

travel property
travel: float

The armature's peak-to-peak travel relative to the body, in m: the table's peak to peak with the body counted. A number, not judged: the stops see the travel each way.

travel_down property
travel_down: float

The relative travel below center, where the armature rests, in m, positive.

travel_up property
travel_up: float

The relative travel above center, where the armature rests, in m, positive.

equivalent_duration property
equivalent_duration: float

The duration of the half-sine with this record's peak and velocity change, in s: πΔV / (2A).

On a half-sine it is the pulse's own duration; on any other shape it is the half-sine the rule beside the integration is computed for.

rule property
rule: float

The half-sine rule's peak to peak for this record's peak and equivalent_duration, in m: the first check beside the integrated peak_to_peak (rule_stroke).

force property
force: float | None

The force the coil must make, the moving mass times the peak acceleration, in N; None without a moving mass.

fits property
fits: bool | None

Whether every rating given holds the pulse; None when no rating was given.

fits_stroke property
fits_stroke: bool | None

Whether the travel stays inside half the stroke each way from center, where the armature rests and the stops are half the stroke away; None without a stroke.

Methods:
with_ratings
with_ratings(*, stroke: float | None = None, velocity: float | None = None, force: float | None = None, moving_mass: float | None = None, body_mass: float | None = None) -> StrokeReading

This reading against other ratings, the arrays kept: the integration does not depend on the ratings, so a rating moved in the panel does not integrate the record again. The ratings are taken as read_stroke takes them, zero and None both meaning none.

Source code in src/visualdynamics/core/stroke.py
def with_ratings(self, *, stroke: float | None = None,
                 velocity: float | None = None,
                 force: float | None = None,
                 moving_mass: float | None = None,
                 body_mass: float | None = None) -> StrokeReading:
    """This reading against other ratings, the arrays kept: the
    integration does not depend on the ratings, so a rating moved
    in the panel does not integrate the record again. The ratings
    are taken as `read_stroke` takes them, zero and None both
    meaning none."""
    from dataclasses import replace

    return replace(self, rating=ShakerRating(
        force=_rating(force), stroke=_rating(stroke),
        velocity=_rating(velocity), moving_mass=_rating(moving_mass),
        body_mass=_rating(body_mass)))
shares
shares() -> dict[str, float | None]

Each demand as a share of its rating, by core.shaker's rule: 'stroke' (the larger travel each way from center over half the stroke, so one is a stop), 'velocity' (counted against the body) and 'force'. None where the rating, or for the force the moving mass, was not given.

Source code in src/visualdynamics/core/stroke.py
def shares(self) -> dict[str, float | None]:
    """Each demand as a share of its rating, by `core.shaker`'s rule:
    'stroke' (the larger travel each way from center over half the
    stroke, so one is a stop), 'velocity' (counted against the body)
    and 'force'. None where the rating, or for the force the moving
    mass, was not given."""
    return {'stroke': self.rating.stroke_share(self.travel_down,
                                               self.travel_up),
            'velocity': self.rating.velocity_share(self.peak_velocity),
            'force': self.rating.force_share(self.peak_acceleration)}
to_the_stop
to_the_stop(unit_system: UnitSystem | None = None) -> str | None

The larger travel from center against the half stroke to its stop, in words, such as 'one-sided travel 1.46 in against 1.25 in to the stop'; None without a stroke.

Source code in src/visualdynamics/core/stroke.py
def to_the_stop(self, unit_system: UnitSystem | None = None
                ) -> str | None:
    """The larger travel from center against the half stroke to its
    stop, in words, such as 'one-sided travel 1.46 in against
    1.25 in to the stop'; None without a stroke."""
    from ..units import DEFAULT_SYSTEM

    if self.rating.room is None:
        return None
    us = DEFAULT_SYSTEM if unit_system is None else unit_system
    larger = max(self.travel_down, self.travel_up)
    return (f'{self._travel_name()} {amount(larger, "length", us)} '
            f'against {amount(self.rating.room, "length", us)} to the '
            'stop')
describe
describe(unit_system: UnitSystem | None = None) -> list[str]

The reading in lines, the numbers in unit_system (units.DEFAULT_SYSTEM when omitted): the travel to the stop, the velocity and the force each against its rating, the displacement each way, and the peak to peak beside the half-sine rule.

Source code in src/visualdynamics/core/stroke.py
def describe(self, unit_system: UnitSystem | None = None) -> list[str]:
    """The reading in lines, the numbers in `unit_system`
    (`units.DEFAULT_SYSTEM` when omitted): the travel to the stop,
    the velocity and the force each against its rating, the
    displacement each way, and the peak to peak beside the half-sine
    rule."""
    from ..units import DEFAULT_SYSTEM

    us = DEFAULT_SYSTEM if unit_system is None else unit_system
    shares = self.shares()

    def verdict(line, key):
        if shares[key] is None:
            return line
        word = 'within' if shares[key] <= 1.0 else 'OVER'
        return f'{line}: {word}, {100.0 * shares[key]:.0f} %'

    def against(name, demand, rating, key, dimension):
        line = f'{name} {amount(demand, dimension, us)}'
        if shares[key] is not None:
            line += f' against {amount(rating, dimension, us)}'
        return verdict(line, key)

    rating = self.rating
    if rating.room is None:
        travel = (f'{sentence(self._travel_name())} '
                  f'{amount(max(self.travel_down, self.travel_up), "length", us)}')
    else:
        travel = verdict(sentence(self.to_the_stop(us)), 'stroke')
    lines = [travel]
    if self.fits_stroke is False and self.travel <= rating.stroke:
        lines.append(f'Centered, the travel would fit: '
                     f'{amount(self.travel, "length", us)} peak to peak '
                     f'against {amount(rating.stroke, "length", us)}. '
                     'Centering the travel is the controller\'s '
                     'compensation, an asymmetric pre- and post-pulse')
    lines.append(against('Peak velocity'
                         + ' with the body' * self.counts_body,
                         self.relative_velocity, rating.velocity,
                         'velocity', 'velocity'))
    if self.force is not None:
        lines.append(against('Force', self.force, rating.force,
                             'force', 'force'))
    lines.append(f'Each way from center: '
                 f'{amount(self.travel_down, "length", us)} down, '
                 f'{amount(self.travel_up, "length", us)} up')
    lines.append(f'Peak to peak {amount(self.peak_to_peak, "length", us)} '
                 f'integrated, {amount(self.rule, "length", us)} by the '
                 'half-sine rule')
    return lines

Functions:

compensated_half_sine

compensated_half_sine(peak: float, duration: float, sample_rate: float, *, share: float = COMPENSATION_SHARE, response_dof: str = '1Z+', unit: str | None = None) -> TimeHistory

A half-sine with symmetric half-sine compensation, as a record.

pulse = compensated_half_sine(50.0, 0.011, 102400.0, unit='g')

The main pulse, of peak peak and duration duration, sits between a pre-pulse and a post-pulse of the opposite sign, each at share times the peak and each lasting duration / (2 * share), so each carries back half the main pulse's area. The record opens at the pre-pulse's first sample and closes at the post-pulse's last, and the table starts and ends it at rest: its net velocity change and its net displacement are both zero.

Parameters:

Name Type Description Default
peak float

The main pulse's peak, in unit (SI when omitted).

required
duration float

The main pulse's duration, in seconds.

required
sample_rate float

Samples per second.

required
share float

The compensation pulses' height as a share of the peak, between zero and one.

`COMPENSATION_SHARE`
response_dof str

The channel the record is at.

'1Z+'
unit str

The unit peak is written in, such as 'g'; the record keeps it as its declared unit.

None

Returns:

Type Description
TimeHistory

One acceleration channel, stored in SI, on a clock that starts at zero.

Raises:

Type Description
ValueError

If the peak, the duration or the sample rate is not positive, the share is not between zero and one, or the main pulse is shorter than four samples.

Source code in src/visualdynamics/core/stroke.py
def compensated_half_sine(peak: float, duration: float,
                          sample_rate: float, *,
                          share: float = COMPENSATION_SHARE,
                          response_dof: str = '1Z+',
                          unit: str | None = None) -> TimeHistory:
    """A half-sine with symmetric half-sine compensation, as a record.

        pulse = compensated_half_sine(50.0, 0.011, 102400.0, unit='g')

    The main pulse, of peak `peak` and duration `duration`, sits between
    a pre-pulse and a post-pulse of the opposite sign, each at `share`
    times the peak and each lasting ``duration / (2 * share)``, so each
    carries back half the main pulse's area. The record opens at the
    pre-pulse's first sample and closes at the post-pulse's last, and
    the table starts and ends it at rest: its net velocity change and
    its net displacement are both zero.

    Parameters
    ----------
    peak : float
        The main pulse's peak, in `unit` (SI when omitted).
    duration : float
        The main pulse's duration, in seconds.
    sample_rate : float
        Samples per second.
    share : float, default `COMPENSATION_SHARE`
        The compensation pulses' height as a share of the peak, between
        zero and one.
    response_dof : str, default '1Z+'
        The channel the record is at.
    unit : str, optional
        The unit `peak` is written in, such as 'g'; the record keeps it
        as its declared unit.

    Returns
    -------
    TimeHistory
        One acceleration channel, stored in SI, on a clock that starts
        at zero.

    Raises
    ------
    ValueError
        If the peak, the duration or the sample rate is not positive,
        the share is not between zero and one, or the main pulse is
        shorter than four samples.
    """
    from ..units import si_transform
    from .data import TimeHistory

    peak, duration, rate, share = (float(peak), float(duration),
                                   float(sample_rate), float(share))
    if not (peak > 0.0 and duration > 0.0 and rate > 0.0):
        raise ValueError('a pulse needs a positive peak, duration and '
                         'sample rate')
    if not 0.0 < share < 1.0:
        raise ValueError(f'the compensation share ({share:g}) is a share '
                         'of the peak, between zero and one')
    if duration * rate < 4.0:
        raise ValueError(f'a {duration * 1e3:g} ms pulse at {rate:g} '
                         'samples a second is fewer than four samples; '
                         'its peak and its area would be guesses')
    if unit is not None:
        scale, offset = si_transform(unit, 'acceleration')
        peak = peak * scale + offset
    each = duration / (2.0 * share)               # 2.5 durations at 0.2
    total = 2.0 * each + duration
    t = np.arange(round(total * rate) + 1) / rate
    # three half-sines, each zero outside its own stretch; the sample
    # grid need not land on the joins, and every pulse is zero at both
    # of its ends, so a sample either side of a join is still exact
    pre = np.where(t < each, -share * peak * np.sin(np.pi * t / each), 0.0)
    inside = (t >= each) & (t <= each + duration)
    main = np.where(inside, peak * np.sin(np.pi * (t - each) / duration),
                    0.0)
    after = t > each + duration
    post = np.where(after, -share * peak
                    * np.sin(np.pi * np.minimum(t - each - duration, each)
                             / each), 0.0)
    return TimeHistory(t, (pre + main + post)[None, :],
                       response_dof=[response_dof],
                       ordinate_dim=['acceleration'],
                       ordinate_unit=None if unit is None else [unit],
                       comment=[(f'half-sine, {duration * 1e3:g} ms, '
                                 f'compensated at {share:g} of its peak')])

rule_stroke

rule_stroke(peak: float, duration: float) -> float

The half-sine rule's peak to peak, in m: RULE times A T^2.

peak is in m/s^2 and duration in seconds. In inches with the peak in g this is 193 A T^2. It reads the peak to peak of a half-sine compensated symmetrically at a fifth of its peak 0.16 % high, and it is the relation to keep in the head: double the peak and the stroke doubles, double the duration and it quadruples.

Source code in src/visualdynamics/core/stroke.py
def rule_stroke(peak: float, duration: float) -> float:
    """The half-sine rule's peak to peak, in m: `RULE` times A T^2.

    `peak` is in m/s^2 and `duration` in seconds. In inches with the
    peak in g this is 193 A T^2. It reads the peak to peak of a
    half-sine compensated symmetrically at a fifth of its peak 0.16 %
    high, and it is the relation to keep in the head:
    double the peak and the stroke doubles, double the duration and it
    quadruples.
    """
    return RULE * float(peak) * float(duration) ** 2

read_stroke

read_stroke(history: TimeHistory, channel: int | str | None = None, *, stroke: float | None = None, velocity: float | None = None, force: float | None = None, moving_mass: float | None = None, body_mass: float | None = None) -> StrokeReading

Integrate one acceleration channel twice, raw, and read it against the shaker's shock ratings.

reading = read_stroke(compensated_half_sine(50.0, 0.011, 102400.0,
                                            unit='g'),
                      stroke=0.0635, velocity=1.778,
                      moving_mass=45.4, body_mass=1361.0)

The record is integrated from rest at its first sample with nothing removed (filters.integrate with drift_corner=None; the module docstring says why raw), over its whole length, so a record of one event should open before the event and close after it. Every rating is optional, and one not given, or given as zero, is reported without a verdict. The ratings are judged by core.shaker's rule.

Parameters:

Name Type Description Default
history TimeHistory

The shock record, or a nominal pulse from compensated_half_sine.

required
channel int or str

The record's index or its DOF; its first acceleration channel when omitted. Only a channel whose unit is declared counts.

None
stroke float

The shaker's shock stroke, peak to peak, in m: the armature's travel relative to the body.

None
velocity float

The shaker's shock velocity, in m/s.

None
force float

The shaker's shock force, in N.

None
moving_mass float

Everything that moves, in kg; with force, the force is checked.

None
body_mass float

The shaker body's mass, in kg; with moving_mass, the body's motion is counted into the travel and the velocity. The table alone when omitted, which is right for a body bolted to a seismic mass.

None

Returns:

Type Description
StrokeReading

The arrays, the peaks, the travel each way, the half-sine rule and the shares of each rating.

Raises:

Type Description
ValueError

If the channel is not an acceleration channel of the record, the record has none, the channel holds a sample that is not finite, or a rating or mass is negative or not finite.

Source code in src/visualdynamics/core/stroke.py
def read_stroke(history: TimeHistory, channel: int | str | None = None, *,
                stroke: float | None = None, velocity: float | None = None,
                force: float | None = None,
                moving_mass: float | None = None,
                body_mass: float | None = None) -> StrokeReading:
    """Integrate one acceleration channel twice, raw, and read it
    against the shaker's shock ratings.

        reading = read_stroke(compensated_half_sine(50.0, 0.011, 102400.0,
                                                    unit='g'),
                              stroke=0.0635, velocity=1.778,
                              moving_mass=45.4, body_mass=1361.0)

    The record is integrated from rest at its first sample with nothing
    removed (`filters.integrate` with ``drift_corner=None``; the module
    docstring says why raw), over its whole length, so a record of one
    event should open before the event and close after it. Every rating
    is optional, and one not given, or given as zero, is reported
    without a verdict. The ratings are judged by `core.shaker`'s rule.

    Parameters
    ----------
    history : TimeHistory
        The shock record, or a nominal pulse from
        `compensated_half_sine`.
    channel : int or str, optional
        The record's index or its DOF; its first acceleration channel
        when omitted. Only a channel whose unit is declared counts.
    stroke : float, optional
        The shaker's shock stroke, peak to peak, in m: the armature's
        travel relative to the body.
    velocity : float, optional
        The shaker's shock velocity, in m/s.
    force : float, optional
        The shaker's shock force, in N.
    moving_mass : float, optional
        Everything that moves, in kg; with `force`, the force is checked.
    body_mass : float, optional
        The shaker body's mass, in kg; with `moving_mass`, the body's
        motion is counted into the travel and the velocity. The table
        alone when omitted, which is right for a body bolted to a
        seismic mass.

    Returns
    -------
    StrokeReading
        The arrays, the peaks, the travel each way, the half-sine rule
        and the shares of each rating.

    Raises
    ------
    ValueError
        If the channel is not an acceleration channel of the record, the
        record has none, the channel holds a sample that is not finite,
        or a rating or mass is negative or not finite.
    """
    from .data import TimeHistory
    from .filters import integrate

    index = acceleration_channel(history, channel)
    dof = str(history.response_dof[index])
    rows = finite(history.ordinate[index], dof)[None]
    one = TimeHistory(history.abscissa, rows, response_dof=[dof],
                      ordinate_dim=['acceleration'])
    # twice through the one trapezoid rule the package has, raw
    moving = integrate(one, None)
    travel = integrate(moving, None)
    return StrokeReading(
        dof=dof, sample_rate=history.sample_rate,
        start=float(history.abscissa[0]), acceleration=rows[0],
        velocity=np.asarray(moving.ordinate[0].real, dtype=float),
        displacement=np.asarray(travel.ordinate[0].real, dtype=float),
        rating=ShakerRating()).with_ratings(
            stroke=stroke, velocity=velocity, force=force,
            moving_mass=moving_mass, body_mass=body_mass)

amount

amount(value: float, dimension: str, unit_system: UnitSystem) -> str

value, in SI, as a number and a unit in unit_system: three significant figures, whole numbers from a thousand up with a thousands separator, then the system's label for dimension. The stroke check and the sine profile both write amounts this way, so a value reads the same in each.

Source code in src/visualdynamics/core/stroke.py
def amount(value: float, dimension: str, unit_system: UnitSystem) -> str:
    """`value`, in SI, as a number and a unit in `unit_system`: three
    significant figures, whole numbers from a thousand up with a
    thousands separator, then the system's label for `dimension`. The
    stroke check and the sine profile both write amounts this way, so a
    value reads the same in each."""
    return (f'{_number(float(unit_system.from_si(value, dimension)))} '
            f'{unit_system.label_text(dimension)}')