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

jbubble.pulse.base.Pulse

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 (e.g. Keller–Miksis) can compute jax.grad(pulse)(t).

Subclasses implement :meth:_evaluate (the raw, un-enveloped signal). The base :meth:__call__ applies the :attr:envelope automatically.

Operator overloads for composition::

combined = pulse_a + pulse_b          # → Summed
scaled   = 0.5 * pulse_a              # → Scaled
windowed = combined.windowed(HannEnvelope())  # swap envelope

duration abstractmethod property

Active pulse duration [s] (excluding any leading silence).

t_end property

Suggested simulation end time [s].

Default: initial_time + 2 × duration.

__call__(t)

Evaluate pressure at time t [Pa], with envelope applied.

Source code in jbubble/pulse/base.py
def __call__(self, t: jax.Array) -> jax.Array:
    """Evaluate pressure at time *t* [Pa], with envelope applied."""
    tau = t - self.initial_time
    return self._evaluate(t) * self.envelope(tau, self.duration)

__add__(other)

Add another pulse or constant offset: pulse_a + pulse_b or pulse + 1.0

Source code in jbubble/pulse/base.py
def __add__(self, other: Pulse | float) -> Pulse:
    """Add another pulse or constant offset: pulse_a + pulse_b or pulse + 1.0"""
    if isinstance(other, (int, float)):
        return Offset(pulse=self, offset=float(other))
    left = self.pulses if isinstance(self, Summed) else (self,)
    right = other.pulses if isinstance(other, Summed) else (other,)
    return Summed(pulses=left + right)

__radd__(other)

Right addition: other + self. If other is a Pulse, delegate to its add.

Source code in jbubble/pulse/base.py
def __radd__(self, other: Pulse | float) -> Pulse:
    """Right addition: other + self.  If *other* is a Pulse, delegate to its __add__."""
    if isinstance(other, (int, float)):
        return Offset(pulse=self, offset=float(other))
    if isinstance(other, Pulse):
        return other.__add__(self)
    return NotImplemented

__mul__(factor)

Scale the pulse by a factor: pulse * factor

Source code in jbubble/pulse/base.py
def __mul__(self, factor: float) -> Scaled:
    """Scale the pulse by a factor: pulse * factor"""
    return Scaled(pulse=self, factor=float(factor))

__rmul__(factor)

Right multiplication: factor * pulse

Source code in jbubble/pulse/base.py
def __rmul__(self, factor: float) -> Scaled:
    """Right multiplication: factor * pulse"""
    return Scaled(pulse=self, factor=float(factor))

__neg__()

Negate the pulse (flip polarity): -pulse

Source code in jbubble/pulse/base.py
def __neg__(self) -> Scaled:
    """Negate the pulse (flip polarity): -pulse"""
    return Scaled(pulse=self, factor=-1.0)

__pos__()

Unary plus (identity): +pulse

Source code in jbubble/pulse/base.py
def __pos__(self) -> Pulse:
    """Unary plus (identity): +pulse"""
    return self

__sub__(other)

Subtract another pulse or constant offset: pulse_a - pulse_b or pulse - 1.0

Source code in jbubble/pulse/base.py
def __sub__(self, other: Pulse | float) -> Pulse:
    """Subtract another pulse or constant offset: pulse_a - pulse_b or pulse - 1.0"""
    if isinstance(other, (int, float)):
        return Offset(pulse=self, offset=-float(other))
    return self + (-other)

__rsub__(other)

Right subtraction: other - self

Source code in jbubble/pulse/base.py
def __rsub__(self, other: Pulse | float) -> Pulse:
    """Right subtraction: other - self"""
    if isinstance(other, (int, float)):
        # other - self = (-self) + other
        return Offset(pulse=(-self), offset=float(other))
    if isinstance(other, Pulse):
        return other + (-self)
    return NotImplemented

__truediv__(factor)

Divide pulse by a factor: pulse / 2.0

Source code in jbubble/pulse/base.py
def __truediv__(self, factor: float) -> Scaled:
    """Divide pulse by a factor: pulse / 2.0"""
    return Scaled(pulse=self, factor=1.0 / float(factor))

__iadd__(other)

In-place addition: pulse += other or pulse += 1.0

Source code in jbubble/pulse/base.py
def __iadd__(self, other: Pulse | float):
    """In-place addition: pulse += other or pulse += 1.0"""
    return self + other

__isub__(other)

In-place subtraction: pulse -= other or pulse -= 1.0

Source code in jbubble/pulse/base.py
def __isub__(self, other: Pulse | float):
    """In-place subtraction: pulse -= other or pulse -= 1.0"""
    return self - other

__imul__(factor)

In-place multiplication: pulse *= factor

Source code in jbubble/pulse/base.py
def __imul__(self, factor: float):
    """In-place multiplication: pulse *= factor"""
    return Scaled(pulse=self, factor=float(factor))

__itruediv__(factor)

In-place division: pulse /= factor

Source code in jbubble/pulse/base.py
def __itruediv__(self, factor: float):
    """In-place division: pulse /= factor"""
    return Scaled(pulse=self, factor=1.0 / float(factor))

windowed(envelope)

Return a copy of this pulse with envelope replacing the current one.

Source code in jbubble/pulse/base.py
def windowed(self, envelope: Envelope) -> Pulse:
    """Return a copy of this pulse with *envelope* replacing the current one."""
    return eqx.tree_at(
        lambda p: p.envelope,
        self,
        envelope,
        is_leaf=lambda x: isinstance(x, Envelope),
    )

jbubble.pulse.base.Scaled

Bases: Pulse

Amplitude-scaled version of another pulse.

Scaled is transparent: it delegates entirely to the child pulse's __call__ (which already applies the child's envelope) and simply multiplies by factor. No additional envelope is applied.

Parameters:

Name Type Description Default
pulse Pulse

The pulse to scale.

required
factor float

Multiplicative factor.

required

jbubble.pulse.base.Summed

Bases: Pulse

Additive superposition of multiple pulses.

Each child pulse is evaluated with its own envelope, then the results are summed. The Summed pulse's own :attr:envelope (inherited from :class:Pulse, default RectangularEnvelope) is applied on top — use .windowed(HannEnvelope()) to window the combined signal.

Parameters:

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

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

required

jbubble.pulse.base.Offset

Bases: Pulse

Constant-offset version of another pulse.

Offset is transparent: it delegates entirely to the child pulse's __call__ (which already applies the child's envelope) and simply adds a constant offset. No additional envelope is applied.

Parameters:

Name Type Description Default
pulse Pulse

The pulse to offset.

required
offset float

Additive constant offset.

required

Pulse types

jbubble.pulse.tone_burst.ToneBurst

Bases: Pulse

Tone burst: carrier waveform × envelope × pressure amplitude.

A ToneBurst is the standard parametric pulse used in most ultrasound simulations. It combines a periodic :class:PulseShape (e.g. Sine) with an :class:Envelope (e.g. HannEnvelope) and a peak pressure.

The initial_time and envelope fields are inherited from :class:Pulse and can be set 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 (Sine, Sawtooth, …).

required
phase float

Carrier phase offset [rad]. Default: 0.

required
cycle_num float

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

required

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))) < 0.5  # sigmoid is ~0.5 at t=0
True

jbubble.pulse.chirp.ChirpPulse

Bases: Pulse

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

The :attr:shape determines the carrier waveform (default: sine); the :attr:sweep determines how instantaneous frequency varies with time (default: linear). Both are :class:~equinox.Module leaves — swapping either keeps the same computational graph and does not force a re-trace.

The shape is evaluated at the instantaneous accumulated phase Φ(τ), so any :class:~jbubble.pulse.shapes.PulseShape works as a carrier.

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: :class:~jbubble.pulse.shapes.Sine).

required
sweep ChirpSweep

Phase law (default: :class:LinearSweep).

required

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(),
... )

jbubble.pulse.sampled.SampledPulse

Bases: Pulse

Acoustic pulse defined by an array of pressure samples.

Evaluates the pressure at arbitrary times via piecewise-linear interpolation (jnp.interp). The inherited envelope (default RectangularEnvelope) gates the signal to [initial_time, initial_time + duration].

Parameters:

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

Sample time points [s]. Must be monotonically increasing.

required
pressures (Array, shape(N))

Pressure values [Pa] at each sample time.

required

Examples:

>>> import jax.numpy as jnp
>>> from jbubble.pulse import SampledPulse
>>> ts = jnp.linspace(0, 10e-6, 1000)
>>> pressures = 200e3 * jnp.sin(2 * jnp.pi * 1e6 * ts)
>>> pulse = SampledPulse(ts=ts, pressures=pressures)
>>> pulse.duration  # 10 µs
1e-05

from_uniform(pressures, dt, initial_time=0.0) staticmethod

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
Source code in jbubble/pulse/sampled.py
@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.
    """
    ts = initial_time + jnp.arange(pressures.shape[0]) * dt
    return SampledPulse(ts=ts, pressures=pressures, initial_time=initial_time)

jbubble.pulse.neural.NeuralPulse

Bases: Pulse

Acoustic pulse parameterised by a neural network.

The network maps normalised time t / duration to instantaneous pressure, scaled by pressure_scale. Because the entire pulse is an Equinox module, its parameters participate in JAX transformations (jit, grad, vmap) — making it straightforward to optimise the driving waveform via gradient descent.

The initial_time and envelope fields are inherited from :class:Pulse and can be set as keyword arguments.

Parameters:

Name Type Description Default
net Module

Any callable Equinox module (e.g. eqx.nn.MLP) mapping a scalar (or 1-element array) to a scalar output.

required
pulse_duration float

Nominal pulse duration [s] — used for time normalisation and the :attr:duration property.

required
pressure_scale float

Multiplicative scaling applied to the network output [Pa]. Default: 1.0.

required

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)

Envelopes

jbubble.pulse.envelope.Envelope

Bases: Module, ABC

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

Called as envelope(tau, duration) where tau = t − initial_time. Returns 0 outside [0, duration].

jbubble.pulse.envelope.RectangularEnvelope

Bases: Envelope

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

jbubble.pulse.envelope.SoftRectangularEnvelope

Bases: Envelope

Smooth approximation to a rectangular window using sigmoid transitions.

Replaces the hard on/off step of :class:RectangularEnvelope with smooth sigmoid ramps, keeping dp_ac/dt continuous everywhere. This is the preferred envelope when a near-rectangular window is needed for gradient-based parameter fitting via the adjoint method.

The window value is::

w(τ) = σ(τ / k) · σ((T − τ) / k),   k = T / steepness

where σ is the logistic sigmoid and T is the pulse duration. The plateau is flat to within 2·exp(−steepness/2) of 1.0.

Parameters:

Name Type Description Default
steepness float

Controls transition sharpness. Transitions span roughly 4·T / steepness in time (±2σ). Default 100 gives transitions of ≈ 4% of the pulse duration — imperceptible for bursts of 5+ cycles.

required

jbubble.pulse.envelope.HannEnvelope

Bases: Envelope

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

jbubble.pulse.envelope.TukeyEnvelope

Bases: Envelope

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

Parameters:

Name Type Description Default
alpha float

Fraction of the window inside the cosine tapers. alpha = 0 → rectangular, alpha = 1 → Hann.

required