Pulse program implementation library
Project description
xq-pulse
xq-pulse is a pulse-level abstraction for NV-center control that stays independent of the underlying hardware.
You describe experiments as a typed, unit-safe pulse program; hardware-specific execution is handled later via channel
mapping and setup backends.
Why this package exists
Most NV experiments share the same logical pulse structure (drive, laser, acquisition, delays, sweeps, loops), but
different devices expose different channel APIs. xq-pulse separates those concerns:
- You build a hardware-agnostic pulse tree with a Python DSL.
- Parameters and units are explicit (
pint) and validated early. - You can use symbolic expressions for sweep-dependent values.
- You can unroll, inspect, visualize, serialize, and map to hardware channels.
Installation
Inside this monorepo:
uv sync
As a standalone dependency (if published in your environment):
pip install xq-pulse
Quick Start
from xq_pulse.pulse.dsl import (
acquire,
acquisition_target,
delay,
drive,
laser,
parallel,
pulse_program,
)
from xq_pulse.util import unit
with pulse_program() as program:
pl = acquisition_target("pl_counts", bins=200)
drive(
duration=200 * unit.ns,
frequency=2.87 * unit.GHz,
amplitude=100 * unit.uT,
phase=0 * unit.rad,
)
with parallel():
laser(duration=1 * unit.us, wavelength=532 * unit.nm, power=75 * unit.mW)
acquire(duration=1 * unit.us, target=pl, bin=0)
delay(duration=500 * unit.ns)
# Inspect or visualize
program_unrolled = program.unroll()
figure = program.plot(show=False)
Mental Model
xq-pulse programs are pulse trees built from mostly immutable node types (attrs @frozen pulse classes).
- Leaf pulses:
DelayPulseDrivePulseLaserPulseAcquisitionPulseChannelMappedPulse(after mapping)
- Container pulses:
SequencePulse(serial composition)ParallelPulse(time overlap)ForLoopPulse(parametric repetition)
PulseProgram adds:
root: pulse treeacquisition_targets: named acquisition buffersparameter_sweeps: global sweeps declared at program level
Unit System (pint)
All physical values are pint.Quantity values from:
from xq_pulse.util import unit
Examples:
200 * unit.ns2.87 * unit.GHz100 * unit.uT90 * unit.degor0.5 * unit.rad
Important details:
- Bare
int/floatinputs to DSL sweep/loop helpers are treated asdimensionless. - Unit compatibility is validated on pulse creation.
- Expressions are unit-aware and checked when constructed/validated.
DSL Overview
Main builders in xq_pulse.pulse.dsl:
- Program/context:
pulse_program()sequence()parallel()parameter_sweep(start, stop, step)for_idx(start, stop, step)
- Pulses:
drive(...)laser(...)delay(...)acquire(...)acquisition_target(name, bins)
1) Program context
All pulse-emitting functions must run inside with pulse_program():.
from xq_pulse.pulse.dsl import delay, pulse_program
from xq_pulse.util import unit
with pulse_program() as program:
delay(duration=1 * unit.us)
2) Sequence and parallel composition
with pulse_program() as program:
with sequence():
drive(duration=200 * unit.ns, frequency=2.87 * unit.GHz, amplitude=100 * unit.uT)
with parallel():
laser(duration=1 * unit.us, wavelength=532 * unit.nm, power=75 * unit.mW)
acquire(duration=1 * unit.us, target=acquisition_target("pl", bins=100), bin=0)
3) Parameter sweeps (experiment axes)
Use parameter_sweep for global experiment scans.
from xq_pulse.pulse.dsl import drive, parameter_sweep, pulse_program
from xq_pulse.util import unit
with pulse_program() as program:
with parameter_sweep(
start=2.84 * unit.GHz,
stop=2.90 * unit.GHz,
step=1 * unit.MHz,
) as (sweep_idx, frequency):
drive(
duration=200 * unit.ns,
frequency=frequency,
amplitude=100 * unit.uT,
)
Notes:
parameter_sweepyields(index_parameter, value_parameter).- Sweeps are converted into
ForLoopPulsenodes during unroll/plot. - Sweep points are inclusive of
stop(with a small numerical epsilon). - There is a safety limit of
< 10_000elements per sweep. - Sweeps must be declared before adding pulses to the program body.
4) For-loops inside the pulse tree
Use for_idx for loop structure in the pulse tree itself.
with pulse_program() as program:
with for_idx(start=0, stop=3, step=1) as (i, _loop_value):
acquire(duration=1 * unit.us, target=acquisition_target("pl", bins=4), bin=i)
for_idx also supports physical units:
with for_idx(start=2.84 * unit.GHz, stop=2.90 * unit.GHz, step=2 * unit.MHz) as (_, freq):
drive(duration=100 * unit.ns, frequency=freq, amplitude=80 * unit.uT)
Reusable Sequence Functions
A common pattern is writing Python functions that emit DSL operations, then calling them inside a context.
from xq_pulse.pulse.dsl import delay, drive, pulse_program, sequence
from xq_pulse.util import Quantity, unit
def x_pulse(frequency: Quantity, amplitude: Quantity, rabi_period: Quantity) -> None:
drive(duration=rabi_period / 2, frequency=frequency, amplitude=amplitude)
def y_pulse(frequency: Quantity, amplitude: Quantity, rabi_period: Quantity) -> None:
drive(
duration=rabi_period / 2,
frequency=frequency,
amplitude=amplitude,
phase=90 * unit.deg,
)
def xy_block(tau: Quantity, frequency: Quantity, amplitude: Quantity, rabi_period: Quantity) -> None:
with sequence():
x_pulse(frequency, amplitude, rabi_period)
delay(duration=tau - rabi_period / 2)
y_pulse(frequency, amplitude, rabi_period)
with pulse_program() as program:
xy_block(
tau=1 * unit.us,
frequency=2.87 * unit.GHz,
amplitude=100 * unit.uT,
rabi_period=80 * unit.ns,
)
This is how the built-in experiment helpers are implemented.
Symbolic Expressions and Parameters
Pulse fields can be expressions, not only literals.
import numpy as np
from xq_pulse.pulse.dsl import drive, parameter_sweep, pulse_program
from xq_pulse.util import unit
with pulse_program() as program:
with parameter_sweep(start=1000 * unit.ns, stop=2000 * unit.ns, step=200 * unit.ns) as (_, tau):
phase_shift = 2 * np.pi * unit.rad * (1.08 * unit.MHz) * tau
drive(
duration=1 * unit.us,
frequency=2.86 * unit.GHz,
amplitude=75 * unit.uT,
phase=phase_shift,
)
Supported expression operations include:
- Sum / difference
- Product / quotient
- Maximum (
xq_pulse.pulse.expression.max(...)) - Binding via
bind(...) - Evaluation via
eval_expression(...)once fully bound
The package patches pint.Quantity arithmetic so expressions like 700 * unit.ns - sweep_param work naturally.
Envelope Shaping
Drive pulses accept an Envelope (SquareEnvelope by default).
Available envelope types:
SquareEnvelope(level=...)PiecewiseEnvelope(segments=(EnvelopeSegment(...), ...))SymbolicEnvelope(expression="...")- Convenience helpers:
gaussian_envelope(...)sin_envelope()symbolic_envelope(expression=...)
Piecewise example
from xq_pulse.pulse.envelope import EnvelopeSegment, PiecewiseEnvelope
env = PiecewiseEnvelope(
segments=(
EnvelopeSegment(end=0.3, value=0.0),
EnvelopeSegment(end=1.0, value=1.0),
)
)
Symbolic example
from xq_pulse.pulse.envelope import SymbolicEnvelope
env = SymbolicEnvelope(expression="exp(-((tau - 0.5) / 0.15)**2)")
Symbolic envelope rules:
- Variable must be
tau(normalized time in[0, 1]). - Only supported math functions are allowed (
exp,sin,cos,tan,sqrt,log,Abs,Piecewise,Max,Min). - Envelope values are dimensionless.
Apply to a drive pulse:
drive(
duration=100 * unit.ns,
frequency=2.87 * unit.GHz,
amplitude=150 * unit.uT,
envelope=env,
)
Plot an envelope and its FFT:
figure = env.plot(show=False)
Program Introspection and Transformation
PulseProgram utilities:
program.simplify(): simplify expression and container structure.program.unroll(): expand loops/sweeps to a timeline (UnrolledPulse).program.plot(show=False): pulse timeline visualization.program.append(other, gap=...): concatenate two programs (second must have no sweeps).
UnrolledPulse utilities:
pulse_starts,pulse_ends,unrolled_pulsessource_by_unrolledandunrolled_by_sourcemappingsappend(...)andmerge(...)
ActivePulseIntervals can split unrolled timelines into contiguous regions with stable active-pulse sets.
Hardware Abstraction: Channels and Setup
Execution is abstracted through Setup and Channel.
- Define channel classes with
can_generate(pulse). - Define a
Setupwith available channels and arun(program)method. - Use
map_channels(program, setup)to assign abstract pulses to concrete channels.
from attrs import frozen
from xq_pulse.pulse.channel import Channel, ChannelType
from xq_pulse.pulse.channel_mapping import map_channels
from xq_pulse.pulse.pulse import DrivePulse
from xq_pulse.pulse.setup import Setup
from xq_pulse.util import unit
@frozen
class MWChannel(Channel):
type: ChannelType = ChannelType.DRIVE
def can_generate(self, pulse):
return isinstance(pulse, DrivePulse) and (2.2 * unit.GHz <= pulse.frequency <= 3.0 * unit.GHz)
@frozen
class MySetup(Setup):
channels: frozenset[Channel] = frozenset({MWChannel(name="mw1")})
def run(self, program):
mapped = map_channels(program, self)
# Translate mapped program into hardware calls here.
Mapping constraints:
- Programs cannot have free parameters outside declared sweeps.
- Parallel branches require non-conflicting channel assignments.
Serialization
Use xq_pulse.pulse.serialization.create_converter() for cattrs-based serialization/deserialization.
import json
from xq_pulse.pulse.program import PulseProgram
from xq_pulse.pulse.serialization import create_converter
converter = create_converter()
payload = converter.unstructure(program)
json.dumps(payload) # transport-safe
program2 = converter.structure(payload, PulseProgram)
Serialization notes:
- Envelopes and expressions are tagged unions.
ChannelMappedPulseis intentionally not serializable; serialize unmapped programs.
Built-In Experiment Helpers (xq_pulse.experiments)
Top-level helpers:
from xq_pulse.experiments import (
deer,
deer_sequence,
hahn_echo,
hahn_echo_sequence,
pulsed_odmr,
rabi,
ur8,
ur8_sequence,
ur16,
ur16_sequence,
xy8,
xy8_sequence,
)
What they provide:
rabi(...): duration sweep of a drive pulse.pulsed_odmr(...): frequency sweep for ODMR.hahn_echo(...)/hahn_echo_sequence(...): spin-echo block and swept sequence.xy8(...)/xy8_sequence(...): XY8 dynamical decoupling with order and tau sweep.ur8/ur16variants: universally robust DD sequences.deer(...)/deer_sequence(...): DEER-style coupled sequence.
Additional advanced helper available in submodule:
from xq_pulse.experiments.axy import axy8, axy8_sequence
Validation Ranges and Assumptions
Built-in pulse validators enforce practical bounds:
duration: 1 ns to 1000 usDrivePulse.amplitude: (0, 1 mT)DrivePulse.frequency: (0, 20 GHz)DrivePulse.phase: radians-compatibleLaserPulse.wavelength: (400, 1000) nmLaserPulse.power: (0, 500) mWAcquisitionPulse.bin: dimensionless and >= 0
Common pitfalls:
- Calling DSL pulse functions outside a context raises an error.
acquisition_target(...)must be called insidepulse_program.- Unit mismatches fail fast.
- Negative/zero durations or out-of-range physical values are rejected.
Minimal End-to-End Example
This combines sweeps, reusable blocks, envelope shaping, and serialization:
from xq_pulse.pulse.dsl import (
acquire,
acquisition_target,
delay,
drive,
parameter_sweep,
pulse_program,
sequence,
)
from xq_pulse.pulse.envelope import gaussian_envelope
from xq_pulse.pulse.serialization import create_converter
from xq_pulse.util import unit
def readout_block(target) -> None:
with sequence():
delay(duration=200 * unit.ns)
acquire(duration=1 * unit.us, target=target, bin=0)
with pulse_program() as program:
target = acquisition_target("pl", bins=200)
env = gaussian_envelope(amplitude=1.0, center=0.5, sigma=0.15)
with parameter_sweep(start=2.84 * unit.GHz, stop=2.90 * unit.GHz, step=2 * unit.MHz) as (_, freq):
drive(
duration=120 * unit.ns,
frequency=freq,
amplitude=120 * unit.uT,
envelope=env,
)
readout_block(target)
figure = program.plot(show=False)
payload = create_converter().unstructure(program)
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file xq_pulse-0.5.0.tar.gz.
File metadata
- Download URL: xq_pulse-0.5.0.tar.gz
- Upload date:
- Size: 34.3 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.10.8 {"installer":{"name":"uv","version":"0.10.8","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"25.04","id":"plucky","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
ff82c15faa4aad732a2844c2ce70721a725434c5678a0f55a25df970dfd8c878
|
|
| MD5 |
636fb60d8e0f5d81cfa9c2a4c9289637
|
|
| BLAKE2b-256 |
002773fc77e128f8efafb78a82bf7d71565ce28ed29d449ebb6db380d2d3738a
|
File details
Details for the file xq_pulse-0.5.0-py3-none-any.whl.
File metadata
- Download URL: xq_pulse-0.5.0-py3-none-any.whl
- Upload date:
- Size: 43.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.10.8 {"installer":{"name":"uv","version":"0.10.8","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"25.04","id":"plucky","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
d58ffdb49b99961e6539ff62446b7ef4091852793e56a1461cff453af3a157c1
|
|
| MD5 |
91356307de39a71cf8bd8cfb1c5933a7
|
|
| BLAKE2b-256 |
35a5fb9550e7af8a68bebcbbb770ba1980a0bb14ab75131636903a1d0d67d914
|