Skip to main content
IBM Quantum Platform

Nuclear magnetic resonance with Qiskit

For this Qiskit in Classrooms module, students must have a working Python environment with the following packages installed:

  • qiskit v2.1 or newer
  • qiskit-ibm-runtime v0.40.1 or newer
  • qiskit-aer v0.17 or newer
  • scipy and matplotlib (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, T1T_1 and T2T_2.

A superconducting qubit is an engineered version of the same object: a controllable two-level quantum system. Its ∣0⟩|0\rangle and ∣1⟩|1\rangle 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 T1T_1 and T2T_2. 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 π\pi or π/2\pi/2 rotation.
  • T1T_1 — spin-lattice relaxation, the decay of an excited spin back to equilibrium.
  • T2T_2 — spin-spin relaxation (dephasing), measured with a Ramsey experiment and refocused with a Hahn echo.
  • NMR spectroscopy (chemical shift and JJ-coupling) — simulating a small molecule on several qubits and reading its multiplet spectrum to recover connectivity through the n+1n+1 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 μ=γℏI\boldsymbol{\mu} = \gamma \hbar \mathbf{I}, where γ\gamma is the gyromagnetic ratio. In a static field B0B_0 along zz, the two spin states split in energy (the Zeeman effect):

E±=∓12γℏB0,ΔE=γℏB0=ℏω0.E_{\pm} = \mp \tfrac{1}{2}\gamma \hbar B_0, \qquad \Delta E = \gamma \hbar B_0 = \hbar \omega_0 .

The energy gap defines the Larmor (resonance) frequency ω0=γB0\omega_0 = \gamma B_0. The magnetic moment precesses about B0B_0 at this same frequency.

To rotate the spin we apply an oscillating RF field at (or near) ω0\omega_0. In the frame rotating at the drive frequency (the rotating frame), a resonant drive rotates the Bloch vector about an axis in the xx-yy plane. How far it rotates (the flip angle) is set by the pulse's strength and duration. Two choices matter most: a 90∘90^\circ pulse tips a +1/2+1/2 spin into the transverse plane, and a 180∘180^\circ pulse rotates it fully to −1/2-1/2.

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∣0⟩\ket{0} / ∣1⟩\ket{1}
Zeeman gap ΔE=ℏω0\Delta E = \hbar\omega_0Qubit transition energy ℏω01\hbar\omega_{01}
Larmor frequency ω0\omega_0Qubit frequency ω01\omega_{01}
Resonant RF pulse (flip angle θ\theta)Single-qubit rotation gate RX(θ)R_X(\theta)
90∘90^\circ / 180∘180^\circ pulseRX(π/2)R_X(\pi/2) / RX(π)R_X(\pi)
Spin-lattice relaxation T1T_1Qubit energy-relaxation time T1T_1
Spin-spin relaxation T2T_2Qubit dephasing time T2T_2
NutationRabi oscillations
Free induction decay (FID), Hahn echoRamsey, 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:

  1. Map the problem to a quantum circuit.
  2. Optimize the circuit for execution (transpile to the backend).
  3. Execute using an IBM Quantum primitive.
  4. 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.

# 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}")

Output:

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 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 θ\theta about the xx-axis to a qubit starting in ∣0⟩|0\rangle, the probability of finding it in ∣1⟩|1\rangle is:

P1(θ)=sin⁡2 ⁣(θ2)=12(1−cos⁡θ).P_1(\theta) = \sin^2\!\left(\tfrac{\theta}{2}\right) = \tfrac{1}{2}\bigl(1 - \cos\theta\bigr).

This is the Rabi curve. Its first maximum is the π\pi pulse (full inversion, the 180∘180^\circ pulse); a π/2\pi/2 pulse is a quarter-turn (the 90∘90^\circ pulse that tips a spin into the transverse plane). In NMR this curve is how you calibrate your pulses.

Experimental caveat

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 θ\theta of an RX(θ)R_X(\theta) gate. The curve and the π\pi/π/2\pi/2 identification are identical; the only difference is that we choose an angle directly instead of calibrating a pulse length. On hardware with fractional gates, RX(θ)R_X(\theta) for arbitrary θ\theta runs natively.

A Bloch-sphere view

The animation below shows the nutation: a resonant drive rotates the Bloch vector steadily about the xx-axis, carrying it from ∣0⟩|0\rangle down toward ∣1⟩|1\rangle and back. This is the very motion the Rabi curve measures.

Map the problem to a circuit

We build one parameterized circuit, RX(θ)R_X(\theta) 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")

Output:

Output of the previous code cell

Optimize for execution

Transpile to an Instruction Set Architecture (ISA) circuit for the chosen backend.

rabi_isa = pm_q.run(rabi)

Execute

We sweep θ\theta from 00 to 4π4\pi (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 P1P_1 at each angle and compare to sin⁡2(θ/2)\sin^2(\theta/2).

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

Output:

Output of the previous code cell

Check your understanding

On the curve you measured, P1P_1 first reaches its maximum at the π\pi pulse (θ=π\theta = \pi). Now suppose a device is miscalibrated, so that a nominal angle θ\theta on the x-axis actually produces a rotation of 1.05 θ1.05\,\theta.

(a) At what nominal angle would the first maximum of P1P_1 now appear? (b) Would applying a single π\pi pulse be a sensitive way to detect this 5% error?

  • (a) The maximum appears where the actual rotation equals π\pi, that is, at the nominal angle θ=π/1.05≈0.95π\theta = \pi/1.05 \approx 0.95\pi — a small leftward shift of the peak.

    (b) No. A nominal π\pi pulse produces an actual rotation of 1.05π1.05\pi, giving P1=sin⁡2(1.05π/2)≈0.994P_1 = \sin^2(1.05\pi/2) \approx 0.994 instead of 11 — 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 360∘360^\circ 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: T1T_1 (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 T1T_1:

P1(τ)=e−τ/T1.P_1(\tau) = e^{-\tau/T_1}.

For a chemist, T1T_1 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 ∣1⟩|1\rangle with an XX gate (a π\pi pulse), wait a variable delay τ\tau, then measure. The fraction still in ∣1⟩|1\rangle traces out the decay.

A Bloch-sphere view

T1T_1 relaxation returns the excited state to equilibrium, so the Bloch vector slides from ∣1⟩|1\rangle back up to ∣0⟩|0\rangle along the zz-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)

Output:

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")

Output:

Output of the previous code cell
Measured T1 = 225.3 microseconds

The exact T1T_1 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 5 T15\,T_1 between scans. Using the decay law, what fraction of the equilibrium magnetization has recovered after 5 T15\,T_1?

  • After 5 T15\,T_1, the remaining deviation from equilibrium is e−5≈0.0067e^{-5}\approx 0.0067, that is, about 99.3% recovered.


Experiment: T2T_2, T2∗T_2^*

Theory

T1T_1 measures energy loss. T2T_2 measures loss of phase coherence in the transverse plane. We can observe this with a Ramsey experiment: a π/2\pi/2 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 T2∗T_2^*.

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 T2T_2. 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 T2T_2, these produce the shorter apparent decay time T2∗T_2^* observed in a Ramsey experiment.

So how do we recover the true T2T_2 if a Ramsey experiment only tells us about T2∗T_2^*? The second experiment, called a Hahn echo, cancels all the noise of the second type to find the true T2T_2. It starts the same as a Ramsey experiment, with a π/2\pi/2 pulse, then the spins fan out as before. After some time τ\tau, a π\pi 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, τ\tau.

T2∗T_2^* and T2T_2 are both important numbers to know in both the chemistry setting and the quantum computing setting. In the chemistry setting, T2∗T_2^* sets your linewidth for a standard NMR experiment (you'll see what this means in a later section). T2T_2 can tell you much about the molecules, such as molecular tumbling, and a change in T2T_2 can signal that a molecule has bound to something larger. In the quantum computing setting, we generally want both T2T_2 and T2∗T_2^* 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 T2T_2, not T2∗T_2^*. 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 ZZ axis, that is, whether it's ∣0⟩\vert 0 \rangle or ∣1⟩\vert 1 \rangle. Therefore, for both experiments, we end with an additional π/2\pi/2 pulse followed by a measurement. This π/2\pi/2 pulse translates the phase information of the qubit into a population.

Below we run each experiment to determine T2∗T_2^* and T2T_2.

Ramsey (T2∗T_2^{*}): Two π/2\pi/2 pulses with delay time τ\tau

The first π/2\pi/2 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 τ\tau. 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 T2∗T_2^*.

A Bloch-sphere view

A π/2\pi/2 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 T2∗T_2^*.

# 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)

Output:

Ramsey sweep: 0..248 us in 97 points (detuning 48.4 kHz, 8 samples/fringe)
Output of the previous code cell

Hahn echo (T2T_2): Add a refocusing π\pi pulse in the middle

A Bloch-sphere view

The Hahn echo: the spins fan out as before, then a π\pi 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

Output:

Echo sweep: 0..1240 us in 24 points
Output of the previous code cell

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")

Output:

Output of the previous code cell
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 T2∗T_2^* and T2T_2, respectively.

Check your understanding

You should typically find T2≳T2∗T_2 \gtrsim T_2^{*}. Explain why in one sentence, and say what physical effect the Hahn echo's π\pi pulse removes, as well as what it cannot remove.

  • T2∗T_2^{*} includes both irreversible dephasing and reversible static spread, so it decays faster; the echo's π\pi pulse refocuses the static, reversible dephasing (field inhomogeneity / fixed-frequency offsets), leaving only the irreversible spin-spin dephasing, which is the true T2T_2. The echo cannot undo fluctuations that change between the two halves of the sequence.


NMR spectroscopy of a molecule: Chemical shift and JJ-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 νi\nu_i, because its local electrons shield the applied field by different amounts. Peak positions therefore identify chemical environments (a CH3_3 proton versus an aromatic one, for example).
  • Scalar JJ-coupling. A through-bond interaction between two spins splits each peak into a multiplet. A nucleus with nn equivalent neighbors is split into n+1n+1 lines with binomial intensities, the n+1n+1 rule. The splitting pattern reveals connectivity (which nuclei are bonded), and the spacing is the coupling constant JJ.

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 ≫\gg couplings), the XXXX and YYYY parts of the coupling average out, leaving only ZZZZ. So the Hamiltonian is (angular-frequency units, ℏ=1\hbar=1):

H=∑iπ νi Zi⏟chemical shift  +  ∑i<jπ2 Jij ZiZj⏟J-coupling.H = \underbrace{\sum_i \pi\,\nu_i\, Z_i}_{\text{chemical shift}} \;+\; \underbrace{\sum_{i<j} \tfrac{\pi}{2}\,J_{ij}\, Z_iZ_j}_{J\text{-coupling}}.

Each nucleus is one qubit: the chemical shift is a single-qubit ZZ rotation, and the coupling is a two-qubit ZZZZ interaction.

The experiment proceeds as follows. A π/2\pi/2 pulse tips every spin into the transverse plane, and the spins precess and couple for time tt. We record the complex transverse magnetization s(t)=⟨X⟩+i⟨Y⟩s(t) = \langle X\rangle + i\langle Y\rangle 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 tt. When we take the Fourier transform of the signal, we get a spectrum with peaks at the chemical shift νi\nu_i split by the scalar coupling JijJ_{ij}.

Note

We simulate the molecule's evolution by mapping the time tt onto rotation angles: a larger tt means larger ZZ and ZZZZ rotations, not a longer or deeper circuit. So while the simulated state does advance with tt (that's what makes the free induction decay (FID) oscillate), the physical qubits never idle any longer and there's no tt-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 T2∗T_2^*. We add it back by multiplying the FID by a window e−t/T2∗e^{-t/T_2^{*}} 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 νi\nu_i and couplings JijJ_{ij}, returns a parameterized state-preparation circuit. The π/2\pi/2 pulse tips the spins to the equator, then the ZZ rotations simulate the evolution of the state under the chemical-shift and JJ-coupling terms. Each νi\nu_i 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 RZR_Z gates, one per spin, and each νi\nu_i fixes where that spin's peak sits in the spectrum. The two-qubit ZZZZ gate applies the coupling JijJ_{ij}, 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 ⟨X⟩\langle X\rangle and ⟨Y⟩\langle Y\rangle at each. We combine these into the complex FID ⟨X⟩+i⟨Y⟩\langle X\rangle + i\langle Y\rangle, 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)}),
)

Output:

Output of the previous code cell
Expected AX lines (Hz): [-7.0, -5.0, 5.0, 7.0]

Extension: An AX2_2 system and the n+1n+1 rule

Now add a second equivalent neighbor. In an AX2_2 system one A spin couples, with the same JJ, to two equivalent X spins. (Remember, X is just a label for the two equivalent spins; it has nothing to do with the Pauli XX operators. Every coupling below is still ZZZZ.) Spin A then sees four neighbor configurations (↑↑, ↑↓, ↓↑, ↓↓)(\uparrow\uparrow,\ \uparrow\downarrow,\ \downarrow\uparrow,\ \downarrow\downarrow); the two mixed ones coincide, so A is split into a 1:2:1 triplet (n=2⇒n+1=3n=2 \Rightarrow n+1 = 3 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 (Cl2_2CH–CH2_2Cl). 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 CH2_2 protons are the two X spins. Now just count neighbors: the CH proton has two neighbors (the CH2_2 pair), so its signal splits into a 1:2:1 triplet (n=2n=2); each CH2_2 proton has only one neighbor (the CH), so the CH2_2 signal is a 1:1 doublet (n=1n=1). 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.

1,1,2-trichloroethane: the CH proton is A, the two equivalent CH_2 protons are X

Below, we simulate an AX2_2 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 JJ? If you moved to a spectrometer with twice the magnetic field, which one would change?

  • The 12 Hz center-to-center separation is the chemical-shift difference (ν1−ν2\nu_1-\nu_2); the 2 Hz line spacing is the coupling constant JJ. Chemical shifts come from shielding of B0B_0, so they scale with the field. At twice the field the separation doubles to 24 Hz. The coupling JJ 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 n+1n+1 rule, predict the A multiplet if a third equivalent X spin were added (an AX3_3 system), and explain why the two equivalent X spins in AX2_2 produce no splitting of each other.

  • With n=3n=3 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, T1T_1, T2T_2, and T2∗T_2^*, 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 ZZ rotation; what a chemist calls a JJ-coupling, a quantum engineer calls a ZZZZ 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 ∣0⟩/∣1⟩|0\rangle/|1\rangle, frequency, gates, T1T_1, and T2T_2 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 P1=sin⁡2(θ/2)P_1=\sin^2(\theta/2) identifies π\pi and π/2\pi/2 rotations.
  • T1T_1 is energy (spin-lattice) relaxation; T2T_2 is phase (spin-spin) relaxation; T2∗T_2^{*} 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 JJ-coupling splits peaks into multiplets following the n+1n+1 rule, which together encode chemical environment and connectivity.

True / false

  1. T/F The Larmor frequency is the energy gap between the two spin states divided by ℏ\hbar.
  2. T/F A 90∘90^\circ (π/2\pi/2) pulse fully inverts an equilibrium spin.
  3. T/F T2∗T_2^{*} is generally longer than T2T_2.
  4. T/F The Hahn echo refocuses irreversible spin-spin dephasing.
  5. T/F In an AX2_2 system the A nucleus appears as a 1:2:1 triplet.
  6. T/F The coupling constant JJ doubles when you double the spectrometer's magnetic field.
  7. T/F Removing Qiskit Pulse makes it impossible to measure T1T_1 on current hardware.

Short answer

  1. Explain in two sentences why a single π\pi pulse is a poor way to detect a 5%5\% flip-angle error, and how chaining pulses fixes this.
  2. A sample shows broad NMR lines. In terms of T2T_2, what does that tell you, and what qubit quantity is the analog?
  3. Give the qubit analog of each: (a) the recycle delay, (b) the 90∘90^\circ pulse, (c) the chemical shift.
  4. Using the n+1n+1 rule, explain why an ethyl group (CH3_3CH2_2–) shows a 1:2:1 triplet and a 1:3:3:1 quartet, and identify which group produces which multiplet.

Challenge problems

  1. Carr-Purcell train. Modify the Hahn echo to insert nn equally spaced π\pi pulses (a CPMG-style train) and measure T2T_2 as a function of nn. Discuss why more refocusing pulses can lengthen the apparent T2T_2.
  2. Pulse-error amplification. Build a circuit that applies RX(π)R_X(\pi)  n\,n times to ∣0⟩|0\rangle and plot P1P_1 vs nn for a deliberately mis-set angle RX(1.05π)R_X(1.05\pi). Show how the error becomes visible as nn grows. Hint: put a barrier after every rotation; otherwise the transpiler may combine the repeated rotations into a single equivalent gate.
  3. Ethyl group. Using fid_circuit, build the proton spectrum of an ethyl fragment (CH3_3–CH2_2): five qubits, with the three CH3_3 protons sharing one chemical shift and the two CH2_2 protons another, and every CH3_3–CH2_2 pair coupled by the same JJ (no coupling within an equivalent group). Predict the pattern with the n+1n+1 rule, then simulate it; you should recover a 1:3:3:1 quartet (CH2_2) and a 1:2:1 triplet (CH3_3).

Appendix (optional): Trotterized evolution — let decoherence set the linewidth

In the NMR spectroscopy of a molecule section, the evolution e−iHte^{-iHt} is applied in a single layer whose depth does not grow with tt, so the FID does not decay on its own and we impose the linewidth with the e−t/T2e^{-t/T_2} apodization window. This optional cell takes the opposite approach. It breaks the evolution into pp Trotter steps and puts a barrier after each step (so the transpiler cannot merge them back into one layer). Time point kk uses kk steps, so the circuit depth grows with time.

Because every term in HH 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 T2↔T_2\leftrightarrowlinewidth idea of the T2T_2, T2∗T_2^* experiment section, now arising from real decoherence instead of a processing window. We run both the AX system (two doublets) and the AX2_2 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 ∼ ⁣47\sim\!47 two-qubit gates and the AX2_2 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()
Was this page helpful?
Report a bug, typo, or request content on GitHub.