Pulse Excitation Profiles¶
Compute how a shaped microwave pulse acts on spins across resonance offset, by
full Bloch/propagator spin dynamics of a single \(S=1/2\) — the EasySpin
exciteprofile approach. Because it propagates the actual dynamics (not the
linear-response / FFT approximation), it is correct for adiabatic pulses
(WURST, sech/tanh) as well as weak ones.
Pure NumPy, no hardware. Use it to design pulses (bandwidth, inversion quality), estimate where two pulses at different carriers overlap, or build small two-pulse sequences.
Background¶
In the rotating frame of the microwave carrier a spin at resonance offset \(\Delta\omega\) sees
with \(\omega_1(t) = 2\pi\,\nu_1\,a(t)\) the instantaneous nutation (B1) drive, \(a(t)\in[0,1]\) the amplitude envelope and \(\phi(t)\) the running RF phase (carrier + chirp). The pulse is propagated piecewise-constant: over each short step \(dt\) the Hamiltonian is held at its mid-step value and
For a two-level system \(U\) is an \(SU(2)\) rotation, so instead of a matrix exponential per (offset, step) the Bloch vector \(\mathbf{M}=(M_x,M_y,M_z)\) is rotated directly with Rodrigues' formula. This vectorises over the whole offset axis at once, so a microsecond WURST profile computes in well under 0.1 s.
Units
Internally everything is GHz (frequencies) and ns (time) — a product
GHz·ns is a number of cycles. The offset axis, nu1 and the result are in
these base units; shape parameters in the params dict (bw, center, …)
are in MHz. Convert MHz→GHz with /1000 on the way in.
What is and isn't modelled
A single isolated \(S=1/2\), coherent dynamics only — no relaxation
(T1/T2), no B1 inhomogeneity, no
coupled/nuclear spins. A finite resonator bandwidth can be included via
the optional resonator argument (see
Non-ideal pulses). The on-resonance flip angle from
flip_angle is the pulse area; for swept/adiabatic pulses
the effective rotation is offset-dependent and not a single number — use
adiabaticity_profile to check whether a chirped
pulse actually inverts across its band.
Pulse shapes¶
pe.SHAPES lists the supported shapes. Parameter names and units follow the
common AWG (arbitrary-waveform generator) convention, so a pulse designed here
reproduces the one the hardware emits. Widths are in ns, frequencies in
MHz, b in 1/ns. Every shape also reads center — the pulse carrier
frequency (MHz), which shifts the whole excitation band along the offset axis
(for WURST/sech it is the sweep centre).
| Shape | Extra params |
Notes |
|---|---|---|
rectangular |
center |
constant envelope |
gaussian |
sigma (ns), center |
\(\exp[-\tfrac12((t-t_p/2)/\sigma)^2]\) — sigma is the Gaussian std (not FWHM) |
sinc |
sigma (ns), center |
\(\mathrm{sinc}(2(t-t_p/2)/\sigma)\), first zero at \(\pm\sigma/2\) |
half-sine |
center |
\(\sin(\pi t/t_p)\) |
quartersine |
edge (ns), center |
flat top, quarter-sine rise/fall over edge |
WURST |
n, bw (MHz), center |
\(1-\lvert\sin\rvert^{\,n}\) envelope, linear chirp across bw |
sech/tanh |
b (1/ns), n, bw (MHz), center |
HS\(n\) hyperbolic-secant adiabatic inversion; b truncation, n order |
Sample-grid convention
The sech/tanh envelope and sweep are defined on a sample grid (the module
constant SAMPLE_RATE, default 1.25 samples/ns = 1250 MHz), matching how an
AWG builds the waveform; b is therefore tied to that sample rate. The other
shapes are sample-rate-independent.
excitation_profile(shape, tp, nu1, offsets, params, dt=0.5, phi0=0.0, init=(0,0,1), resonator=None)¶
The excitation/inversion profile of a single pulse from an initial state (default equilibrium \(+z\)).
| Argument | Description |
|---|---|
shape |
one of pe.SHAPES |
tp |
pulse length (ns) |
nu1 |
peak nutation / B1 frequency (GHz); rectangular flip \(=2\pi\,\nu_1 t_p\) |
offsets |
array of resonance offsets (GHz) |
params |
shape parameters (MHz units), see table above |
dt |
propagation step (ns); smaller = more accurate / slower |
phi0 |
constant pulse phase (rad) — x/y/… |
init |
initial magnetization (Mx, My, Mz) |
resonator |
optional dict applying a finite-bandwidth resonator to the pulse, see Non-ideal pulses; None = ideal transmitter |
Returns Mx, My, Mz arrays over offsets. Mz is the inversion profile;
hypot(Mx, My) is the transverse excitation.
import numpy as np
import atomize.math_modules.pulse_excitation as pe
import atomize.general_modules.general_functions as general
offsets = np.linspace(-0.25, 0.25, 401) # GHz (-250..250 MHz)
Mx, My, Mz = pe.excitation_profile(
'WURST', tp=200, nu1=0.031, offsets=offsets, # 31 MHz B1
params={'n': 20, 'bw': 200, 'center': 0})
general.plot_1d('WURST inversion', offsets*1e3, Mz, xname='Offset', xscale='MHz',
yname='Mz/M0')
general.message('inversion band reaches Mz = %.2f' % Mz.min())
A rectangular \(\pi\) pulse for comparison (carrier shifted to +60 MHz so the band sits there):
Mx, My, Mz = pe.excitation_profile('rectangular', tp=16, nu1=1/(2*16),
offsets=offsets, params={'center': 60})
waveform(shape, t, tp, params)¶
The building block: amplitude envelope and instantaneous frequency of a shape.
Returns (a, nu) — a the normalised envelope in \([0,1]\), nu the instantaneous
frequency (GHz) relative to the reference (constant center plus, for swept
shapes, the chirp). Use it to plot the pulse or feed a custom propagation.
t = np.linspace(0, 200, 400)
a, nu = pe.waveform('WURST', t, 200, {'n': 20, 'bw': 200, 'center': 0})
I, Q = a*np.cos(2*np.pi*np.cumsum(nu)*(t[1]-t[0])), a*np.sin(2*np.pi*np.cumsum(nu)*(t[1]-t[0]))
flip_angle(shape, tp, nu1, params, dt=0.5)¶
On-resonance flip angle (rad) — the integral of the envelope, \(2\pi\,\nu_1\!\int a\,dt\). Exact for non-swept shapes; the nominal area for chirp/adiabatic pulses.
fa = pe.flip_angle('gaussian', tp=40, nu1=0.02, params={'sigma': 10})
general.message('flip = %.1f deg' % np.degrees(fa))
adiabaticity_profile(shape, tp, nu1, offsets, params, dt=0.5, phi0=0.0, resonator=None)¶
Adiabaticity factor \(Q(\Delta\omega)\) of a frequency-swept pulse (WURST, sech/tanh) versus resonance offset. In the frame following the instantaneous RF frequency the effective field has transverse part \(\omega_1(t)=2\pi\nu_1 a(t)\) and longitudinal part \(\Delta\Omega(t)=2\pi[\Delta\omega-\nu_\text{inst}(t)]\); adiabatic passage needs the field to reorient slowly compared with its own magnitude,
evaluated at each spin's resonance crossing (\(\Delta\Omega=0\)). \(Q\gg1\) (rule of
thumb \(\gtrsim5\)) means the spin is cleanly inverted; at the sweep centre this is
the textbook \(Q=2\pi\nu_1^2\,t_p/\Delta\nu\). Passing a resonator dict
(Non-ideal pulses) filters the waveform first, so a compensated
pulse can be compared with a simulated (distorted) one to see whether the AWG
pre-distortion restores adiabaticity at the band edges (it counts the
resonator's amplitude roll-off across the sweep — the dominant effect on
adiabaticity — while keeping the programmed sweep as the instantaneous
frequency, which avoids the phase-derivative spikes a filtered waveform would
otherwise produce). Returns \(Q\) for every offset. Read it only inside the
swept band (\(|\Delta\omega - \text{centre}| < \Delta\nu/2\)): there \(Q\) is flat
in the interior and dips toward zero at the sweep edges (where the crossing
falls on the near-zero tail of the envelope — the genuine reason adiabatic
inversion rolls off there). Outside the band a spin is never swept through, so
the returned \(Q\) is large but meaningless (it does not imply inversion). Only
meaningful for swept shapes.
offsets = np.linspace(-0.15, 0.15, 401) # GHz
params = {'n': 20, 'bw': 200, 'center': 0} # 200 MHz WURST sweep
Q = pe.adiabaticity_profile('WURST', tp=400, nu1=0.03, offsets=offsets, params=params)
general.plot_1d('adiabaticity', offsets*1e3, Q, xname='Offset', xscale='MHz',
yname='Q', yscale='') # log-y; Q >~ 5 inverts
Building sequences¶
Two lower-level helpers let you chain pulses for multi-pulse experiments.
excitation_profile is just propagate_pulse on a tiled equilibrium state.
propagate_pulse(M, shape, tp, nu1, offsets, params, dt=0.5, phi0=0.0, resonator=None)¶
Apply one pulse to an existing Bloch-vector array M of shape
(len(offsets), 3); returns the new array (input not mutated). The optional
resonator dict (Non-ideal pulses) filters the pulse through a
finite-bandwidth resonator before it acts on M.
free_evolution(M, offsets, tau)¶
Free precession for tau ns: rotate each offset about \(+z\) by
\(2\pi\,\Delta\omega\,\tau\) (same sign convention as a zero-amplitude pulse).
# Hahn echo: pi/2_x -- tau -- pi_y -- tau, transverse magnetization vs offset
offsets = np.linspace(-0.05, 0.05, 401)
M = np.tile([0., 0., 1.], (offsets.size, 1)) # equilibrium +z
M = pe.propagate_pulse(M, 'rectangular', 16, 1/(4*16), offsets, {'center': 0}) # pi/2 x
M = pe.free_evolution(M, offsets, 200.0)
M = pe.propagate_pulse(M, 'rectangular', 16, 1/(2*16), offsets, {'center': 0},
phi0=np.pi/2) # pi y
M = pe.free_evolution(M, offsets, 200.0)
general.plot_1d('echo profile', offsets*1e3, np.hypot(M[:, 0], M[:, 1]),
xname='Offset', xscale='MHz', yname='|Mxy|')
Carrier phase across a delay
Each pulse's phase is referenced to its own start. For a continuous-LO
instrument where pulse \(k\) at carrier \(\nu_{0k}\) keeps accumulating phase
during the delay, add the absolute-time term yourself:
phi0 += 2*pi*nu0_k*(t_start_k) (in GHz·ns). This only matters when a later
pulse sits at a non-zero carrier (e.g. a DEER pump).
Non-ideal pulses: resonator transfer function¶
A real resonator has a finite bandwidth, so the spins do not see the programmed
waveform but its filtered version. Pass a resonator dict to
excitation_profile or propagate_pulse
to model this with an ideal RLC transfer function
the same form the AWG hardware correction uses (see
awg_correction()). |H| peaks at the resonator
centre \(\nu_0\) and the power bandwidth (FWHM) is \(\nu_0/Q\).
| Key | Description |
|---|---|
nu0 |
resonator centre frequency (GHz, absolute) |
Q |
loaded quality factor (power bandwidth \(=\nu_0/Q\)) |
detuning |
resonator centre minus carrier (GHz); 0 = carrier on resonance |
mode |
'simulate' (multiply by \(H\) — the distorted, uncorrected pulse) or 'compensate' (multiply by \(1/H\) — the pre-distorted pulse the hardware sends) |
ringdown |
simulate only: ns of post-pulse ring-down to propagate (see below); 0 = none |
measured |
optional (freq_GHz, H_complex) measured transfer function (e.g. VNA S21); replaces the ideal RLC \(H\) (see measured_transfer). nu0/detuning then only set the carrier and nu0/Q the ring-down clock |
offsets = np.linspace(-0.25, 0.25, 401) # GHz
res = {'nu0': 9.7, 'Q': 88, 'detuning': 0.0, 'mode': 'simulate'}
# uncorrected WURST through the resonator
Mx, My, Mz = pe.excitation_profile('WURST', tp=200, nu1=0.031, offsets=offsets,
params={'n': 20, 'bw': 200, 'center': 0},
resonator=res)
# the same pulse pre-distorted (compensate) — recovers the ideal profile
res_c = dict(res, mode='compensate')
Mxc, Myc, Mzc = pe.excitation_profile('WURST', tp=200, nu1=0.031, offsets=offsets,
params={'n': 20, 'bw': 200, 'center': 0},
resonator=res_c)
The filtering is done in the frequency domain (zero-padded FFT, so a rectangular
pulse's edges ring up/down rather than wrapping around). In compensate mode the
\(1/H\) boost is capped (far off resonance \(H\to0\)), mirroring the hardware clamp.
resonator_transfer(freqs, nu0, Q, detuning=0.0)¶
The complex transfer function \(H\) evaluated at rotating-frame frequencies freqs
(GHz). A component at f sits at the absolute frequency
nu0 - detuning + f.
measured_transfer(freqs, freq_meas, H_meas, carrier)¶
A drop-in replacement for resonator_transfer that uses a
measured complex transfer function (e.g. a VNA \(S_{21}\) sweep) instead of the
ideal RLC, so the real non-Lorentzian magnitude ripple and phase are modelled.
freq_meas is the absolute microwave frequency (GHz) of the measurement,
H_meas the complex transfer there, and carrier (GHz) maps a rotating-frame
component at f to the absolute frequency carrier + f. Magnitude and unwrapped
phase are interpolated separately and the result is normalised to unit peak
magnitude and zero phase at the carrier — the same convention as
resonator_transfer (\(|H|=1\) at centre, phase referenced to the pulse start), so
only the relative across-band distortion is applied. Out-of-band frequencies clamp
to the nearest measured point. Drive it through the resonator dict's measured
key (see the table above).
apply_resonator(w, dt, nu0, Q, detuning=0.0, mode='simulate', max_gain=10.0, ringdown=0.0, measured=None)¶
Filter a complex baseband waveform w (sampled every dt ns) through the
resonator and return the distorted waveform. max_gain caps the compensate
boost. Pass measured=(freq_GHz, H_complex) to filter through a measured transfer
function instead of the ideal RLC. This is the low-level routine the resonator
dict drives.
ringdown_time(nu0, Q)¶
The resonator amplitude ring-down time constant \(\tau = Q/(\pi\nu_0)\) (ns with
nu0 in GHz); the stored field decays as \(e^{-t/\tau}\). With
mode='simulate', setting ringdown (e.g. \(5\tau\)) appends the post-pulse
ring-down so the spins keep nutating under the decaying field — this restores the
flip angle a finite-bandwidth resonator otherwise steals from a short hard pulse.