We follow up on first tutorial, but with a more concrete quantum system: the nitrogen vacancy (NV) center in diamond. NVs are the most known color centers, due to its extensive research in quantum technologies. Here, however, we will focus on QuaCCAToo’s usage rather than the physics of the NV itself. For more detailed information about NVs, we recommend:
[1] E. V. Levine, et al., Principles and techniques of the quantum diamond microscope, Nanophotonics 8, 1945 (2019).
[2] S. Pezzagna and J. Meijer, Quantum computer based on color centers in diamond, Applied Physics Reviews 8,011308 (2021).
[1]:
import numpy as np
from qutip import fock_dm, tensor, qeye
from quaccatoo import plot_energy_B0, NV, Rabi, Ramsey, PMR, RabiModel, fit_two_lorentz_sym, fit_two_sinc2_sym, Analysis, ExpData
from lmfit import Model
1. Energy Levels
We begin defining the quantum system from the predefined NV
class in quaccatoo. The class has only two mandatory parameters, the nitrogen isotope \(^{14}\text{N}\), \(^{15}\text{N}\) or None (which neglects all nuclear terms) and the magnetic field in mT. Optionally, the user can define a misalignment angle between the NV axis and the magnetic field, alternative magnetic field units, alternative Hamiltonian units, or collapse operators for the pulsed experiments. To visualize the
energy levels, we use the plot_energy
method.
[2]:
qsys_1 = NV(
N=15,
units_B0='mT',
B0 = 38.4,
)
qsys_1.plot_energy()

We observe the three electronic energy levels of the NV center: \(m_s=0,-1,+1\). To be able to see the nuclear levels splitting, we need to specify a smaller energy range in the plot_energy
method.
[3]:
qsys_1.plot_energy(energy_lim=[1790, 1798])

Now, the splitting from the \(^{15}\text{N}\) nuclear spin is observed.
So far we considered \(B_0=38.4\) mT, but if we want to see how these energy levels change as a function of the external magnetic field, we can use the plot_energy_B0
function.
[4]:
# range of B0 values where we want to plot the energy levels
B0_array = np.linspace(0, 200, 100)
plot_energy_B0(
B0 = B0_array,
# here we calculate the Hamiltonian for each B0 value
H0 = [NV(N=15, B0=B, units_B0='mT').H0 for B in B0_array]
)

As expected, the \(m_s=+1\) grows linearly with the field, while the \(m_s=-1\) decreases linearly and \(m_s=0\) level is not affected by the field. After 100 mT, the level anticrossing occurs.
2. Rabi Oscillations of Electronic Spin
Just like in the previous notebook, the first experiment usually conducted is Rabi oscillations. In this case, \(H_1\) and the resonant frequency of the pulse are not as trivial as before. The user can define their own parameters, but the NV class has a standard interaction Hamiltonian
which can be obtained by the MW_H1
method. While the resonant frequencies for the \(m_s=0 \rightarrow -1\) and \(m_s=0 \rightarrow +1\) can be obtained from MW_freqs
attribute, which takes the average between the two nuclear spin states. Again, we will consider a square cosine wave for the pulse.
[5]:
# typical NV Rabi frequency in MHz
w1 = 16.72
rabi_sim_1 = Rabi(
# pulse duration in us
pulse_duration = np.arange(0, 0.15, 3e-3),
# NV center system defined above
system = qsys_1,
# control Hamiltonian
H1 = w1*qsys_1.MW_H1,
# MW frequency for the ms=0 --> ms=-1 state transition
pulse_params = {'f_pulse': qsys_1.MW_freqs[0]}
)
# run the experiment and plot results
rabi_sim_1.run()
rabi_analysis_1 = Analysis(rabi_sim_1)
rabi_analysis_1.plot_results()

If we want to compare with the simulation with experimental data, we can use the ExpData
class to load it from .dat or .txt file and the compare_with
method. In this case, the experimental data was measured for the same NV parameters as defined previously.
[6]:
exp_data = ExpData(file_path='./exp_data_tutorials/Ex02_NV_rabi.dat')
# convert the variable from s to us
exp_data.variable *= 1e6
rabi_analysis_1.compare_with(exp_data)
rabi_analysis_1.plot_comparison()
rabi_analysis_1.pearson
[6]:
LinregressResult(slope=np.float64(2.5895433124843423), intercept=np.float64(-2.0453606037750993), rvalue=np.float64(0.9872724331132969), pvalue=np.float64(5.450830010550885e-40), stderr=np.float64(0.060209767213068484), intercept_stderr=np.float64(0.05996996938639089))

We observe a strong correlation of the simulation and experimental data with an rvalue of 0.987. The standard observable for the NV is the population in the \(m_s=0\) given by the fluorescence. To see the populations of the other two states \(m_s=\pm 1\), we need to redefine the system observables and run again the Rabi experiment.
[7]:
qsys_1.observable = [tensor(fock_dm(3, 0), qeye(2)), tensor(fock_dm(3, 2), qeye(2))]
# run the experiment
rabi_sim_1.run()
Analysis(rabi_sim_1).plot_results()

Note that population of the \(m_s=+1\) remains 0, as we are using the \(m_s=0 \rightarrow -1\) resonant frequency, while the \(m_s=-1\) population oscillates with the same frequency as the \(m_s=0\) population, but with opposite sign. If we used the other resonant frequency, the \(m_s=+1\) population would oscillate but \(m_s=-1\) would remain 0.
[8]:
rabi_sim_2 = Rabi(
pulse_duration = np.linspace(0, 3/w1, 1000),
system = qsys_1,
H1 = w1*qsys_1.MW_H1,
# MW frequency for the ms=0 --> ms=+1 state transition
pulse_params = {'f_pulse': qsys_1.MW_freqs[1]}
)
rabi_sim_2.run()
rabi_analysis_2 = Analysis(rabi_sim_2)
rabi_analysis_2.plot_results()

Finally, by running a fit of the observable we can extract the \(\pi\)-pulse duration.
[9]:
rabi_analysis_2.run_fit(
fit_model = RabiModel(),
# since there are two observables, we need to specify which one we want to fit
results_index = 0
)
rabi_analysis_2.plot_fit()
rabi_analysis_2.fit_params[0]
[9]:
Fit Result
Model: Model(fit_rabi)
fitting method | leastsq |
# function evals | 26 |
# data points | 1000 |
# variables | 4 |
chi-square | 3.3365e-04 |
reduced chi-square | 3.3499e-07 |
Akaike info crit. | -14905.1798 |
Bayesian info crit. | -14885.5488 |
R-squared | 0.99999731 |
name | value | standard error | relative error | initial value | min | max | vary |
---|---|---|---|---|---|---|---|
amp | 0.49650660 | 2.5932e-05 | (0.01%) | 0.49757541936557315 | -inf | inf | True |
Tpi | 0.02978212 | 3.0015e-07 | (0.00%) | 0.02993424046055625 | -inf | inf | True |
phi | 3.14178728 | 1.0842e-04 | (0.00%) | 3.4271919857343196 | -inf | inf | True |
offset | 0.49592288 | 1.8962e-05 | (0.00%) | 0.4934025679635617 | -inf | inf | True |
Parameter1 | Parameter 2 | Correlation |
---|---|---|
Tpi | phi | +0.8761 |
Tpi | offset | -0.2614 |
phi | offset | -0.2291 |

As expected the \(\pi\)-pulse duration is very close to \(1/(2\omega_1)\).
3. Ramsey Experiment
A simple, but important, pulse sequence in quantum sensing is the Ramsey experiment. It measures the accumulated phase difference in the two levels, by applying a \(\pi/2\) pulse, waiting for a time \(\tau\) and applying another \(\pi/2\) pulse, then measuring the population in the \(m_s=0\) state.
[10]:
# NV center system same as before with standard observables
qsys_2 = NV(
N=15,
B0 = 40,
units_B0='mT'
)
# Rabi frequency in MHz
w1 = 30
ramsey_sim = Ramsey(
# free evolution time in us
free_duration = np.linspace(1/2/w1, 3, 500),
system = qsys_2,
pi_pulse_duration = 1/2/w1,
H1 = w1*qsys_2.MW_H1,
pulse_params = {'f_pulse': qsys_2.MW_freqs[0]}
)
ramsey_sim.plot_pulses(tau=.03)

[11]:
ramsey_sim.run()
Analysis(ramsey_sim).plot_results()

We could fit the results again with a period oscillation, but to illustrate a different method we will use the FFT from quaccatoo. First, the FFT is run with run_FFT
method, then the peaks are found with get_peaks_FFT
.
[12]:
ramsey_analysis = Analysis(ramsey_sim)
ramsey_analysis.run_FFT()
ramsey_analysis.get_peaks_FFT()
ramsey_analysis.plot_FFT(freq_lim=[0,5])

The frequencies are too spaced in the FFT due to the short range of \(\tau\), but we can see the main peak corresponds to approximately half of the hyperfine splitting of the nuclear spin \(a_{||}/2=1.515\) MHz, as expected.
4. Pulsed Optically Detected Magnetic Resonance (pODMR)
To further study the energy levels of the system, we simulate a pODMR experiment. A pODMR experiment is simply composed by a pulse of varying frequency, thus when it corresponds to a resonance, a change in the observable is expected.
[13]:
podmr_sim_1 = PMR(
# frequencies to scan in MHz
frequencies = np.arange(1400, 4300, 10),
system = qsys_2,
pulse_duration = 1/2/w1,
H1 = w1*qsys_2.MW_H1
)
podmr_sim_1.run()
Analysis(podmr_sim_1).plot_results()

Two resonances can be observed, corresponding to the two electronic spin transitions and with a splitting given by the Zeeman interaction with the external magnetic field. To see the nuclear spin splitting, we need to reduce the Rabi frequency to have a better spectral resolution.
[14]:
w1 = 0.3
podmr_sim_2 = PMR(
frequencies = np.arange(1745, 1753, 0.05),
system = qsys_2,
pulse_duration = 1/2/w1,
H1 = w1*qsys_2.MW_H1,
)
podmr_sim_2.run()
Analysis(podmr_sim_2).plot_results()

The two resonances now correspond to the two nuclear spin transitions. To get the splitting, we can fit them with Lorentzian Functions.
[15]:
podmr_analysis_1 = Analysis(podmr_sim_2)
podmr_analysis_1.run_fit(
fit_model = Model(fit_two_lorentz_sym),
# initial guess for the fit parameters
guess = {'A': 0.5, 'gamma': 0.2, 'f_mean':1749, 'f_delta':3,'C':1},
)
podmr_analysis_1.plot_fit(xlabel='Frequency')
podmr_analysis_1.fit_params
[15]:
Fit Result
Model: Model(fit_two_lorentz_sym)
fitting method | leastsq |
# function evals | 24 |
# data points | 160 |
# variables | 5 |
chi-square | 0.11917557 |
reduced chi-square | 7.6887e-04 |
Akaike info crit. | -1142.37301 |
Bayesian info crit. | -1126.99714 |
R-squared | 0.95699504 |
name | value | standard error | relative error | initial value | min | max | vary |
---|---|---|---|---|---|---|---|
A | 0.53978264 | 0.01113652 | (2.06%) | 0.5 | -inf | inf | True |
gamma | 0.19906076 | 0.00681328 | (3.42%) | 0.2 | -inf | inf | True |
f_mean | 1749.00957 | 0.00410519 | (0.00%) | 1749 | -inf | inf | True |
f_delta | 3.02851436 | 0.00817499 | (0.27%) | 3 | -inf | inf | True |
C | 1.00473395 | 0.00311813 | (0.31%) | 1 | -inf | inf | True |
Parameter1 | Parameter 2 | Correlation |
---|---|---|
A | gamma | -0.5631 |
gamma | C | +0.5552 |

From the fit we can extract the parallel component of the hyperfine splitting \(a_{||}=3.03\) MHz. However, the commonly used Lorentzian function does not completely describes all features of the resonace. A better fit is provided with a sinc function.
[16]:
podmr_analysis_2 = Analysis(podmr_sim_2)
podmr_analysis_2.run_fit(
fit_model = Model(fit_two_sinc2_sym),
guess = {'A': 0.5, 'gamma': 0.4, 'f_mean':1749, 'f_delta':3,'C':1},
)
podmr_analysis_2.plot_fit(xlabel='Frequency')
podmr_analysis_2.fit_params
[16]:
Fit Result
Model: Model(fit_two_sinc2_sym)
fitting method | leastsq |
# function evals | 37 |
# data points | 160 |
# variables | 5 |
chi-square | 1.1555e-04 |
reduced chi-square | 7.4547e-07 |
Akaike info crit. | -2252.56039 |
Bayesian info crit. | -2237.18453 |
R-squared | 0.99995830 |
name | value | standard error | relative error | initial value | min | max | vary |
---|---|---|---|---|---|---|---|
A | 0.49679723 | 2.6772e-04 | (0.05%) | 0.5 | -inf | inf | True |
gamma | 0.30066635 | 1.1429e-04 | (0.04%) | 0.4 | -inf | inf | True |
f_mean | 1749.00882 | 1.2186e-04 | (0.00%) | 1749 | -inf | inf | True |
f_delta | 3.02839469 | 2.4162e-04 | (0.01%) | 3 | -inf | inf | True |
C | 0.99916892 | 8.0602e-05 | (0.01%) | 1 | -inf | inf | True |
Parameter1 | Parameter 2 | Correlation |
---|---|---|
A | C | +0.4124 |
A | gamma | -0.2688 |
gamma | C | +0.2126 |
