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()
)
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:
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
|
envelope
|
Envelope
|
Window applied to the raw signal. Keyword-only. Default:
|
SoftRectangularEnvelope()
|
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
¶
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. Summedreplaces its own envelope, which multiplies the sum on top of each child's envelope. A sum has no window of its own until you callwindowedon it.ScaledandOffsethave no envelope of their own, so they passenvelopeto the wrapped pulse. The constant of anOffsetstays outside the window:(pulse + c).windowed(env)equalspulse.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 | |
Scaled
¶
Scaled(
pulse: Pulse,
factor: float | Array,
*,
initial_time: float = 0.0,
envelope: Envelope = SoftRectangularEnvelope(),
)
Bases: Pulse
Amplitude-scaled version of another pulse.
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 |
Summed
¶
Summed(
pulses: tuple[Pulse, ...],
*,
initial_time: float = 0.0,
envelope: Envelope = NoEnvelope(),
)
Bases: Pulse
Additive superposition of multiple pulses.
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:
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()
|
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_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.
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 |
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.
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:
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 |
required |
phase
|
float
|
Carrier phase offset [rad]. Default: |
0.0
|
cycle_num
|
float
|
Number of carrier cycles in the burst. Default: |
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()
|
sweep
|
ChirpSweep
|
Phase law. Default:
|
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:
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 |
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
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
|
Returns:
| Type | Description |
|---|---|
SampledPulse
|
Pulse with |
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 | |
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:
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 |
required |
pulse_duration
|
float
|
Nominal pulse duration [s], used for time normalisation and the
|
required |
pressure_scale
|
float
|
Multiplicative scaling applied to the network output [Pa]. Static:
it must be concrete, not traced. Default: |
1.0
|
Raises:
| Type | Description |
|---|---|
TypeError
|
If |
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)
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.
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.
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()
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()
RectangularEnvelope
¶
RectangularEnvelope()
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
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
|
HannEnvelope
¶
HannEnvelope()
Bases: Envelope
Hann (raised-cosine) window for smooth on/off transitions.
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 |
0.5
|