Nuclear magnetic resonance with Qiskit
For this Qiskit in Classrooms module, students must have a working Python environment with the following packages installed:
qiskitv2.1 or newerqiskit-ibm-runtimev0.40.1 or newerqiskit-aerv0.17 or newerscipyandmatplotlib(for fitting and plotting)pylatexenc(for rendering circuit diagrams)
To set up and install the packages above, see the Install Qiskit guide. To run jobs on real quantum computers, students must set up an account with IBM Quantum® by following the steps in the Set up your IBM Cloud® account guide.
This module has been tested and uses approximately 5 minutes of QPU time. This is an estimate, and your actual usage might vary.
Introduction
When a chemist runs an NMR experiment, they place a sample of nuclear spins in a strong magnetic field, apply a radio-frequency (RF) pulse, and measure the signal that comes back. The technique rests on a few ideas from the quantum mechanics of a spin-1/2 particle in a magnetic field. The spin has two energy levels separated by an energy that corresponds to a characteristic Larmor frequency. A resonant pulse at that frequency tips the spin into the transverse plane, where it precesses and induces a signal that then fades with two characteristic times, and .
A superconducting qubit is an engineered version of the same object: a controllable two-level quantum system. Its and states are the analog of spin-up and spin-down, the gate we apply to rotate it is the analog of the RF pulse, and it loses coherence with its own and . This is not merely a loose comparison; some of the earliest experimental quantum processors in the late 1990s and early 2000s used NMR.
In this module you will use a real quantum computer to run the same characterization experiments that an NMR spectroscopist (or a quantum engineer calibrating hardware) performs:
- Rabi / nutation — driving the spin and watching it rotate. This is how you calibrate a or rotation.
- — spin-lattice relaxation, the decay of an excited spin back to equilibrium.
- — spin-spin relaxation (dephasing), measured with a Ramsey experiment and refocused with a Hahn echo.
- NMR spectroscopy (chemical shift and -coupling) — simulating a small molecule on several qubits and reading its multiplet spectrum to recover connectivity through the rule.
For each concept we first give the minimal theory, then implement the experiment in Qiskit following the four-step Qiskit patterns workflow.
Theory
Spin-1/2 particles in a magnetic field
A spin-1/2 nucleus has a magnetic moment , where is the gyromagnetic ratio. In a static field along , the two spin states split in energy (the Zeeman effect):
The energy gap defines the Larmor (resonance) frequency . The magnetic moment precesses about at this same frequency.
To rotate the spin we apply an oscillating RF field at (or near) . In the frame rotating at the drive frequency (the rotating frame), a resonant drive rotates the Bloch vector about an axis in the - plane. How far it rotates (the flip angle) is set by the pulse's strength and duration. Two choices matter most: a pulse tips a spin into the transverse plane, and a pulse rotates it fully to .
Translating between NMR and quantum computing jargon
A qubit is a two-level system, mathematically identical to a spin-1/2 in a magnetic field — so while we're running on a quantum processor rather than a nucleus, the physics is the same physics that underlies NMR. Only the vocabulary differs. This table helps you talk to physical chemists and quantum-computing specialists alike.
| NMR / spin-1/2 | Quantum processor (qubit) |
|---|---|
| Spin-up / spin-down | / |
| Zeeman gap | Qubit transition energy |
| Larmor frequency | Qubit frequency |
| Resonant RF pulse (flip angle ) | Single-qubit rotation gate |
| / pulse | / |
| Spin-lattice relaxation | Qubit energy-relaxation time |
| Spin-spin relaxation | Qubit dephasing time |
| Nutation | Rabi oscillations |
| Free induction decay (FID), Hahn echo | Ramsey, echo sequences |
Everything below is an exercise in measuring the right-hand column and reading it as the left-hand column.
Set up the Qiskit environment
We follow the Qiskit patterns workflow throughout:
- Map the problem to a quantum circuit.
- Optimize the circuit for execution (transpile to the backend).
- Execute using an IBM Quantum primitive.
- Post-process and analyze.
First we load the runtime service and pick the least-busy real backend. If you haven't yet saved your credentials, see the documentation page on how to save your login credentials.
# Added by doQumentation — installs the packages this notebook needs if they are missing
import importlib.util
_needed = {"matplotlib": "matplotlib", "numpy": "numpy", "qiskit": "qiskit", "qiskit_aer": "qiskit-aer", "qiskit_ibm_runtime": "qiskit-ibm-runtime", "scipy": "scipy"}
_missing = [pip for module, pip in _needed.items()
if importlib.util.find_spec(module) is None]
# One at a time, so a package that fails to install does not block the others
for _pip in _missing:
%pip install -q {_pip}
if not _missing:
print("\u2713 All required packages are installed")
# Load the IBM Quantum Compute Service and select the least-busy real backend
from qiskit_ibm_runtime import QiskitRuntimeService, SamplerV2 as Sampler, Batch
# After setup, this picks up your saved credentials automatically:
service = QiskitRuntimeService()
backend = service.least_busy(operational=True, simulator=False, min_num_qubits=156)
print(f"Selected backend: {backend.name}")
Selected backend: ibm_kingston
Use the cell below to switch between real hardware and a local simulator for the whole notebook. Running on hardware is the point of the module, but if there are connectivity or usage issues, the simulator can be a backup. Set USE_HARDWARE = True to run on the QPU selected above.
import numpy as np
from itertools import combinations
from qiskit_aer import AerSimulator
from qiskit.transpiler import generate_preset_pass_manager
from qiskit_ibm_runtime import EstimatorV2 as Estimator
USE_HARDWARE = True # <-- set True to run on the QPU selected above
USE_BATCH = False # Safer for a class with pauses; set True for an uninterrupted run
if USE_HARDWARE:
run_backend = backend # the real QPU selected above
else:
# Local testing mode: an Aer simulator that mimics the selected device -- its noise model,
# coupling map, and qubit count -- while layout choices still use real calibration data.
run_backend = AerSimulator.from_backend(backend)
# --- Choose connected qubits with low readout error. --------------------------------------
def readout_error(q):
props = backend.target["measure"].get((q,))
return props.error if props is not None and props.error is not None else 1.0
coupling_map = backend.coupling_map or backend.target.build_coupling_map()
edges = {tuple(sorted(edge)) for edge in coupling_map.get_edges() if edge[0] != edge[1]}
# AX needs one connected pair.
AX_LAYOUT = list(min(edges, key=lambda edge: sum(readout_error(q) for q in edge)))
# AX2 needs one qubit connected to two neighbours; the first entry is the central A qubit.
neighbors = {q: set() for q in range(backend.num_qubits)}
for q0, q1 in edges:
neighbors[q0].add(q1)
neighbors[q1].add(q0)
ax2_layouts = (
[a, x1, x2]
for a, adjacent in neighbors.items()
for x1, x2 in combinations(adjacent, 2)
)
AX2_LAYOUT = min(ax2_layouts, key=lambda layout: sum(readout_error(q) for q in layout))
# Use a long-lived qubit for the single-qubit experiments.
def best_qubit(backend):
return max(
range(backend.num_qubits), key=lambda q: backend.qubit_properties(q).t2 or 0.0
)
QUBIT = best_qubit(backend)
qp = backend.qubit_properties(QUBIT)
print(
f"Using qubit {QUBIT} for single-qubit experiments: "
f"T1 = {qp.t1 * 1e6:.0f} us, T2 = {qp.t2 * 1e6:.0f} us"
)
print(f"Using physical qubits {AX_LAYOUT} for AX")
print(f"Using physical qubits {AX2_LAYOUT} for AX2 (A first, then the two X spins)")
pm_q = generate_preset_pass_manager(
optimization_level=1, backend=run_backend, initial_layout=[QUBIT]
)
pm_ax = generate_preset_pass_manager(
optimization_level=1, backend=run_backend, initial_layout=AX_LAYOUT
)
pm_ax2 = generate_preset_pass_manager(
optimization_level=1, backend=run_backend, initial_layout=AX2_LAYOUT
)
# Job mode is the default because a classroom pause can deactivate a Batch. On the Open plan,
# a Batch has a 10-minute maximum lifetime and deactivates after 1 minute with no queued job.
batch = Batch(backend=run_backend) if USE_BATCH else None
execution_mode = batch if batch is not None else run_backend
sampler = Sampler(mode=execution_mode)
estimator = Estimator(mode=execution_mode)
estimator.options.resilience_level = 0 # raw expectation values, no error mitigation
estimator.options.default_shots = 1000
mode_name = "Batch" if USE_BATCH else "individual jobs"
device_name = "hardware" if USE_HARDWARE else "local simulator (runtime local mode)"
print(f"Ready. Running on {device_name} using {mode_name}.")
Experiment: Rabi oscillations (nutation)
Theory
Drive a spin on resonance and its Bloch vector rotates steadily from pole to pole — it nutates. If we apply a rotation of flip angle about the -axis to a qubit starting in , the probability of finding it in is:
This is the Rabi curve. Its first maximum is the pulse (full inversion, the pulse); a pulse is a quarter-turn (the pulse that tips a spin into the transverse plane). In NMR this curve is how you calibrate your pulses.
A literal Rabi experiment sweeps the drive amplitude or duration in time. That requires pulse-level control, which was removed in Qiskit v2.0. Here we instead sweep the rotation angle of an gate. The curve and the / identification are identical; the only difference is that we choose an angle directly instead of calibrating a pulse length. On hardware with fractional gates, for arbitrary runs natively.
A Bloch-sphere view
The animation below shows the nutation: a resonant drive rotates the Bloch vector steadily about the -axis, carrying it from down toward and back. This is the very motion the Rabi curve measures.
Map the problem to a circuit
We build one parameterized circuit, followed by a measurement, and later evaluate it at many angles at once.
from qiskit import QuantumCircuit
from qiskit.circuit import Parameter
theta = Parameter("theta")
rabi = QuantumCircuit(1, 1)
rabi.rx(theta, 0)
rabi.measure(0, 0)
rabi.draw("mpl")
Optimize for execution
Transpile to an Instruction Set Architecture (ISA) circuit for the chosen backend.
rabi_isa = pm_q.run(rabi)
Execute
We sweep from to (two full Rabi cycles). Passing an array of parameter values as a single Primitive Unified Bloc (PUB) runs them all in one job.
angles = np.linspace(0, 4 * np.pi, 41)
job = sampler.run([(rabi_isa, angles.reshape(-1, 1))], shots=1000)
rabi_result = job.result()
Post-process and analyze
Extract at each angle and compare to .
import matplotlib.pyplot as plt
ba = rabi_result[0].data.c # BitArray, one entry per angle
shots = ba.num_shots
p1 = np.array([ba.get_counts(i).get("1", 0) / shots for i in range(len(angles))])
plt.figure(figsize=(7, 4))
plt.plot(angles, p1, "o", label="measured")
plt.plot(angles, np.sin(angles / 2) ** 2, "-", label=r"$\sin^2(\theta/2)$")
for k, name in [(np.pi / 2, r"$\pi/2$ (90°)"), (np.pi, r"$\pi$ (180°)")]:
plt.axvline(k, color="gray", ls="--", lw=1)
plt.text(k, 1.02, name, ha="center", fontsize=9)
plt.xlabel(r"flip angle $\theta$ (rad)")
plt.ylabel(r"$P_1$")
plt.title("Rabi / nutation curve")
plt.legend()
plt.tight_layout()
plt.show()

Check your understanding
On the curve you measured, first reaches its maximum at the pulse (). Now suppose a device is miscalibrated, so that a nominal angle on the x-axis actually produces a rotation of .
(a) At what nominal angle would the first maximum of now appear? (b) Would applying a single pulse be a sensitive way to detect this 5% error?
Answer
(a) The maximum appears where the actual rotation equals , that is, at the nominal angle — a small leftward shift of the peak.
(b) No. A nominal pulse produces an actual rotation of , giving instead of — a change too small to see against measurement noise. This is why, to calibrate precisely, NMR spectroscopists amplify the error by applying the rotation many times (for example the method, or a long pulse train), so a small per-pulse error accumulates into a large, measurable deviation. We return to this idea in the challenge problems.
Experiment: (spin-lattice relaxation)
Theory
Left alone, an excited spin gives up energy to its surroundings (the "lattice") and relaxes back toward thermal equilibrium. The excited-state population decays exponentially with the spin-lattice relaxation time :
For a chemist, sets the recycle delay you must wait between scans for quantitative NMR, and it reports on molecular tumbling and dynamics. On a qubit it is the energy-relaxation time, and is one of the two main numbers used to model the decoherence effects in a quantum computer.
The experiment. Prepare with an gate (a pulse), wait a variable delay , then measure. The fraction still in traces out the decay.
A Bloch-sphere view
relaxation returns the excited state to equilibrium, so the Bloch vector slides from back up to along the -axis.
Build and transpile a delay scan
Delay durations must be concrete values (they are scheduled in hardware time), so we build one circuit per delay.
# Scale the T1 delay sweep to the qubit's reported coherence, and snap each delay to an integer
# number of samples (dt) so there are no "duration rounded" warnings (as in the Ramsey/echo cells).
T1_us = (backend.qubit_properties(QUBIT).t1 or 100e-6) * 1e6 # reported T1 (us)
delays_us = np.linspace(0, 5 * T1_us, 20) # 0 .. ~5 T1 captures the full decay
print(f"T1 sweep: 0..{5*T1_us:.0f} us in {len(delays_us)} points")
dt = backend.dt
t1_circuits = []
for tau in delays_us:
n_dt = int(round(tau * 1e-6 / dt)) # integer samples -> no rounding warning
qc = QuantumCircuit(1, 1)
qc.x(0) # pi pulse: prepare |1>
qc.delay(n_dt, 0, unit="dt") # wait tau
qc.measure(0, 0)
t1_circuits.append(qc)
t1_isa = pm_q.run(t1_circuits)
T1 sweep: 0..1393 us in 20 points
Execute
t1_job = sampler.run([(c,) for c in t1_isa], shots=1000)
t1_res = t1_job.result()
Analyze — fit the exponential
from scipy.optimize import curve_fit
p1_t1 = np.array(
[r.data.c.get_counts().get("1", 0) / r.data.c.num_shots for r in t1_res]
)
def decay(t, A, T1, B):
return A * np.exp(-t / T1) + B
p0 = [1.0, 100.0, 0.0]
popt, _ = curve_fit(decay, delays_us, p1_t1, p0=p0, maxfev=10000)
T1_fit = popt[1]
plt.figure(figsize=(7, 4))
plt.plot(delays_us, p1_t1, "o", label="measured")
tt = np.linspace(0, delays_us.max(), 200)
plt.plot(tt, decay(tt, *popt), "-", label=rf"fit: $T_1$ = {T1_fit:.1f} µs")
plt.xlabel("delay τ (µs)")
plt.ylabel(r"$P_1$")
plt.title("$T_1$ relaxation")
plt.legend()
plt.tight_layout()
plt.show()
print(f"Measured T1 = {T1_fit:.1f} microseconds")
Measured T1 = 225.3 microseconds
The exact time varies from experiment to experiment and backend to backend, but you should see a result of around a few hundred microseconds.
Check your understanding
In quantitative NMR you are told to wait about between scans. Using the decay law, what fraction of the equilibrium magnetization has recovered after ?
Answer
After , the remaining deviation from equilibrium is , that is, about 99.3% recovered.
Experiment: ,
Theory
measures energy loss. measures loss of phase coherence in the transverse plane. We can observe this with a Ramsey experiment: a pulse rotates all the spins down to the equator, all pointing in the same direction. They Larmor precess, but each one precesses at a slightly different frequency, so they fan out along the equator and the net transverse signal decays, with a characteristic decay time of .
Aside for quantum readers: The fanning-out picture shows one way to lose coherence: a spread of definite phases that average out. It's worth knowing this isn't the only mechanism. Coherence can also be lost because the phase becomes entangled with the environment, so it's no longer a definite value the system holds at all. You can't tell the two mechanisms apart just by watching the signal decay, so the fanning-out picture is a good intuition. Be mindful not to read it as the complete or literal story.
Two effects dephase a spin:
- Irreversible (or effectively irreversible) decoherence arising from uncontrolled coupling to other degrees of freedom, characterized by the true . In NMR, this generally comes from molecular motion modulating the couplings between spins. In quantum computing, it corresponds to unwanted couplings between qubits and the environment.
- Static or slowly-varying, reversible frequency offsets, such as field inhomogeneity, qubit-to-qubit frequency variation, and calibration errors. Combined with the true , these produce the shorter apparent decay time observed in a Ramsey experiment.
So how do we recover the true if a Ramsey experiment only tells us about ? The second experiment, called a Hahn echo, cancels all the noise of the second type to find the true . It starts the same as a Ramsey experiment, with a pulse, then the spins fan out as before. After some time , a pulse is applied. The pulse reverses the phase accumulation caused by static frequency offsets, so the spins refocus after a second delay of the same duration, .
and are both important numbers to know in both the chemistry setting and the quantum computing setting. In the chemistry setting, sets your linewidth for a standard NMR experiment (you'll see what this means in a later section). can tell you much about the molecules, such as molecular tumbling, and a change in can signal that a molecule has bound to something larger. In the quantum computing setting, we generally want both and to be as long as possible, so we have more time to perform experiments before decoherence overwhelms the system. A whole family of techniques based on the Hahn echo extends this time by canceling the reversible noise, so that the circuit is ideally limited by the true , not . This is called "dynamical decoupling."
Implementation
When we implement the Ramsey and Hahn echo experiments on a qubit, the readout is slightly different than for the NMR case. In NMR, you can directly observe the oscillating fields induced by the spins precessing at the Larmor frequency in the transverse plane. But with a qubit, we can only measure its projection along the axis, that is, whether it's or . Therefore, for both experiments, we end with an additional pulse followed by a measurement. This pulse translates the phase information of the qubit into a population.
Below we run each experiment to determine and .
Ramsey (): Two pulses with delay time
The first pulse tips the Bloch vector to the equator, and the next one translates the phase of the Bloch vector to a population. Because the phase changes in time as the Bloch vector precesses, we see a sinusoidal pattern in the population as a function of precession time . As the qubit loses phase coherence we should see the amplitude of this sine curve decay.
Some more subtleties for the quantum readers: In quantum computing experiments, the oscillations we see aren't actually at the Larmor frequency, which is in the GHz range and much too fast for us to resolve. Quantum experiments operate in the rotating frame: a frame that spins along with the qubit at (near) the Larmor frequency, so the fast precession is invisible and the Bloch vector looks nearly still. To see fringes, we deliberately set our rotating frame slightly detuned from the Larmor frequency. The Bloch vector then appears to precess at that small detuning, and we get an oscillating signal at the detuning frequency. Below, we don't fix the detuning at one value; we choose it so that about a dozen fringes span the measurement window (which we size to the qubit's coherence time), which keeps the oscillation easy to sample without aliasing. One more wrinkle: in Qiskit we don't have the pulse-level control needed to change the pulse frequency (which is what sets the rotating-frame frequency). Instead, we add an effective detuning by hand, using it to set the phase of the second Ramsey pulse. It's a bit contrived, but the result is the same and the decay we observe is still the genuine .
A Bloch-sphere view
A pulse tips the spins to the equator; each then precesses at a slightly different rate and they fan out, so the net (red) vector shrinks as they dephase — this decay is .
# Adaptive Ramsey: scale the window to the reported coherence and choose the detuning so a fixed
# number of fringes span it. This decouples the point count from the window -- a long window stays
# cheap and cannot alias (we keep 8 samples per fringe).
T2_us = (backend.qubit_properties(QUBIT).t2 or 100e-6) * 1e6 # reported (echo) T2 (us)
ramsey_span = 0.5 * T2_us # window = half the reported T2
n_fringes = 12 # fringes across the window
f_detune = n_fringes / (
ramsey_span * 1e-6
) # adaptive detuning (Hz) -> low, so a long window is cheap
samp_per_fringe = 8
n_ramsey = n_fringes * samp_per_fringe + 1 # ~97 points, fixed regardless of T2
ramsey_delays_us = np.linspace(0, ramsey_span, n_ramsey)
print(
f"Ramsey sweep: 0..{ramsey_span:.0f} us in {n_ramsey} points "
f"(detuning {f_detune/1e3:.1f} kHz, {samp_per_fringe} samples/fringe)"
)
dt = backend.dt # seconds per sample
delays_dt = [int(round(t * 1e-6 / dt)) for t in ramsey_delays_us] # integer samples
actual_delays_us = np.array(delays_dt) * dt * 1e6 # true (snapped) delays for fitting
ramsey_circ = []
for n_dt, tau_us in zip(delays_dt, actual_delays_us):
qc = QuantumCircuit(1, 1)
qc.rx(np.pi / 2, 0)
qc.delay(n_dt, 0, unit="dt") # integer duration -> no rounding warning
qc.rz(
2 * np.pi * f_detune * tau_us * 1e-6, 0
) # detuning phase (uses the true delay)
qc.rx(np.pi / 2, 0)
qc.measure(0, 0)
ramsey_circ.append(qc)
ramsey_isa = pm_q.run(ramsey_circ)
ramsey_res = sampler.run([(c,) for c in ramsey_isa], shots=1000).result()
p1_ramsey = np.array(
[r.data.c.get_counts().get("1", 0) / r.data.c.num_shots for r in ramsey_res]
)
ramsey_circ[len(ramsey_circ) // 2].draw("mpl") # draw a sample circuit (mid-sweep)
Ramsey sweep: 0..248 us in 97 points (detuning 48.4 kHz, 8 samples/fringe)
Hahn echo (): Add a refocusing pulse in the middle
A Bloch-sphere view
The Hahn echo: the spins fan out as before, then a pulse flips them, so their continued precession refocuses them back together (the red vector regrows), recovering the coherence lost to static dephasing.
# Scale the echo sweep to the reported (echo) T2. The echo signal is monotonic (no fringes),
# so only the range matters here; 0 .. ~2.5 T2 shows the full decay for a clean fit.
T2_us = (backend.qubit_properties(QUBIT).t2 or 100e-6) * 1e6
echo_delays_us = np.linspace(0, 2.5 * T2_us, 24) # total free-evolution time
print(f"Echo sweep: 0..{2.5*T2_us:.0f} us in {len(echo_delays_us)} points")
dt = backend.dt
half_dt = [
int(round(t / 2 * 1e-6 / dt)) for t in echo_delays_us
] # samples per half-delay
actual_echo_us = np.array(half_dt) * 2 * dt * 1e6 # true total delay for fitting
echo_circ = []
for n_half in half_dt:
qc = QuantumCircuit(1, 1)
qc.rx(np.pi / 2, 0)
qc.delay(n_half, 0, unit="dt") # half the free-evolution, integer samples
qc.rx(np.pi, 0) # refocusing pi pulse
qc.delay(n_half, 0, unit="dt")
qc.rx(-np.pi / 2, 0)
qc.measure(0, 0)
echo_circ.append(qc)
echo_isa = pm_q.run(echo_circ)
echo_res = sampler.run([(c,) for c in echo_isa], shots=1000).result()
p1_echo = np.array(
[r.data.c.get_counts().get("1", 0) / r.data.c.num_shots for r in echo_res]
)
echo_circ[len(echo_circ) // 2].draw("mpl") # draw a sample circuit
Echo sweep: 0..1240 us in 24 points
Analyze: Fit and compare the two decays
# Initial guesses / bounds derived from the reported coherence so the fits track the scaled axes.
T2_us = (backend.qubit_properties(QUBIT).t2 or 100e-6) * 1e6
# Ramsey: decaying oscillation -> extract T2*
def ramsey_fn(t, A, T2s, f, phi, B):
return A * np.exp(-t / T2s) * np.cos(2 * np.pi * f * t + phi) + B
rp0 = [0.4, T2_us / 2, f_detune * 1e-6, 0.0, 0.5] # T2* guess ~ half the echo T2
rbounds = (
[0, 0.1, 0, -np.pi, 0],
[1, 2 * T2_us, 5, np.pi, 1],
) # keep the fit from collapsing to ~0
rpopt, _ = curve_fit(
ramsey_fn, ramsey_delays_us, p1_ramsey, p0=rp0, bounds=rbounds, maxfev=20000
)
T2_star = abs(rpopt[1])
# Echo: simple exponential envelope -> extract T2
def env(t, A, T2, B):
return A * np.exp(-t / T2) + B
epopt, _ = curve_fit(env, echo_delays_us, p1_echo, p0=[0.5, T2_us, 0.0], maxfev=10000)
T2 = abs(epopt[1])
fig, ax = plt.subplots(1, 2, figsize=(12, 4))
ax[0].plot(ramsey_delays_us, p1_ramsey, "o")
tt = np.linspace(0, ramsey_delays_us.max(), 400)
ax[0].plot(tt, ramsey_fn(tt, *rpopt), "-")
ax[0].set_title(rf"Ramsey: $T_2^*$ = {T2_star:.1f} µs")
ax[0].set_xlabel("τ (µs)")
ax[0].set_ylabel(r"$P_1$")
ax[1].plot(echo_delays_us, p1_echo, "o")
tt2 = np.linspace(0, echo_delays_us.max(), 400)
ax[1].plot(tt2, env(tt2, *epopt), "-")
ax[1].set_title(rf"Hahn echo: $T_2$ = {T2:.1f} µs")
ax[1].set_xlabel("τ (µs)")
plt.tight_layout()
plt.show()
print(f"T2* (Ramsey) = {T2_star:.1f} µs ; T2 (echo) = {T2:.1f} µs")

T2* (Ramsey) = 171.2 µs ; T2 (echo) = 326.0 µs
Here we see both the Ramsey oscillation and the Hahn echo signal decay, as expected. The associated time scales for each of these decays are and , respectively.
Check your understanding
You should typically find . Explain why in one sentence, and say what physical effect the Hahn echo's pulse removes, as well as what it cannot remove.
Answer
includes both irreversible dephasing and reversible static spread, so it decays faster; the echo's pulse refocuses the static, reversible dephasing (field inhomogeneity / fixed-frequency offsets), leaving only the irreversible spin-spin dephasing, which is the true . The echo cannot undo fluctuations that change between the two halves of the sequence.
NMR spectroscopy of a molecule: Chemical shift and -coupling
From one spin to a molecule
The experiments so far characterized a single, isolated spin. Real chemical NMR reads a molecule (many coupled spins at once) and uses the resulting spectrum to infer features of the molecule's structure. This can tell us about two effects:
- Chemical shift. Each chemically distinct nucleus precesses at a slightly different frequency , because its local electrons shield the applied field by different amounts. Peak positions therefore identify chemical environments (a CH proton versus an aromatic one, for example).
- Scalar -coupling. A through-bond interaction between two spins splits each peak into a multiplet. A nucleus with equivalent neighbors is split into lines with binomial intensities, the rule. The splitting pattern reveals connectivity (which nuclei are bonded), and the spacing is the coupling constant .
The experiment
To model this in our quantum computer, we need to implement a spin Hamiltonian on the qubits. In the weak-coupling, high-field limit (chemical-shift differences couplings), the and parts of the coupling average out, leaving only . So the Hamiltonian is (angular-frequency units, ):
Each nucleus is one qubit: the chemical shift is a single-qubit rotation, and the coupling is a two-qubit interaction.
The experiment proceeds as follows. A pulse tips every spin into the transverse plane, and the spins precess and couple for time . We record the complex transverse magnetization summed over all spins. As in the Ramsey experiment above, this transverse magnetization traces out a decaying sinusoid as a function of the precession time . When we take the Fourier transform of the signal, we get a spectrum with peaks at the chemical shift split by the scalar coupling .
We simulate the molecule's evolution by mapping the time onto rotation angles: a larger means larger and rotations, not a longer or deeper circuit. So while the simulated state does advance with (that's what makes the free induction decay (FID) oscillate), the physical qubits never idle any longer and there's no -dependent decoherence. The simulated FID therefore never decays on its own. For an infinitely long ideal FID the spectral lines would be infinitely sharp; our finite sampling window instead gives them finite resolution and can introduce spectral leakage. Real NMR lines have a finite width set by . We add it back by multiplying the FID by a window before the Fourier transform. This is called a line-broadening apodization, which spectroscopists apply in processing to set the linewidth (trading resolution for signal-to-noise). (An optional cell in the Appendix of this module shows a Trotterized alternative, where the decay instead comes from real decoherence that grows with circuit depth.)
The AX system
Let's start with the so-called AX system, a molecule consisting of two coupled, chemically distinct spins. The labels A and X denote the two chemically distinct spin environments. The letters are arbitrary, but the fact that they are far apart in the alphabet illustrates their chemical dissimilarity.
Map and optimize
We write one function that, given the chemical-shift offsets and couplings , returns a parameterized state-preparation circuit. The pulse tips the spins to the equator, then the rotations simulate the evolution of the state under the chemical-shift and -coupling terms. Each is a small precession offset in Hz relative to a chosen reference, or rotating-frame carrier. Here we set it manually; in a real molecule, electron shielding changes the local magnetic field and shifts each nucleus's resonance relative to that reference. This is modeled by the single-qubit gates, one per spin, and each fixes where that spin's peak sits in the spectrum. The two-qubit gate applies the coupling , which splits each peak into a multiplet and sets the spacing between its lines.
from qiskit import QuantumCircuit
from qiskit.circuit import Parameter
from qiskit.quantum_info import SparsePauliOp
t = Parameter(
"t"
) # evolution time (arbitrary units; the frequencies below are in matching units)
def fid_circuit(offsets, couplings):
"""State-preparation circuit for a set of spins: a pi/2 pulse, then evolution under the
chemical-shift + J-coupling Hamiltonian for time t. No measurement -- the Estimator reads
<X> and <Y> directly.
offsets : list of chemical-shift frequencies nu_i, one per qubit
couplings : dict {(i, j): J_ij}
"""
n = len(offsets)
qc = QuantumCircuit(n)
qc.ry(np.pi / 2, range(n)) # pi/2 pulse: tip every spin into the transverse plane
for i, nu in enumerate(offsets):
qc.rz(2 * np.pi * nu * t, i) # chemical-shift precession -> line at nu_i
for (i, j), J in couplings.items():
qc.rzz(np.pi * J * t, i, j) # J-coupling -> splitting of size J
return qc
def transverse_obs(n):
"""Total transverse observables (sum over spins): (sum_i X_i, sum_i Y_i)."""
ox = SparsePauliOp.from_sparse_list(
[("X", [i], 1.0) for i in range(n)], num_qubits=n
)
oy = SparsePauliOp.from_sparse_list(
[("Y", [i], 1.0) for i in range(n)], num_qubits=n
)
return ox, oy
# AX system: two distinct chemical shifts, one coupling
ax_offsets = [6.0, -6.0] # chemical shifts nu_1, nu_2 (Hz)
ax_couplings = {(0, 1): 2.0} # J_12 = 2 Hz
ax_isa = pm_ax.run(fid_circuit(ax_offsets, ax_couplings))
fid_circuit(ax_offsets, ax_couplings).draw("mpl")
Execute and post-process
We evaluate the circuit over a grid of evolution times, and the Estimator returns and at each. We combine these into the complex FID , apply the apodization window, and Fourier transform the result to obtain the spectrum.
# --- time grid ---
n_t = 128 # number of FID samples
dt = 1 / 32 # time step -> Nyquist frequency = 1/(2 dt) = 16 Hz
times = np.arange(n_t) * dt
# --- Step 3-4: Estimator returns <X> and <Y> directly (both quadratures in one job) ---
def run_fid(isa_circ, n, est):
"""Complex FID s(t) = sum_i <X_i> + i <Y_i> over the time grid, via the Estimator."""
ox, oy = transverse_obs(n)
ox = ox.apply_layout(isa_circ.layout) # map observables onto the transpiled qubits
oy = oy.apply_layout(isa_circ.layout)
params = times.reshape(-1, 1)
res = est.run(
[(isa_circ, ox, params), (isa_circ, oy, params)]
).result() # both quadratures, one job
return res[0].data.evs + 1j * res[1].data.evs
def spectrum(fid, T2=1.0):
sig = fid * np.exp(-times / T2) # apodization -> Lorentzian linewidth ~ 1/(pi T2)
spec = np.fft.fftshift(np.fft.fft(sig))
freq = np.fft.fftshift(np.fft.fftfreq(len(times), dt))
return freq, np.abs(spec)
fid_ax = run_fid(ax_isa, n=2, est=estimator)
freq, spec = spectrum(fid_ax, T2=1.0)
fig, ax = plt.subplots(1, 2, figsize=(12, 4))
ax[0].plot(times, fid_ax.real, label=r"Re $s(t)=\langle X\rangle$")
ax[0].plot(times, fid_ax.imag, label=r"Im $s(t)=\langle Y\rangle$")
ax[0].set_xlabel("evolution time $t$")
ax[0].set_ylabel("transverse signal")
ax[0].set_title("AX free induction decay")
ax[0].legend()
ax[1].plot(freq, spec)
for nu in ax_offsets:
for s in (-1, 1):
ax[1].axvline(nu + s * ax_couplings[(0, 1)] / 2, color="gray", ls="--", lw=1)
ax[1].set_xlim(-12, 12)
ax[1].set_xlabel(r"frequency $\nu$ (Hz)")
ax[1].set_ylabel("intensity")
ax[1].set_title("AX spectrum: two doublets, split by $J$")
plt.tight_layout()
plt.show()
print(
"Expected AX lines (Hz):",
sorted({nu + s * ax_couplings[(0, 1)] / 2 for nu in ax_offsets for s in (-1, 1)}),
)

Expected AX lines (Hz): [-7.0, -5.0, 5.0, 7.0]
Extension: An AX system and the rule
Now add a second equivalent neighbor. In an AX system one A spin couples, with the same , to two equivalent X spins. (Remember, X is just a label for the two equivalent spins; it has nothing to do with the Pauli operators. Every coupling below is still .) Spin A then sees four neighbor configurations ; the two mixed ones coincide, so A is split into a 1:2:1 triplet ( lines). Each X spin sees only A and stays a doublet. Equivalent spins do not split each other, so we include no X–X coupling.
A real molecule with this pattern is 1,1,2-trichloroethane (ClCH–CHCl). When targeting the hydrogen spins (which is the most common form of NMR), the single hydrogen proton in CH plays the role of A, and the two equivalent CH protons are the two X spins. Now just count neighbors: the CH proton has two neighbors (the CH pair), so its signal splits into a 1:2:1 triplet (); each CH proton has only one neighbor (the CH), so the CH signal is a 1:1 doublet (). Note that the group with fewer protons (CH) shows more lines; the line count reflects how many neighbors a signal has, not how many protons share it.
Below, we simulate an AX system with three qubits.
# AX2: spin A (qubit 0) coupled equally to two equivalent X spins (qubits 1, 2)
ax2_offsets = [6.0, -6.0, -6.0] # A at +6 Hz; the two X both at -6 Hz
ax2_couplings = {
(0, 1): 2.0,
(0, 2): 2.0,
} # equal A-X couplings; no coupling between the X spins
ax2_isa = pm_ax2.run(fid_circuit(ax2_offsets, ax2_couplings))
fid_ax2 = run_fid(ax2_isa, n=3, est=estimator)
freq2, spec2 = spectrum(fid_ax2, T2=1.0)
fig, ax = plt.subplots(1, 2, figsize=(12, 4))
ax[0].plot(times, fid_ax2.real, label=r"Re $s(t)=\langle X\rangle$")
ax[0].plot(times, fid_ax2.imag, label=r"Im $s(t)=\langle Y\rangle$")
ax[0].set_xlabel("evolution time $t$")
ax[0].set_ylabel("transverse signal")
ax[0].set_title("AX$_2$ free induction decay")
ax[0].legend()
ax[1].plot(freq2, spec2)
ax[1].set_xlim(-12, 12)
ax[1].set_xlabel(r"frequency $\nu$ (Hz)")
ax[1].set_ylabel("intensity")
ax[1].set_title(r"AX$_2$ spectrum: A is a 1:2:1 triplet, X is a doublet")
plt.tight_layout()
plt.show()
print(
"A triplet at (Hz):",
[ax2_offsets[0] + k * ax2_couplings[(0, 1)] for k in (-1, 0, 1)],
)
print(
"X doublet at (Hz):",
[ax2_offsets[1] + s * ax2_couplings[(0, 1)] / 2 for s in (-1, 1)],
)
Check your understanding
Read each question, think about your answer, then click the triangle to reveal the solution.
In the AX spectrum the two doublets are separated (center-to-center) by 12 Hz, and the two lines within each doublet are 2 Hz apart. Which number is the chemical-shift difference and which is the coupling constant ? If you moved to a spectrometer with twice the magnetic field, which one would change?
Answer
The 12 Hz center-to-center separation is the chemical-shift difference (); the 2 Hz line spacing is the coupling constant . Chemical shifts come from shielding of , so they scale with the field. At twice the field the separation doubles to 24 Hz. The coupling is a through-bond molecular property and is field-independent, so it stays 2 Hz. Chemists tell shift from coupling by watching how a splitting scales with field.
Using the rule, predict the A multiplet if a third equivalent X spin were added (an AX system), and explain why the two equivalent X spins in AX produce no splitting of each other.
Answer
With equivalent neighbors, A becomes a 1:3:3:1 quartet. Equivalent spins don't split each other because their coupling shifts the relevant energy levels all by the same amount, so the gaps between those levels — the frequencies we see — don't change.
Conclusion
We have used a real quantum computer to explore how nuclear spins precess and decohere in an NMR experiment, and how to interpret the resulting spectra. It's worth stepping back to review why this was possible at all, and why it's more than a curiosity.
A qubit and a spin-1/2 nucleus are, mathematically, the same object: two-level quantum systems living on the Bloch sphere, rotated by resonant pulses, precessing about an axis, and losing coherence to their surroundings. The entire vocabulary of this module (the Larmor frequency, the rotating frame, , , and , Ramsey fringes, the Hahn echo) belongs to NMR and quantum computing equally. What a chemist calls a chemical shift, a quantum engineer calls a single-qubit rotation; what a chemist calls a -coupling, a quantum engineer calls a interaction. The two fields have been describing the same physics in different words.
That connection is historical, not just conceptual: NMR was one of the first platforms to run real quantum algorithms. By 2001, a molecule in solution had executed Shor's algorithm to factor 15, with each nuclear spin serving as a qubit. NMR also handed quantum computing much of its control toolkit. The spin echo, CPMG pulse trains, composite pulses, and dynamical decoupling were all invented by spectroscopists decades before qubits needed them to fight decoherence. There is a satisfying symmetry in what we just did: the field that gave birth to early quantum computers is now something a quantum computer can simulate.
Review and questions
Critical concepts
- A qubit is an engineered two-level system equivalent to a spin-1/2; its , frequency, gates, , and are the direct analogs of NMR quantities.
- A resonant pulse rotates the Bloch vector; the flip angle sets the rotation, and the Rabi/nutation curve identifies and rotations.
- is energy (spin-lattice) relaxation; is phase (spin-spin) relaxation; additionally includes reversible static dephasing that a Hahn echo refocuses.
- A molecule is a set of coupled spins: chemical shift sets each peak's position, and -coupling splits peaks into multiplets following the rule, which together encode chemical environment and connectivity.
True / false
- T/F The Larmor frequency is the energy gap between the two spin states divided by .
- T/F A () pulse fully inverts an equilibrium spin.
- T/F is generally longer than .
- T/F The Hahn echo refocuses irreversible spin-spin dephasing.
- T/F In an AX system the A nucleus appears as a 1:2:1 triplet.
- T/F The coupling constant doubles when you double the spectrometer's magnetic field.
- T/F Removing Qiskit Pulse makes it impossible to measure on current hardware.
Short answer
- Explain in two sentences why a single pulse is a poor way to detect a flip-angle error, and how chaining pulses fixes this.
- A sample shows broad NMR lines. In terms of , what does that tell you, and what qubit quantity is the analog?
- Give the qubit analog of each: (a) the recycle delay, (b) the pulse, (c) the chemical shift.
- Using the rule, explain why an ethyl group (CHCH–) shows a 1:2:1 triplet and a 1:3:3:1 quartet, and identify which group produces which multiplet.
Challenge problems
- Carr-Purcell train. Modify the Hahn echo to insert equally spaced pulses (a CPMG-style train) and measure as a function of . Discuss why more refocusing pulses can lengthen the apparent .
- Pulse-error amplification. Build a circuit that applies times to and plot vs for a deliberately mis-set angle . Show how the error becomes visible as grows. Hint: put a barrier after every rotation; otherwise the transpiler may combine the repeated rotations into a single equivalent gate.
- Ethyl group. Using
fid_circuit, build the proton spectrum of an ethyl fragment (CH–CH): five qubits, with the three CH protons sharing one chemical shift and the two CH protons another, and every CH–CH pair coupled by the same (no coupling within an equivalent group). Predict the pattern with the rule, then simulate it; you should recover a 1:3:3:1 quartet (CH) and a 1:2:1 triplet (CH).
Appendix (optional): Trotterized evolution — let decoherence set the linewidth
In the NMR spectroscopy of a molecule section, the evolution is applied in a single layer whose depth does not grow with , so the FID does not decay on its own and we impose the linewidth with the apodization window. This optional cell takes the opposite approach. It breaks the evolution into Trotter steps and puts a barrier after each step (so the transpiler cannot merge them back into one layer). Time point uses steps, so the circuit depth grows with time.
Because every term in commutes, this is exact: on a noiseless simulator it gives the same sharp peaks. But on a noise-model backend or real hardware the accumulating gate error makes the FID decay by itself, and that decay is what sets the linewidth. This is the linewidth idea of the , experiment section, now arising from real decoherence instead of a processing window. We run both the AX system (two doublets) and the AX system (a 1:2:1 triplet plus a doublet) this way.
Two practical notes to consider. First, the cost is depth: the deepest AX circuit uses two-qubit gates and the AX version about twice that, so both are far noisier and more expensive than the exact version. Secondly, each spin system is still submitted as one job, and every time point reuses the same physical-qubit layout selected in the spectroscopy section. If USE_BATCH=True, these jobs also use the optional notebook-wide Batch.
# Trotterized FID: depth grows with time, so real decoherence (not apodization) sets the linewidth.
estimator.options.default_shots = 2000 # deep circuits need more shots in this appendix
n_tr, dt_tr = (
48,
1 / 20,
) # Nyquist = 1/(2 dt) = 10 Hz (covers the AX2 triplet's 8 Hz line)
times_tr = np.arange(n_tr) * dt_tr
APODIZE_TROTTER = False # keep False so the linewidth comes from decoherence
def trotter_fid_circuit(offsets, couplings, p, step):
n = len(offsets)
qc = QuantumCircuit(n)
qc.ry(np.pi / 2, range(n)) # 90-degree pulse
for _ in range(p): # p Trotter steps, each of duration `step`
for i, nu in enumerate(offsets):
qc.rz(2 * np.pi * nu * step, i) # chemical-shift evolution for one step
for (i, j), J in couplings.items():
qc.rzz(np.pi * J * step, i, j) # J-coupling evolution for one step
qc.barrier() # <-- stops the transpiler merging the steps
return qc
def run_trotter(offsets, couplings, n, est, pass_manager):
# Time point k uses k Trotter steps (its own circuit). Submit every time point and both
# quadratures together as ONE Estimator job, using one fixed physical-qubit layout.
ox, oy = transverse_obs(n)
pubs = []
for k in range(n_tr):
isa = pass_manager.run(trotter_fid_circuit(offsets, couplings, k, dt_tr))
pubs.append((isa, ox.apply_layout(isa.layout))) # <X> at time k
pubs.append((isa, oy.apply_layout(isa.layout))) # <Y> at time k
res = est.run(pubs).result()
return np.array(
[res[2 * k].data.evs + 1j * res[2 * k + 1].data.evs for k in range(n_tr)]
)
def trotter_spectrum(fid):
sig = fid * np.exp(-times_tr / 1.0) if APODIZE_TROTTER else fid
spec = np.abs(np.fft.fftshift(np.fft.fft(sig)))
freq = np.fft.fftshift(np.fft.fftfreq(n_tr, dt_tr))
return freq, spec
fid_tr = run_trotter(
ax_offsets, ax_couplings, 2, estimator, pm_ax
) # AX -> two doublets
fid2_tr = run_trotter(
ax2_offsets, ax2_couplings, 3, estimator, pm_ax2
) # AX2 -> triplet + doublet
f_ax, s_ax = trotter_spectrum(fid_tr)
f_ax2, s_ax2 = trotter_spectrum(fid2_tr)
fig, ax = plt.subplots(1, 3, figsize=(15, 4))
ax[0].plot(times_tr, np.abs(fid_tr), "o-", label="AX")
ax[0].plot(times_tr, np.abs(fid2_tr), "s-", label="AX$_2$")
ax[0].set_xlabel("evolution time $t$")
ax[0].set_ylabel("|FID|")
ax[0].set_title("Trotter FID envelope (decays only on a noisy backend)")
ax[0].legend()
ax[1].plot(f_ax, s_ax)
ax[1].set_xlim(-12, 12)
ax[1].set_xlabel(r"frequency $\nu$ (Hz)")
ax[1].set_ylabel("intensity")
ax[1].set_title("AX: two doublets")
ax[2].plot(f_ax2, s_ax2)
ax[2].set_xlim(-12, 12)
ax[2].set_xlabel(r"frequency $\nu$ (Hz)")
ax[2].set_title("AX$_2$: 1:2:1 triplet + doublet")
plt.tight_layout()
plt.show()
print(
f"Deepest AX circuit: {n_tr-1} two-qubit gates; deepest AX2 circuit: {2*(n_tr-1)} two-qubit gates."
)
print(
"Noiseless backend: |FID| stays flat, peaks razor-sharp (set APODIZE_TROTTER=True to tame ripples)."
)
print(
"Noise-model backend / hardware: |FID| decays on its own and the peaks gain a real linewidth."
)
# Close the optional runtime Batch, if one was opened.
if batch is not None:
batch.close()