Quantum Orchestration for

NV & Other Defect Centers

Find out how you can leverage the Quantum Orchestration Platform to perform groundbreaking experiments in your NV Center lab with these real-life use cases.

“Replacing 3 devices with one synchronized, orchestrated machine tremendously simplifies lab workflow. Now, our pulse sequences run in a fraction of the time of any other device combo. Plus, we can talk to the FPGA in human-speak to run real-time calculations that were too complicated before! Along with the yoga-level flexibility of QM’s engineers, the OPX truly is a trailblazer.”

Dr. Amit Finkler, Weizmann Institute of Science

“Dedicated hardware for controlling and operating quantum bits is something we have all been dreaming of. Quantum Machines has answered this call by allowing us and others in the field to scale up with ease and with far greater functionality than was ever possible”

Prof. Amir Yacoby, Harvard University

Dynamical decoupling (XY8-N as an example)


Fig. 1. Experimental setup for dynamical decoupling using the NV center in diamond. The MW control signal is generated using IQ modulation with two analog outputs of the OPX.

NV centers are excellent systems for sensing. Not only are they highly sensitive to various types of forces (magnetic, electric, strain, etc.), but their small size allows for spatial resolutions of a few nm. In this example, we want to demonstrate how one of the most common sensing methods with NV centers, namely dynamical decoupling (DD), can be effortlessly implemented using the Quantum Orchestration Platform (QOP). It’s a great showcase of the real-time paradigm the OPX operates on, which removes the need for creating long arbitrary waveforms before starting the experiment.

Dynamical decoupling is typically used in quantum control to prolong the coherence of the spin system. This is achieved by a periodic sequence of control pulses, which refocus the environmental effects and hence attenuate noise. Since phases accumulated from frequency components close to the pulse spacing are being enhanced, DD effectively acts as a frequency filter. Thus, the technique can be used for noise spectroscopy.

Fig. 1 shows the experimental setup. The OPX is controlling the laser via a digital marker output. Two analog outputs of the OPX are used for IQ modulation of the MW signal controlling the NV spin. The output pulses of the APD, which collects the fluorescence of the NV center, are sent to an analog input of the OPX for time tagging.

In this example, we want to focus on one of the most common DD sequences used for NV-based sensing, namely the XY8-N sequence. The XY8-N sequence consists of the following pulse sequence: \((\pi_x-\pi_y-\pi_x-\pi_y-\pi_y-\pi_x-\pi_y-\pi_x)^N\), where \(N\) is the so-called XY8 order and the indices \(x\) and \(y\) correspond to the rotation axis in the rotating frame. The pulses are spaced equidistantly with a spacing of \(\tau\). The entire sequence is shown in Fig 2a.

The XY8 sequence is applied after the NV electron spin is brought into a superposition state \(|-1> + |0>\) by an initial \((\pi/2)_x\)-pulse. After the decoupling sequence, the spin state is mapped onto the spin population by a final \((\pi/2)_x\)-pulse (see Fig 2a). Finally, the NV center is read out optically by a laser pulse, which simultaneously repolarizes the electron spin state. Fig 2b shows the filter function of the sequence. The central frequency \(\nu=1/2\tau\) is defined by the periodicity of the pulses, while the width \(\Delta\nu=1/N\tau\) depends on the total acquisition time \(T=N\tau\).


Fig. 2a. XY8-N sequence with waiting time \(\tau.\)

Fig. 2b. Filter function of the XY8-N sequence. The central frequency is determined by the waiting time \(\tau,\) while the width is defined by the total acquisition time \(T=N\tau.\)

The example QUA program runs a for_each loop, which iterates through a given list of \(\tau\) values. Additionally, an outer for loop averages over many sweeps. In QUA, we can define macros that make code shorter and clearer. In this example, we use the macro xy8_n(n) to create all the XY8 sequence pulses in a single line. The macro dynamically creates the XY8 sequence according to the order specified in parameter n. The macro creates all pulses and wait times by looping over another helper macro, xy8_block(), which creates the 8 pulses of a single XY8 block. The \(\pi_y\) pulses are generated by rotating the frame of the spin using the built-in frame_rotation() function. Then, the frame is reset back into its initial state by calling reset_frame().

from qm.QuantumMachinesManager import QuantumMachinesManager
from qm.qua import *
from qm import SimulationConfig
import matplotlib.pyplot as plt
import numpy as np


NV_IF = 100e6
t_min = 4
t_max = 100
dt = 1
t_vec = np.arange(t_min, t_max, dt)

repsN = 3
simulate = True


with program() as xy8:
    # Realtime FPGA variables
    a = declare(int)  # For averages
    i = declare(int)  # For XY8-N
    t = declare(int)  # For tau
    times = declare(int, size=100)  # Time-Tagging
    counts = declare(int)  # Counts
    counts_ref = declare(int)
    diff = declare(int)  # Diff in counts between counts & counts_ref
    counts_st = declare_stream()  # Streams for server processing
    counts_ref_st = declare_stream()
    diff_st = declare_stream()

    with for_(a, 0, a < 1e6, a + 1):
        play("laser", "qubit")

        with for_(t, t_min, t <= t_max, t+dt):  # Implicit Align
            # Play meas (pi/2 pulse at x)
            play("pi_half", "qubit")
            xy8_n(repsN)
            play("pi_half", "qubit")
            measure("readout", "qubit", None, time_tagging.raw(times, 300, counts))
            # Time tagging done here, in real time

            # Plays ref (pi/2 pulse at -x)
            play("pi_half", "qubit")
            xy8_n(repsN)
            frame_rotation(np.pi, "qubit")
            play("pi_half", "qubit")
            reset_frame('qubit')  # Such that next tau would start in x.
            measure("readout", "qubit", None, time_tagging.raw(times, 300, counts_ref))
            # Time tagging done here, in real time

            # save counts:
            assign(diff, counts - counts_ref)
            save(counts, counts_st)
            save(counts_ref, counts_ref_st)
            save(diff, diff_st)

    with stream_processing():
        counts_st.buffer(len(t_vec)).average().save("dd")
        counts_ref_st.buffer(len(t_vec)).average().save("ddref")
        diff_st.buffer(len(t_vec)).average().save("diff")
 

qmm = QuantumMachinesManager()
qm = qmm.open_qm(config)
job = qm.execute(xy8, duration_limit=0, time_limit=0)


def xy8_n(n):
    # Assumes it starts frame at x, if not, reset_frame before
    wait(t, "qubit")

    xy8_block()

    with for_(i, 0, i < n - 1, i + 1):
        wait(2 * t, "qubit")
        xy8_block()

    wait(t, "qubit")


def xy8_block():
    play("pi", "qubit")  # 1 X
    wait(2 * t, "qubit")

    frame_rotation(np.pi / 2, "qubit")
    play("pi", "qubit")  # 2 Y
    wait(2 * t, "qubit")

    reset_frame("qubit")
    play("pi", "qubit")  # 3 X
    wait(2 * t, "qubit")

    frame_rotation(np.pi / 2, "qubit")
    play("pi", "qubit")  # 4 Y
    wait(2 * t, "qubit")

    play("pi", "qubit")  # 5 Y
    wait(2 * t, "qubit")

    reset_frame("qubit")
    play("pi", "qubit")  # 6 X
    wait(2 * t, "qubit")

    frame_rotation(np.pi / 2, "qubit")
    play("pi", "qubit")  # 7 Y
    wait(2 * t, "qubit")

    reset_frame("qubit")
    play("pi", "qubit")  # 8 X






QUA Code.

For the NV centers optical readout, we utilize the built-in time tagging functionality of the QOP. A call of the measure statement starts the time tagging. A time tag for each detected pulse from the APD is saved into the real-time array times, and the total number of detected photons is saved into the integer variable counts. Concurrently, the measure statement generates a readout pulse, which here is the trigger pulse going to the laser system. The same sequence is repeated a second time with a final \((\pi/2)_{-x}\). The photons detected during this are saved in the variable counts_ref and act as a reference signal.

At the end of each loop, we save the photon counts into a so-called stream, using the save() function. These streams allow streaming data to the client PC while the program is still running. The stream processing feature also offers a rich library of data processing functions that can be applied to streams. The QOP server performs the processing before sending it to the client PC. It can significantly reduce the amount of transferred data by limiting it to the user’s preferred result. In this example, we use the average() function to average the data while streaming.

Nanoscale NMR with a Nuclear Spin Memory


Fig. 3. Setup for nanoscale NMR using a nuclear spin memory. The MW control sequence for the electron spin is created using IQ modulation with two analog outputs of the OPX (blue). The RF signal for nuclear spin manipulation is directly synthesized with the OPX (orange). The pulses of the APD are time-tagged by the OPX via one of the analog inputs (red).

With the QOP and QUA, we can write even the most complex experiments as short and clear single programs. To demonstrate this, let’s look at NV-based NMR experiment that utilizes a nuclear spin as an additional memory [S. Zaiser et al., Enhancing quantum sensing sensitivity by a quantum memory, Nature Comm. 7, 12279 (2016); M. Pfender et al., Nonvolatile nuclear spin memory enables sensor-unlimited nanoscale spectroscopy of small spin clusters, Nature Comm. 8, 834 (2017)]. It is possible to drastically enhance the spectral resolution by using the long lifetime of the nuclear spin as a resource. This technique allows, e.g., nanoscale NMR with chemical contrast [N. Aslam et al., Nanoscale nuclear magnetic resonance with chemical resolution, Science 357, 67-71 (2017)].

NMR using NV centers is typically based on imprinting the Larmor precession of sample spins into the phase of a superposition state of the NV electron spin [T. Staudacher et al., Nuclear Magnetic Resonance Spectroscopy on a (5-Nanometer)^3 Sample Volume, Science 339, 561-563 (2013)]. We can achieve this, for example, by using Ramsey spectroscopy, Hahn echo sequences or dynamical decoupling. The spectral resolution of these methods is limited by the duration of the phase accumulation period, and consequently, limited by the coherence time \(T_2^{sens}\) of the sensor spin.

It’s possible to overcome this limitation by performing correlation spectroscopy [A. Laraoui et al., High-resolution correlation spectroscopy of 13C spins near a nitrogen-vacancy centre in diamond, Nature Comm. 4, 1651 (2013)]. Here, the signal is generated by correlating the results of two subsequent phase accumulation sequences separated by the correlation time \(T_C\). During the correlation time, the phase information is stored (partially) in the polarization of the sensor spin. Hence, the possible correlation time, and therefore the spectral resolution, is limited by the spin-relaxation time \(T_1^{sens}\) (\(>T_2^{sens}\)) of the sensor.

It’s possible to improve this even further by utilizing a memory spin, which has a much longer longitudinal lifetime. In the correlation spectroscopy experiment we discuss here, the information is stored on the nuclear spin (memory) instead of on the NV electron spin (sensor). As a result, the achievable correlation time is significantly increased. The intrinsic nitrogen nuclear spin of the NV center is a perfect candidate to act as this memory spin. It is strongly coupled to the NV center electron spin, which acts as the sensor, while its coupling to other electron or nuclear spins is negligible. When applying a strong bias magnetic field (3T) aligned along the NV-axis, we can achieve memory lifetimes \(T_1^{mem}\) on the order of several seconds. In this example, we assume that the used NV center incorporates a 15N nuclei with a 1/2-spin and the eigenstates \(|+_n>\) and \(|-_n>\).


Fig. 4. A sequence of NMR with memory spin. It consists of active initialization of the memory spin, encoding, sample manipulation, decoding, and a final readout of the memory spin via single-shot readout.

Fig 4 shows the complete sequence. It consists of five steps: initialization, encoding, sample manipulation, decoding, and readout. The encoding aims to encode the sample-sensor interaction into the spin population of the memory spin. First, the memory spin is brought from its initial state \(|+_n>\) into a superposition state by a \(\pi/2\)-pulse. Next, entanglement between sensor and memory is established for two phase-accumulation windows. The entanglement is created and destroyed by nuclear spin state selective \(\pi\)-pulses performed on the sensor spin. While the sensor and memory spins are entangled, the interaction with the sample spins leads to a phase accumulation on the memory spin superposition state. In between the two phase-accumulation periods, the sample is actively flipped by a resonant \(\pi\)-pulse. The final phase \(\Delta\phi=\phi_2 – \phi_1\), where \(\phi_1\) and \(\phi_2\) are the accumulated phases during the first and second accumulation window respectively, is then mapped into the memory spin population by a final \(\pi/2\)-pulse on the memory spin. The decoding sequence is identical, except for the conditions
of the C\(_n\)NOT\(_e\) gates on the sensor spin.

A good way to improve the readability of experiments written in QUA is to use macros. These are Python functions that can be used to organize QUA code. Let’s first start with the most basic building block:
the C\(_n\)NOT\(_e\) gate on the electron spin. This gate inverts the electron spin only for a specific nuclear spin state. This is achieved by choosing the correct frequency in the hyperfine resolved ODMR spectra of the NV center. In the QUA script below, we define this gate operation in function CnNOTe(). It accepts the nuclear spin state for which the electron spin should be flipped as an integer (-1: \(|-_n>\), +1: \(|+_n>\)). It calculates the corresponding new intermediate frequency according to the given nuclear spin state, by adding or subtracting half of the hyperfine splitting value (hf_splitting ≈ 3 MHz) from the central frequency \(f_0\) of the NV centers hyperfine spectra. We use the built-in QUA function update_frequency() to update the frequency played to the sensor spin. As a result, all following pulses played to this qubit will be at this new frequency. Finally, we play a \(\pi\)-pulse to the sensor spin using the play-command.

from qm.qua import *
from qm.QuantumMachinesManager import QuantumMachinesManager

all_elements = ['sensor', 'sample', 'memory']
N_avg = 1e6
N_SSR = 5000
f0 = 100e6
hf_splitting = 3.0e6
threshold_readout = 200
t_e = 2000
tau_vec = [int(i) for i in np.arange(1e3, 1e6, 10e3)]


with program() as prog:
    """
    Main script
    """
    n = declare(int)
    tau = declare(int)
    result_vec = declare(int, size=len(tau_vec))
    c = declare(int)

    with for_(n, 0, n < N_avg, n + 1):
        assign(c, 0)
        with for_each_(tau, tau_vec):
            init_memory(1)
            encode(t_e)
            align(*all_elements)
            play('pi_2', 'sample')
            wait(tau, 'sample')
            play('pi_2', 'sample')
            align(*all_elements)
            play('laser', 'sensor')
            decode(t_e)
            SSR(N_SSR, result_vec[c])
            assign(c, c + 1)

    with for_(n, 0, result_vec.length(), n + 1):
        save(result_vec[n], 'result')


def init_memory(target_state):
    """
    Initialize the memory into the target_state (element of [-1, 1])
    """
    state = declare(int)
    SSR(N_SSR, state)
    with while_(state == ~target_state):
        play('pi_x', 'memory')
        SSR(N_SSR, state)


def SSR(N, result):
    """
    Determine the state of the nuclear spin
    """
    i = declare(int)
    res_length = declare(int, value=10)
    res_vec = declare(int, size=10)
    ssr_count = declare(int)
    assign(n, 0)

    assign(ssr_count, 0)
    with for_(i, 0, i < N, i + 1):
        wait(100, 'sensor')
        update_frequency('sensor', f0 - hf_splitting / 2)
        play('pi_x', 'sensor')
        measure('readout', 'sensor', None,
                time_tagging.raw(res_vec, 300, targetLen=res_length))
        assign(ssr_count, ssr_count - res_length)
        wait(100, 'sensor')
        update_frequency('sensor', f0 + hf_splitting / 2)
        play('pi_x', 'sensor')
        measure('readout', 'sensor', None,
                time_tagging.raw(res_vec, 50, targetLen=res_length))
        assign(ssr_count, ssr_count + res_length)

    with if_(ssr_count > 0):
        assign(result, 1)
    with else_():
        assign(result, -1)


def CnNOTe(condition_state):
    """
    CNOT-gate on the electron spin. condition_state is element of [-1, 1] and gives the nuclear spin state for which
    the electron spin is flipped.
    """
    align(*all_elements)
    update_frequency('sensor', f0 + condition_state * hf_splitting / 2)
    play('pi_x', 'sensor')
    align(*all_elements)


def encode(t):
    """
    Play the encoding sequence with wait time t.
    """
    align(*all_elements)
    play('pi_2_x', 'memory')
    CnNOTe(1)
    wait(t // 4, 'sensor')
    CnNOTe(1)
    play('pi', 'sample')
    CnNOTe(-1)
    wait(t // 4, 'sensor')
    CnNOTe(1)
    play('pi_2_y', 'memory')
    align(*all_elements)


def decode(t):
    """
    Play the decoding sequence with wait time t.
    """
    align(*all_elements)
    play('pi_2_x', 'memory')
    CnNOTe(-1)
    wait(t // 4, 'sensor')
    CnNOTe(1)
    play('pi', 'sample')
    CnNOTe(-1)
    wait(t // 4, 'sensor')
    CnNOTe(-1)
    play('pi_2_y', 'memory')
    align(*all_elements)


qmm = QuantumMachinesManager()
qm = qmm.open_qm(config)
job = qm.execute(prog)

QUA Code.

The encoding (decoding) sequence is defined in the function encode() (decode()). The different pulses are executed using play-statements and the CnNOTe macro. The QUA-function align() is used to define the timing of the individual pulses. One of the basic principles of QUA is that every command is executed as early as possible. Hence, when not specified otherwise, pulses played on different quantum elements are played in parallel. To ensure that pulses play in a specific order, we use the built-in align() function. The function causes all specified quantum elements to wait until all previous commands are complete, and this way aligns them in time.

Another key part of the experiment is the projective non-demolition readout of the nuclear spin, also known as single-shot readout [P. Neumann et al., Single-Shot Readout of a Single Nuclear Spin, Science 329, 542-544 (2010)]. Fig 4 shows the corresponding pulse sequence in the single-shot readout inset. The memory spin state is determined by comparing the photon count rates of the two hyperfine transitions corresponding nuclear spin states \(|+_n>\) and \(|-_n>\). For this purpose, we play \(\pi\)-pulses on the electron spin alternatingly at the two hyperfine transitions. After each pulse, the electron spin state is read out and repolarized optically by a short laser pulse. The photons are accumulated separately for each transition, and the nuclear spin state is determined by comparing the two individual photon counts. This sequence is repeated N times to accumulate enough photons to achieve a sufficient readout fidelity.

The corresponding QUA code is written in the SSR(N, result) macro. The macro measures the nuclear spin state and saves it into the result variable. The photons are detected with the OPX using the time_tagging module of the measure statement, which at the same time executes the laser pulse. This module allows time tagging of pulses via the analog inputs of the OPX or the digital extension box OPD. The arrival times of all photons detected during the detection window are saved into the real-time array time tags. Additionally, the total number of detected photons is saved into the variable res_length. We use the built-in update_frequency() function to switch between the two hyperfine transitions. The photons associated with the \(|-_n>\) (\(|+_n>\)) transitions are subtracted (added) to the variable ssr_count. As a result, a negative value of ssr_count corresponds to the spin state \(|-_n>\) and a positive value corresponds to \(|+_n>\). Accordingly, either -1 or +1 is saved into the result variable.

One of the key features of the QOP is seamless, fast feedback. This allows the active initialization of the memory spin. In QUA, we implement it with only a few lines of code using a while loop. The corresponding code can be seen in the function init_nuclear_spin(qubit, target_state). The function initializes the spin defined in the qubit parameter into the state defined in target_state. Here, the target spin state is given as an integer with the value -1 (\(|-_n>\)) or +1 (\(|+_n>\)). First, the spin state is determined by using the SSR() macro. If the spin is not in the correct state, \(\pi\)-pulses and subsequent readouts are performed until the target spin state is detected.

Finally, the main script runs the whole sequence for different values of the waiting time of the Ramsey sequence played on the sample spin in a foreach-loop. Additionally, the experiment is averaged over N_avg repetitions by an outer for-loop. The result for each value of \(\tau\) is saved into the corresponding item of the result vector result_vec. Then, the result vector is saved element-wise using the save function and streamed to the user’s PC.

Run the most complex quantum experiments
out of the box

Quantum-error correction
Multi-qubit active-reset
Real-time Bayesian estimation
Qubit tracking
Qubit stabilization
Randomized benchmarking
Weak measurements
Multi-qubit Wigner tomography

Try it yourself with our

Quantum Orchestration Platform

Do you have a challenging, interesting, or complex experiment you wish to run? Our quantum team has a deep understanding of NV Center-based experiments. Shoot us an email, let’s talk about it!

Why Quantum Orchestration?

Increased lab throughput
Control and flexibility
Ease of setup and Scalability
Versatility and complexity
Intuitive quantum programming