Skip to content

pulse

The jbubble.pulse module provides composable, differentiable acoustic driving waveforms.

All pulses are Equinox modules and implement the interface:

pressure = pulse(t)  # scalar time → scalar pressure [Pa]

Base classes

Pulse

Pulse(
    *, initial_time: float = 0.0, envelope: Envelope = SoftRectangularEnvelope()
)

Bases: Module, ABC

Abstract acoustic driving pulse.

Every Pulse is callable: pulse(t) returns the instantaneous pressure [Pa] at time t. Implementations must be JAX-differentiable, so that equations of motion such as Keller-Miksis can compute jax.grad(pulse)(t).

Subclasses implement _evaluate, the raw signal \(s(t)\) before the envelope. The base __call__ applies the envelope \(w\) automatically:

\[ p(t) = s(t)\, w(t - t_\text{start}, T) \]

where \(T\) is duration and \(t_\text{start}\) is t_start, which equals initial_time for most pulses. The active window runs from t_start to t_stop.

Operator overloads compose pulses. A factor or offset can be a Python number or a JAX scalar, including a traced one, so you can differentiate with respect to it:

combined = pulse_a + pulse_b  # Summed
scaled = 0.5 * pulse_a  # Scaled
shifted = pulse_a + 1.0  # Offset: a constant pressure, not a delay
windowed = combined.windowed(HannEnvelope())  # swaps the envelope

To differentiate with respect to an offset that is zero, read the notes on Offset first.

Parameters:

Name Type Description Default
initial_time float

Time at which the pulse starts [s]. Keyword-only. Default: 0.0.

0.0
envelope Envelope

Window applied to the raw signal. Keyword-only. Default: SoftRectangularEnvelope(), except for Summed, whose default is NoEnvelope().

SoftRectangularEnvelope()

duration property

duration

Active pulse duration [s], excluding any leading silence.

t_end property

t_end

Suggested simulation end time [s].

The base implementation returns t_start + 2 * duration.

t_start property

t_start

Absolute time [s] at which the active window starts.

The base implementation returns initial_time.

t_stop property

t_stop

Absolute time [s] at which the active window ends.

Equals t_start + duration.

window_edges property

window_edges

Start and end times [s] of every active window in this pulse.

A leaf pulse returns [t_start, t_stop]. Scaled and Offset return the edges of the pulse they wrap, and Summed returns its own edges with those of every child, in ascending order.

solve_eom makes an adaptive step-size controller step to each of these times, so it can't step over a pulse that starts late. If you write a pulse that wraps other pulses, override this property to include their edges.

windowed

windowed(envelope: Envelope) -> Pulse

Return a copy of this pulse with envelope as its window.

What the new envelope applies to depends on the kind of pulse:

  • A leaf pulse, such as ToneBurst, replaces its own envelope.
  • Summed replaces its own envelope, which multiplies the sum on top of each child's envelope. A sum has no window of its own until you call windowed on it.
  • Scaled and Offset have no envelope of their own, so they pass envelope to the wrapped pulse. The constant of an Offset stays outside the window: (pulse + c).windowed(env) equals pulse.windowed(env) + c. Addition keeps constants outside sums, so ((pulse_a + c) + pulse_b).windowed(env) also equals (pulse_a + pulse_b).windowed(env) + c.

Parameters:

Name Type Description Default
envelope Envelope

The new window.

required

Returns:

Type Description
Pulse

A pulse of the same type as this one.

Source code in jbubble/pulse/base.py
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
def windowed(self, envelope: Envelope) -> Pulse:
    """Return a copy of this pulse with `envelope` as its window.

    What the new envelope applies to depends on the kind of pulse:

    - A leaf pulse, such as
      [`ToneBurst`][jbubble.pulse.tone_burst.ToneBurst], replaces its
      own envelope.
    - [`Summed`][jbubble.pulse.base.Summed] replaces its own envelope,
      which multiplies the sum on top of each child's envelope. A sum
      has no window of its own until you call `windowed` on it.
    - [`Scaled`][jbubble.pulse.base.Scaled] and
      [`Offset`][jbubble.pulse.base.Offset] have no envelope of their
      own, so they pass `envelope` to the wrapped pulse. The constant of
      an `Offset` stays outside the window:
      `(pulse + c).windowed(env)` equals `pulse.windowed(env) + c`.
      Addition keeps constants outside sums, so
      `((pulse_a + c) + pulse_b).windowed(env)` also equals
      `(pulse_a + pulse_b).windowed(env) + c`.

    Parameters
    ----------
    envelope : Envelope
        The new window.

    Returns
    -------
    Pulse
        A pulse of the same type as this one.
    """
    return eqx.tree_at(
        lambda p: p.envelope,
        self,
        envelope,
        is_leaf=lambda x: isinstance(x, Envelope),
    )

Scaled

Scaled(
    pulse: Pulse,
    factor: float | Array,
    *,
    initial_time: float = 0.0,
    envelope: Envelope = SoftRectangularEnvelope(),
)

Bases: Pulse

Amplitude-scaled version of another pulse.

\[ p(t) = k\, p_\text{child}(t) \]

where \(k\) is factor. Scaled is transparent: it delegates to the child pulse's __call__, which already applies the child's envelope, and it takes its active window (t_start, t_stop, duration, t_end, and window_edges) from the child. It has no start time or envelope of its own, so windowed passes the new envelope down to the child pulse.

Parameters:

Name Type Description Default
pulse Pulse

The pulse to scale.

required
factor float or Array

Multiplicative factor. A JAX scalar, including a traced one, works.

required

Raises:

Type Description
ValueError

If you set initial_time or envelope to anything other than the default, because Scaled would ignore it. Traced values aren't checked.

duration property

duration

Duration of the wrapped pulse [s].

t_end property

t_end

Suggested simulation end time of the wrapped pulse [s].

t_start property

t_start

Start of the wrapped pulse's active window [s].

t_stop property

t_stop

End of the wrapped pulse's active window [s].

window_edges property

window_edges

Window edges of the wrapped pulse [s].

Summed

Summed(
    pulses: tuple[Pulse, ...],
    *,
    initial_time: float = 0.0,
    envelope: Envelope = NoEnvelope(),
)

Bases: Pulse

Additive superposition of multiple pulses.

\[ p(t) = w(t - t_\text{start}, T) \sum_i p_i(t) \]

Summed evaluates each child pulse \(p_i\) with its own envelope, sums the results, and multiplies the sum by its own envelope \(w\). That envelope defaults to NoEnvelope, which is 1 at all times, so by default \(p(t) = \sum_i p_i(t)\) exactly and every child keeps its full signal, however late it starts. To window the combined signal, call .windowed(envelope), for example (pulse_a + pulse_b).windowed(HannEnvelope()).

The active window runs from the earliest child t_start to the latest child t_stop, so a window that you apply covers the children wherever they start:

\[ t_\text{start} = \max\bigl(t_0,\ \min_i t_{\text{start},i}\bigr), \qquad T = \max_i t_{\text{stop},i} - t_\text{start}, \]

where \(t_0\) is the sum's own initial_time. The window never starts before \(t_0\), so to start it later than the earliest child, set initial_time.

pulse_a + pulse_b flattens nested sums into one Summed, except a windowed sum, whose envelope isn't NoEnvelope. That sum stays a single child, so it keeps its window. The rule depends only on the envelope's type, so a sum flattens the same way under jax.jit and outside it. Flattening keeps every child but drops a nested sum's own initial_time, which matters only for a window that you apply later.

Parameters:

Name Type Description Default
pulses tuple[Pulse, ...]

Pulses to sum. Must be a tuple, not a list, for Equinox PyTree compatibility.

required
envelope Envelope

Window applied to the sum. Keyword-only. Default: NoEnvelope().

NoEnvelope()

duration property

duration

Time [s] from t_start to the latest child t_stop.

Scaled and Offset report their child's t_stop, so a delayed child that is wrapped in them still counts.

t_end property

t_end

Suggested simulation end time [s]: the latest child t_end.

t_start property

t_start

Start of the active window [s].

Equals the earliest child t_start, but no earlier than initial_time.

window_edges property

window_edges

Window edges [s] of the sum and of every child, in ascending order.

Offset

Offset(
    pulse: Pulse,
    offset: float | Array,
    *,
    initial_time: float = 0.0,
    envelope: Envelope = SoftRectangularEnvelope(),
)

Bases: Pulse

Constant-offset version of another pulse.

\[ p(t) = p_\text{child}(t) + c \]

where \(c\) is offset, a constant pressure added at all times. It doesn't delay the pulse; to delay a pulse, set initial_time on it. Adding pulses keeps the constant outside the sum, so a window that you apply to the sum never gates it: (pulse_a + c) + pulse_b gives Offset(pulse=pulse_a + pulse_b, offset=c), and k * (pulse_a + c) contributes k * c.

Offset is transparent: it delegates to the child pulse's __call__, which already applies the child's envelope, and it takes its active window (t_start, t_stop, duration, t_end, and window_edges) from the child. It has no start time or envelope of its own, so windowed passes the new envelope down to the child pulse and leaves the constant offset unwindowed.

Parameters:

Name Type Description Default
pulse Pulse

The pulse to offset.

required
offset float or Array

Additive constant pressure [Pa]. A JAX scalar, including a traced one, works.

required

Raises:

Type Description
ValueError

If you set initial_time or envelope to anything other than the default, because Offset would ignore it. Traced values aren't checked.

Notes

A gradient with respect to offset can be wrong, by orders of magnitude or as NaN, when offset is zero or close to zero and the pulse starts after a silent lead-in. The bubble then sits exactly at rest until the pulse starts, so an adaptive step-size controller sees no error and takes steps several times longer than the bubble's natural period. The solution stays accurate, but its derivative with respect to offset is unstable at those steps. To fit an offset that starts at zero, cap the step size at about a tenth of the natural period, or less, and pass the configuration to run_simulation:

import diffrax
from jbubble import SolverConfig

default = SolverConfig().stepsize_controller
config = SolverConfig(
    stepsize_controller=diffrax.PIDController(
        rtol=default.rtol, atol=default.atol, dtmax=5e-8
    )
)

Here dtmax=5e-8 [s] is a tenth of the 0.5 µs natural period of a 2 µm air bubble in water. Smaller bubbles have shorter periods and need a smaller cap.

duration property

duration

Duration of the wrapped pulse [s].

t_end property

t_end

Suggested simulation end time of the wrapped pulse [s].

t_start property

t_start

Start of the wrapped pulse's active window [s].

t_stop property

t_stop

End of the wrapped pulse's active window [s].

window_edges property

window_edges

Window edges of the wrapped pulse [s].


Pulse types

ToneBurst

ToneBurst(
    freq: ArrayLike,
    pressure: ArrayLike,
    shape: PulseShape,
    phase: ArrayLike = 0.0,
    cycle_num: ArrayLike = 4.0,
    *,
    initial_time: float = 0.0,
    envelope: Envelope = SoftRectangularEnvelope(),
)

Bases: Pulse

Tone burst: carrier waveform × envelope × pressure amplitude.

A ToneBurst is the standard parametric pulse in most ultrasound simulations. It combines a periodic PulseShape, such as Sine, with an Envelope, such as HannEnvelope, and a peak pressure:

\[ p(t) = P\, s(t - t_0; f, \phi)\, w(t - t_0, T), \qquad T = \frac{N}{f}, \]

where \(P\) is pressure, \(s\) is shape, \(f\) is freq, \(\phi\) is phase, \(w\) is envelope, \(t_0\) is initial_time, and \(N\) is cycle_num.

ToneBurst inherits the initial_time and envelope fields from Pulse; set them as keyword arguments.

Parameters:

Name Type Description Default
freq float

Carrier frequency [Hz].

required
pressure float

Peak pressure amplitude [Pa].

required
shape PulseShape

Waveform shape, such as Sine or Sawtooth.

required
phase float

Carrier phase offset [rad]. Default: 0.0.

0.0
cycle_num float

Number of carrier cycles in the burst. Default: 4.0.

4.0

Examples:

>>> import jax.numpy as jnp
>>> from jbubble.pulse import ToneBurst
>>> from jbubble.pulse.shapes import Sine
>>> pulse = ToneBurst(freq=1e6, pressure=100e3, shape=Sine(), cycle_num=5)
>>> float(pulse(jnp.array(0.0)))  # sin(0) = 0
0.0

ChirpPulse

ChirpPulse(
    freq_start: ArrayLike,
    freq_end: ArrayLike,
    pressure: ArrayLike,
    sweep_duration: ArrayLike,
    shape: PulseShape = Sine(),
    sweep: ChirpSweep = LinearSweep(),
    *,
    initial_time: float = 0.0,
    envelope: Envelope = SoftRectangularEnvelope(),
)

Bases: Pulse

Frequency-sweep pulse with a composable carrier shape and sweep law.

The shape field sets the carrier waveform (default: sine); the sweep field sets how the instantaneous frequency varies with time (default: linear). Both are eqx.Module fields, so you can swap either without changing ChirpPulse. A different shape or sweep class changes the PyTree structure, so jax.jit traces the pulse again.

The pulse calls the shape as shape(t, 0.0, -phi, t), where phi is the accumulated phase \(\Phi(\tau)\) and \(\tau = t - t_0\), so the phase \(x\) that PulseShape defines equals \(\Phi(\tau)\). Any shape that depends on time only through \(x\) works as a carrier. Triangle doesn't, because it divides by freq, which is 0 here; use TimeDomainTriangle instead.

Parameters:

Name Type Description Default
freq_start float

Instantaneous frequency at the start [Hz].

required
freq_end float

Instantaneous frequency at the end [Hz].

required
pressure float

Peak pressure amplitude [Pa].

required
sweep_duration float

Duration of the frequency sweep [s].

required
shape PulseShape

Carrier waveform shape. Default: Sine().

Sine()
sweep ChirpSweep

Phase law. Default: LinearSweep().

LinearSweep()

Examples:

>>> from jbubble.pulse import ChirpPulse, HannEnvelope
>>> from jbubble.pulse.chirp import ExponentialSweep
>>> from jbubble.pulse.shapes import Square
>>> chirp = ChirpPulse(
...     freq_start=0.5e6, freq_end=2e6,
...     pressure=200e3, sweep_duration=10e-6,
...     shape=Square(), sweep=ExponentialSweep(),
...     envelope=HannEnvelope(),
... )

SampledPulse

SampledPulse(
    ts: Array,
    pressures: Array,
    *,
    initial_time: float = 0.0,
    envelope: Envelope = SoftRectangularEnvelope(),
)

Bases: Pulse

Acoustic pulse defined by an array of pressure samples.

Evaluates the pressure at arbitrary times with piecewise-linear interpolation (jnp.interp), which holds the first and last sample values outside ts:

\[ p(t) = \operatorname{interp}(t;\ t_s, p_s)\, w\bigl(t - t_s[0],\ t_s[N-1] - t_s[0]\bigr) \]

where \(t_s\) is ts, \(p_s\) is pressures, and \(w\) is envelope.

The sample times are absolute, so the active window runs from the first sample to the last: t_start is ts[0], and duration is ts[-1] - ts[0]. To delay the waveform, shift ts. The initial_time field doesn't move the window; leave it at its default, or set it to ts[0] as from_uniform does.

The inherited envelope, by default SoftRectangularEnvelope, gates the signal to that window. Its sigmoid ramps are centred on the window edges, so it halves the first and last samples. To keep them at full value, pass a RectangularEnvelope as envelope.

Parameters:

Name Type Description Default
ts (Array, shape(N))

Absolute sample times [s]. Must be monotonically increasing.

required
pressures (Array, shape(N))

Pressure values [Pa] at each sample time.

required

Raises:

Type Description
ValueError

If initial_time is neither 0.0 nor ts[0]. The check runs only when both values are concrete, not traced.

Examples:

>>> import jax.numpy as jnp
>>> from jbubble.pulse import SampledPulse
>>> ts = jnp.linspace(5e-6, 15e-6, 1000)
>>> pressures = 200e3 * jnp.sin(2 * jnp.pi * 1e6 * ts)
>>> pulse = SampledPulse(ts=ts, pressures=pressures)
>>> print(f"{pulse.t_start * 1e6:.1f} µs to {pulse.t_stop * 1e6:.1f} µs")
5.0 µs to 15.0 µs

duration property

duration

Time from the first sample to the last, ts[-1] - ts[0] [s].

t_start property

t_start

First sample time, ts[0] [s], where the active window starts.

from_uniform staticmethod

from_uniform(
    pressures: Array, dt: float, initial_time: float = 0.0
) -> SampledPulse

Create a SampledPulse from uniformly spaced samples.

Parameters:

Name Type Description Default
pressures (Array, shape(N))

Pressure values [Pa].

required
dt float

Time step between samples [s].

required
initial_time float

Time of the first sample [s]. Default: 0.0.

0.0

Returns:

Type Description
SampledPulse

Pulse with ts = initial_time + dt * arange(N) and initial_time set to the time of the first sample.

Source code in jbubble/pulse/sampled.py
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
@staticmethod
def from_uniform(
    pressures: jax.Array, dt: float, initial_time: float = 0.0
) -> SampledPulse:
    """Create a `SampledPulse` from uniformly spaced samples.

    Parameters
    ----------
    pressures : jax.Array, shape (N,)
        Pressure values [Pa].
    dt : float
        Time step between samples [s].
    initial_time : float
        Time of the first sample [s]. Default: `0.0`.

    Returns
    -------
    SampledPulse
        Pulse with `ts = initial_time + dt * arange(N)` and
        `initial_time` set to the time of the first sample.
    """
    ts = initial_time + jnp.arange(pressures.shape[0]) * dt
    return SampledPulse(ts=ts, pressures=pressures, initial_time=initial_time)

NeuralPulse

NeuralPulse(
    net: Module,
    pulse_duration: ArrayLike,
    pressure_scale: ArrayLike = 1.0,
    *,
    initial_time: float = 0.0,
    envelope: Envelope = SoftRectangularEnvelope(),
)

Bases: Pulse

Acoustic pulse parameterised by a neural network.

The network \(f_\theta\) maps the normalised time since the pulse starts to the instantaneous pressure, scaled by pressure_scale:

\[ p(t) = P\, f_\theta\!\left(\frac{t - t_0}{T}\right) w(t - t_0, T) \]

where \(P\) is pressure_scale, \(t_0\) is initial_time, \(T\) is pulse_duration, and \(w\) is envelope. The network input runs from 0 to 1 across the active window, wherever the pulse starts.

The network weights are the trainable state. Because the entire pulse is an Equinox module, they take part in JAX transformations (jit, grad, vmap), so you can optimise the driving waveform with gradient descent. pulse_duration and pressure_scale are static configuration: the constructor converts them to Python floats, and they aren't PyTree leaves, so an optimiser never updates them. A new value of either one makes jax.jit trace the pulse again.

NeuralPulse inherits the initial_time and envelope fields from Pulse; set them as keyword arguments. Unlike pulse_duration and pressure_scale, they stay PyTree leaves, as on every other pulse, so you can jax.vmap or differentiate over the start time or an envelope parameter, such as the steepness of SoftRectangularEnvelope. When they're Python floats, as they are by default, they aren't inexact arrays, so eqx.filter_grad doesn't differentiate them, and an optimiser that updates only the leaves that eqx.is_inexact_array selects leaves them fixed.

Parameters:

Name Type Description Default
net Module

Any callable Equinox module, such as eqx.nn.MLP, that maps a 1-element array to a scalar or 1-element output.

required
pulse_duration float

Nominal pulse duration [s], used for time normalisation and the duration property. Static: it must be concrete, not traced.

required
pressure_scale float

Multiplicative scaling applied to the network output [Pa]. Static: it must be concrete, not traced. Default: 1.0.

1.0

Raises:

Type Description
TypeError

If pulse_duration or pressure_scale is a traced JAX value, for example inside jax.jit, jax.vmap, or jax.grad. To sweep or fit the amplitude, scale the pulse instead: k * pulse accepts a traced factor k.

Examples:

>>> import jax
>>> import equinox as eqx
>>> from jbubble.pulse import NeuralPulse
>>> key = jax.random.PRNGKey(0)
>>> mlp = eqx.nn.MLP(in_size=1, out_size=1, width_size=32,
...                   depth=2, key=key)
>>> pulse = NeuralPulse(net=mlp, pulse_duration=10e-6,
...                     pressure_scale=200e3)

duration property

duration

Nominal pulse duration, pulse_duration [s].


Chirp sweep laws

A sweep law sets how the instantaneous frequency of a ChirpPulse changes over time. Pass one as the sweep argument.

ChirpSweep

ChirpSweep()

Bases: Module

Abstract phase law for a frequency sweep.

Subclasses compute the accumulated phase \(\Phi(\tau)\) [rad] as a function of the elapsed time \(\tau\), the start and end frequencies, and the sweep duration. ChirpPulse then evaluates the carrier PulseShape at that phase.

LinearSweep

LinearSweep()

Bases: ChirpSweep

Linear (constant-rate) frequency sweep.

\[ \Phi(\tau) = 2\pi\left[f_0 \tau + \frac{(f_1 - f_0)\,\tau^2}{2T}\right] \]

where \(f_0\) is freq_start, \(f_1\) is freq_end, and \(T\) is the sweep duration.

ExponentialSweep

ExponentialSweep()

Bases: ChirpSweep

Exponential (geometric) frequency sweep.

\[ \Phi(\tau) = \frac{2\pi f_0 T \left(r^{\tau/T} - 1\right)}{\ln r}, \qquad r = \frac{f_1}{f_0}, \]

where \(f_0\) is freq_start, \(f_1\) is freq_end, and \(T\) is the sweep duration. When \(f_1 = f_0\), the phase is its limit as \(r \to 1\), \(\Phi(\tau) = 2\pi f_0 \tau\), a constant tone, and the gradient with respect to either frequency stays finite.


Envelopes

Envelope

Envelope()

Bases: Module, ABC

Window function that maps relative time tau to a scale in [0, 1].

A pulse calls it as envelope(tau, duration), where tau = t - t_start and t_start is the pulse's t_start. It returns 0 outside [0, duration], except for two envelopes: SoftRectangularEnvelope, whose sigmoid tails decay smoothly outside the window, and NoEnvelope, which is 1 at all times.

NoEnvelope

NoEnvelope()

Bases: Envelope

Identity window: 1 at all times, so the signal passes unchanged.

It is the default envelope of Summed, whose children already apply their own envelopes. To remove the window of a windowed sum, call pulse.windowed(NoEnvelope()).

RectangularEnvelope

RectangularEnvelope()

Bases: Envelope

Hard on/off gating: 1 inside [0, duration], 0 outside.

SoftRectangularEnvelope

SoftRectangularEnvelope(steepness: float = 100.0)

Bases: Envelope

Smooth approximation to a rectangular window with sigmoid transitions.

Replaces the hard on/off step of RectangularEnvelope with smooth sigmoid ramps, which keeps \(\mathrm{d}p_\text{ac}/\mathrm{d}t\) continuous everywhere. This is the preferred envelope when you need a near-rectangular window for gradient-based parameter fitting with the adjoint method.

The window value is

\[ w(\tau) = S\left(\frac{\tau}{k}\right) S\left(\frac{T - \tau}{k}\right), \qquad k = \frac{T}{\text{steepness}}, \]

where \(S\) is the logistic sigmoid and \(T\) is the pulse duration. The plateau is flat to within \(2\exp(-\text{steepness}/2)\) of 1.

Parameters:

Name Type Description Default
steepness float

Controls the transition sharpness. Each transition spans roughly \(4T/\text{steepness}\) in time, from \(-2k\) to \(+2k\). The default, 100.0, gives transitions of about 4% of the pulse duration, which is imperceptible for bursts of five or more cycles.

100.0

HannEnvelope

HannEnvelope()

Bases: Envelope

Hann (raised-cosine) window for smooth on/off transitions.

\[ w(\tau) = \frac{1}{2}\left[1 - \cos\left(\frac{2\pi\tau}{T}\right)\right], \qquad 0 \le \tau \le T, \]

and 0 outside, where \(T\) is the pulse duration.

TukeyEnvelope

TukeyEnvelope(alpha: float = 0.5)

Bases: Envelope

Tukey (tapered cosine) window: flat in the middle, with cosine tapers.

Parameters:

Name Type Description Default
alpha float

Fraction of the window inside the cosine tapers. Must be greater than 0. As alpha approaches 0, the window approaches a rectangular window; for a hard gate, use RectangularEnvelope. alpha = 1 gives a Hann window. Default: 0.5.

0.5