See also
A Jupyter notebook version of this tutorial can be downloaded here.

Pulsed ODMR for Color Centers#
Introducing Color Centers#
One of the commonly known color centers is Nitrogen-Vacancy (NV) centers in diamond. They are unique quantum systems with applications in quantum computing, sensing, and communications. In this notebook, we are going to use the qblox hardware to tune-up NV center qubit.
To use a NV-center as a quantum device, it must first be tuned and characterized. The process depends on the specific color center and involves sequential experiments, where each step builds on the results of the previous one. The workflow for tuning a color center is illustrated in the accompanying figure.

In this diagram, arrows indicate dependencies, showing that experiments are typically conducted from left to right.
One key experiment in characterizing color centers is Optically Detected Magnetic Resonance (ODMR), which is analogous to qubit spectroscopy. This notebook demonstrate two crucial methods, Pulsed ODMR and Pulsed ODMR with power, to measure the energy levels of an NV center.
[1]:
from __future__ import annotations # noqa: I001
from typing import Any
from dependencies.data_analysis import odmr_curve_fit
import pickle
from qblox_scheduler import HardwareAgent, Schedule
from qblox_scheduler.operations.expressions import DType
from qblox_scheduler.operations.loop_domains import linspace
from qblox_scheduler.operations import Measure, SetClockFrequency, SquarePulse
from qblox_scheduler.enums import BinMode
import json
/.venv/lib/python3.14/site-packages/quantify_core/utilities/general.py:13: QCoDeSDeprecationWarning: The `qcodes.utils.helpers` module is deprecated. Please consult the api documentation at https://microsoft.github.io/Qcodes/api/index.html for alternatives.
from qcodes.utils.helpers import NumpyJSONEncoder
Initializing the HardwareAgent#
Here we initialize our HardwareAgent using our cluster (Qblox hardware) and quantum device (qubit) parameters - see this start-up guide for NV centers and the Qblox Scheduler Hello World for further details.
In short, the Hardware Configuration file contains the information specific to Qblox hardware, while the Device Configuration file (QuantumDevice) contains device elements (BasicElectronicNVElement), where we save the various qubit parameters. Here we are loading a template for 1 qubit.
[2]:
## Set to True if not using real cluster
using_dummy: bool = True
## Set to True if using QTM for counting photons, if using QRM set to False
using_qtm: bool = True
# Device Config
device_path = "dependencies/configs/nv_center1q.yaml"
# Hardware config — single base file, patched per setup
with open("dependencies/configs/nv_center_hw.json") as f:
hw_config: dict[str, Any] = json.load(f)
match (using_dummy, using_qtm):
case (True, _):
pass # Base config is ready for dummy use
case (False, True):
hw_config["hardware_description"]["cluster0"]["ip"] = "10.10.200.42"
del hw_config["hardware_description"]["cluster0"]["modules"]
# Adjust QTM input to match physical wiring
hw_config["connectivity"]["graph"] = [
["cluster0.module10.digital_input_5", "qe0:optical_readout"]
if edge[1] == "qe0:optical_readout"
else edge
for edge in hw_config["connectivity"]["graph"]
]
case (False, False):
hw_config["hardware_description"]["cluster0"]["ip"] = "10.10.200.42"
del hw_config["hardware_description"]["cluster0"]["modules"]
# QRM handles optical readout instead of QTM
hw_config["connectivity"]["graph"] = [
["cluster0.module4.real_input_0", "qe0:optical_readout"]
if edge[1] == "qe0:optical_readout"
else edge
for edge in hw_config["connectivity"]["graph"]
]
hw_config["hardware_options"]["sequencer_options"] = {
"qe0:optical_readout-qe0.ge0": {"ttl_acq_threshold": 0.5}
}
hw_config["hardware_options"]["digitization_thresholds"] = {
"qtm0:in-digital": {"analog_threshold": 0.8},
"qtm1:in-digital": {"analog_threshold": 0.8},
}
[3]:
# Create a new hardware agent based on the configuration files discussed above
hw_agent = HardwareAgent(hardware_configuration=hw_config, quantum_device_configuration=device_path)
hw_agent.connect_clusters()
# Create an alias for a specific element of the DUT to conveniently interact with its properties
qubit = hw_agent.quantum_device.get_element("qe0")
/.venv/lib/python3.14/site-packages/qblox_scheduler/qblox/hardware_agent.py:499: UserWarning: cluster0: Trying to instantiate cluster with ip 'None'.Creating a dummy cluster.
warnings.warn(
Pulsed ODMR Schedule#
Pulsed Optically Detected Magnetic Resonance (ODMR) probes the \(|0\rangle\)-\(|1\rangle\) qubit transition by sweeping a microwave frequency using the QCM-RF II, which drives the transition when it matches the resonance frequency. The readout process involves activating the laser with the QCM to excite the color center, causing it to fluoresce, and detecting emitted photons with the QRM/QTM.
For NV centers:
The \(|0\rangle\) state is the bright state, producing photon emissions when excited.
The \(|1\rangle\) state is the dark state, resulting in negligible photon emissions.
The Pulsed ODMR schedule consists for following steps:
Initialization
The readout laser is turned on for a fixed duration to initialize the qubit to \(|0\rangle\), by sending a
SquarePulseto the optical control port with clockge0.
Microwave Frequency Sweep
The microwave frequency is set using
SetClockFrequencyat the qubit’s microwave port.A
SquarePulse/SkewedHermitePulseapplies the microwave tone, sweeping through frequencies to probe the \(|0\rangle\)-\(|1\rangle\) transition.
Readout
The
Measuregate, or (for pulse level) aSquarePulsefollowed byTriggerCountoperation, can be used:Activates the readout laser (clock
ge0) for a set duration, followed by an acquisition with a time-of-flight delay.Accumulates photon counts, grouping results as per the specified acquisition coordinates (frequency), across multiple repetitions.
Generate a range of frequencies with the defined steps, for the Pulsed ODMR spectroscopy.
[4]:
# Frequency settings
frequency_center = qubit.clock_freqs.spec # Hz
frequency_width = 200e6 # Hz
frequency_npoints = 100
repetitions = 400
## In this schedule, we want to mix some pulse-level and gate-level operations.
## For this, we fetch the qubit parameters from the central data structure containing all the qubit
## information, the Device Config file, initialized as `qubit` above.
# Define control parameters for readout laser
clock_ro_laser = f"{qubit.name}.ge0"
port_ro_laser = qubit.ports.optical_control
amp_ro_laser = qubit.measure.pulse_amplitude
duration_ro_laser = qubit.measure.pulse_duration
# Acquisition parameters - only needed if you want to do a pulse-level measure
clock_meas = f"{qubit.name}.ge0"
port_meas = qubit.ports.optical_readout
duration_meas = qubit.measure.acq_duration
# Drive pulse parameters
clock_drive = f"{qubit.name}.spec"
port_drive = f"{qubit.name}:mw"
amp_drive = qubit.rxy.amp180
duration_drive = qubit.rxy.duration
[5]:
# Initialize the schedule with the specified number of repetitions
sched = Schedule("Pulsed ODMR")
## Add an initial optical reset pulse
sched.add(
SquarePulse(
amp=amp_ro_laser, duration=duration_ro_laser, port=port_ro_laser, clock=clock_ro_laser
)
)
## Creating a sweep for repetitions and to scan frequency
with (
sched.loop(linspace(0, repetitions, repetitions, DType.NUMBER)),
sched.loop(
linspace(
start=frequency_center - frequency_width / 2,
stop=frequency_center + frequency_width / 2,
num=frequency_npoints,
dtype=DType.FREQUENCY,
)
) as freq,
):
## Microwave drive pulse
### Sweeping through the 0-1 transition frequencies
sched.add(SetClockFrequency(clock=clock_drive, clock_freq_new=freq))
## Choose Square or Hermite Pulse for driving
drive_pulse = sched.add(
SquarePulse(amp=amp_drive, duration=duration_drive, port=port_drive, clock=clock_drive)
)
# drive_pulse = sched.add(SkewedHermitePulse(duration=duration_drive, amplitude=amp_drive, skewness=0, phase=0, port=port_drive, clock=clock_drive))
## Measure
### Pulse level
# meas_start = sched.add(SquarePulse(amp=amp_ro_laser, duration=duration_ro_laser, port=port_ro_laser, clock=clock_ro_laser),
# ref_op = drive_pulse,
# rel_time=0,
# ref_pt="end")
# sched.add(TriggerCount(port = port_meas, clock=clock_meas, duration=duration_meas, acq_channel="counts", coords={"freq": freq}),
# rel_time=qubit.measure.acq_delay,
# ref_op=meas_start,
# ref_pt="start")
### Gate level - using all the parameters from measure in device config, and clock ge0
sched.add(
Measure(
qubit.name,
coords={"frequency": freq},
acq_protocol="TriggerCount",
acq_channel="counts",
bin_mode=BinMode.AVERAGE_APPEND,
)
)
# Execute the experiment
odmr_dataset = hw_agent.run(sched)
/tmp/ipykernel_3022/3493911514.py:7: FutureWarning: amp is deprecated as an argument to SquarePulse and will be removed in qblox-scheduler >= 2.0; use amplitude instead.
SquarePulse(
/tmp/ipykernel_3022/3493911514.py:27: FutureWarning: clock_freq_new is deprecated as an argument to SetClockFrequency and will be removed in qblox-scheduler >= 2.0; use frequency instead.
sched.add(SetClockFrequency(clock=clock_drive, clock_freq_new=freq))
/tmp/ipykernel_3022/3493911514.py:30: FutureWarning: amp is deprecated as an argument to SquarePulse and will be removed in qblox-scheduler >= 2.0; use amplitude instead.
SquarePulse(amp=amp_drive, duration=duration_drive, port=port_drive, clock=clock_drive)
Let us observe the dataset
[6]:
odmr_dataset
[6]:
<xarray.Dataset> Size: 3kB
Dimensions: (acq_index_counts: 100)
Coordinates:
* acq_index_counts (acq_index_counts) int64 800B 0 1 2 3 ... 97 98 99
loop_repetition_counts (acq_index_counts) float64 800B 0.0 1.0 ... 99.0
frequency (acq_index_counts) float64 800B 2.78e+09 ... 2.98...
Data variables:
counts (acq_index_counts) float64 800B -1.0 -1.0 ... -1.0
Attributes:
tuid: 20260415-120425-121-4f83fd[7]:
comp_sched = hw_agent.compile(sched)
Let us view the compiled Q1ASM instructions
[8]:
# hw_agent.latest_compiled_schedule.compiled_instructions
Looking at the Pulse Diagram
[9]:
# comp_sched.plot_pulse_diagram(plot_backend="plotly")
Analysis#
Once we have measured the pulsed ODMR, the acquired data can be fitted to find the qubit transition frequency.
Here we analyze the experiment data taken for NV center at Fraunhofer IAF, Freiburg Germany with our modules.
[10]:
cl = hw_agent.get_clusters()
# If using a dummy cluster, load the dataset from the example data
if list(cl.items())[0][1].is_dummy:
with open("dependencies/pulsed_odmr_nv_center_qubit/pulsedodmr_one_amp_.pkl", "rb") as file:
odmr_dataset = pickle.load(file)
transition_freq = odmr_curve_fit(odmr_dataset)
Transition Frequency: 2.87220 GHz with Contrast: 9.32%
[11]:
# Update the qubit frequency
qubit.clock_freqs.spec = transition_freq * 1e9
Pulsed ODMR versus Power Schedule#
To resolve the hyperfine structure of the qubit energy levels, pulsed ODMR is often performed across varying power levels. Additionally, after identifying the qubit’s transition frequency, optimizing the microwave power is crucial for better precision.
In Qblox Scheduler, it’s easy to combine frequency and power sweeps within a single schedule. It only requires to add a few minor modifications to the existing Pulsed ODMR schedule.
In the newly defined schedule, the amplitudes to sweep over are provided via the spec_amps parameter. In the loop, for each frequency (spec_freq), the amplitude of the spectroscopy operation is adjusted, enabling efficient frequency and power optimization in one seamless schedule. It’s that simple!
In addition with defining the frequency setpoints, we define the amplitude setpoints as well.
[12]:
# Initialize the schedule with the specified number of repetitions
sched = Schedule("Pulsed ODMR versus Power")
repetitions = 200
frequency_npoints = 100
frequency_width = 50e6
frequency_center = qubit.clock_freqs.spec
# Add an initial optical reset pulse
sched.add(
SquarePulse(
amp=amp_ro_laser, duration=duration_ro_laser, port=port_ro_laser, clock=clock_ro_laser
)
)
with (
sched.loop(linspace(0, repetitions, repetitions, DType.NUMBER)),
sched.loop(
linspace(
start=frequency_center - frequency_width / 2,
stop=frequency_center + frequency_width / 2,
num=frequency_npoints,
dtype=DType.FREQUENCY,
)
) as spec_freq,
sched.loop(linspace(start=0.1, stop=1, num=5, dtype=DType.AMPLITUDE)) as spec_amp,
):
## Microwave drive pulse
sched.add(SetClockFrequency(clock=clock_drive, clock_freq_new=spec_freq))
## Choose Square or Hermite Pulse for drive
drive_pulse = sched.add(
SquarePulse(amp=spec_amp, duration=duration_drive, port=port_drive, clock=clock_drive)
)
# drive_pulse = sched.add(SkewedHermitePulse(duration=duration_drive, amplitude=spec_amp, skewness=0, phase=0, port=port_drive, clock=clock_drive))
## Measure
# meas_start = sched.add(SquarePulse(amp=amp_ro_laser, duration=duration_ro_laser, port=port_ro_laser, clock=clock_ro_laser),
# ref_op = drive_pulse,
# rel_time=0,
# ref_pt="end")
# sched.add(TriggerCount(port = port_meas, clock=clock_meas, duration=duration_meas, acq_channel="counts", coords={"freq": spec_freq, "amp": spec_amp}),
# rel_time=qubit.measure.acq_delay,
# ref_op=meas_start,
# ref_pt="start")
sched.add(
Measure(
qubit.name,
coords={"freq": spec_freq, "amp": spec_amp},
acq_protocol="TriggerCount",
bin_mode=BinMode.AVERAGE_APPEND,
)
)
/tmp/ipykernel_3022/1258936938.py:13: FutureWarning: amp is deprecated as an argument to SquarePulse and will be removed in qblox-scheduler >= 2.0; use amplitude instead.
SquarePulse(
/tmp/ipykernel_3022/1258936938.py:31: FutureWarning: clock_freq_new is deprecated as an argument to SetClockFrequency and will be removed in qblox-scheduler >= 2.0; use frequency instead.
sched.add(SetClockFrequency(clock=clock_drive, clock_freq_new=spec_freq))
/tmp/ipykernel_3022/1258936938.py:34: FutureWarning: amp is deprecated as an argument to SquarePulse and will be removed in qblox-scheduler >= 2.0; use amplitude instead.
SquarePulse(amp=spec_amp, duration=duration_drive, port=port_drive, clock=clock_drive)
[13]:
# # Execute the experiment
power_odmr_dataset = hw_agent.run(sched)
[14]:
# Once again seeing the dataset
power_odmr_dataset
[14]:
<xarray.Dataset> Size: 20kB
Dimensions: (acq_index_0: 500)
Coordinates:
* acq_index_0 (acq_index_0) int64 4kB 0 1 2 3 4 ... 495 496 497 498 499
loop_repetition_0 (acq_index_0) float64 4kB 0.0 1.0 2.0 ... 498.0 499.0
amp (acq_index_0) float64 4kB 0.1 0.325 0.55 ... 0.775 1.0
freq (acq_index_0) float64 4kB 2.847e+09 ... 2.897e+09
Data variables:
0 (acq_index_0) float64 4kB -1.0 -1.0 -1.0 ... -1.0 -1.0
Attributes:
tuid: 20260415-120432-494-3aa206[15]:
hw_agent.compile(sched)
[15]:
CompiledSchedule "Pulsed ODMR versus Power schedule 1" containing (2) 2 (unique) operations.
Checking the compiled instructions!
[16]:
# hw_agent.latest_compiled_schedule.compiled_instructions
Checking the pulse diagram
[17]:
# hw_agent.latest_compiled_schedule.plot_pulse_diagram(plot_backend="plotly")