diff --git a/qiskit_experiments/curve_analysis/__init__.py b/qiskit_experiments/curve_analysis/__init__.py index 5573c85b93..1091596bf9 100644 --- a/qiskit_experiments/curve_analysis/__init__.py +++ b/qiskit_experiments/curve_analysis/__init__.py @@ -48,6 +48,7 @@ DumpedOscillationAnalysis OscillationAnalysis ResonanceAnalysis + GaussianAnalysis ErrorAmplificationAnalysis Functions @@ -73,6 +74,7 @@ fit_function.cos_decay fit_function.exponential_decay fit_function.gaussian + fit_function.sqrt_lorentzian fit_function.sin fit_function.sin_decay fit_function.bloch_oscillation_x @@ -120,5 +122,6 @@ DumpedOscillationAnalysis, OscillationAnalysis, ResonanceAnalysis, + GaussianAnalysis, ErrorAmplificationAnalysis, ) diff --git a/qiskit_experiments/curve_analysis/fit_function.py b/qiskit_experiments/curve_analysis/fit_function.py index 691ee1e54b..280399496f 100644 --- a/qiskit_experiments/curve_analysis/fit_function.py +++ b/qiskit_experiments/curve_analysis/fit_function.py @@ -77,6 +77,17 @@ def gaussian( return amp * np.exp(-((x - x0) ** 2) / (2 * sigma**2)) + baseline +def sqrt_lorentzian( + x: np.ndarray, amp: float = 1.0, kappa: float = 1.0, x0: float = 0.0, baseline: float = 0.0 +) -> np.ndarray: + r"""Square-root Lorentzian function for spectroscopy. + + .. math:: + y = {\rm amp}{\rm abs}\left(\frac{1}{1 + 2i(x - x0)/\kappa}\right) + {\rm baseline} + """ + return amp * np.abs(1 / (1 + 2.0j * (x - x0) / kappa)) + baseline + + def cos_decay( x: np.ndarray, amp: float = 1.0, diff --git a/qiskit_experiments/curve_analysis/standard_analysis/__init__.py b/qiskit_experiments/curve_analysis/standard_analysis/__init__.py index 769136bf02..fef9bfb26f 100644 --- a/qiskit_experiments/curve_analysis/standard_analysis/__init__.py +++ b/qiskit_experiments/curve_analysis/standard_analysis/__init__.py @@ -14,5 +14,6 @@ from .oscillation import OscillationAnalysis, DumpedOscillationAnalysis from .resonance import ResonanceAnalysis +from .gaussian import GaussianAnalysis from .error_amplification_analysis import ErrorAmplificationAnalysis from .decay import DecayAnalysis diff --git a/qiskit_experiments/curve_analysis/standard_analysis/gaussian.py b/qiskit_experiments/curve_analysis/standard_analysis/gaussian.py new file mode 100644 index 0000000000..d8d61d72b3 --- /dev/null +++ b/qiskit_experiments/curve_analysis/standard_analysis/gaussian.py @@ -0,0 +1,156 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2021. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +"""Resonance analysis class based on a Gaussian fit.""" + +from typing import List, Union + +import numpy as np + +import qiskit_experiments.curve_analysis as curve +from qiskit_experiments.framework import Options + + +class GaussianAnalysis(curve.CurveAnalysis): + r"""A class to analyze a resonance, typically seen as a peak. + + Overview + This analysis takes only single series. This series is fit by the Gaussian function. + + Fit Model + The fit is based on the following Gaussian function. + + .. math:: + + F(x) = a \exp(-(x-f)^2/(2\sigma^2)) + b + + Fit Parameters + - :math:`a`: Peak height. + - :math:`b`: Base line. + - :math:`f`: Center frequency. This is the fit parameter of main interest. + - :math:`\sigma`: Standard deviation of Gaussian function. + + Initial Guesses + - :math:`a`: Calculated by :func:`~qiskit_experiments.curve_analysis.guess.max_height`. + - :math:`b`: Calculated by :func:`~qiskit_experiments.curve_analysis.guess.\ + constant_spectral_offset`. + - :math:`f`: Frequency at max height position calculated by + :func:`~qiskit_experiments.curve_analysis.guess.max_height`. + - :math:`\sigma`: Calculated from FWHM of peak :math:`w` + such that :math:`w / \sqrt{8} \ln{2}`, where FWHM is calculated by + :func:`~qiskit_experiments.curve_analysis.guess.full_width_half_max`. + + Bounds + - :math:`a`: [-2, 2] scaled with maximum signal value. + - :math:`b`: [-1, 1] scaled with maximum signal value. + - :math:`f`: [min(x), max(x)] of frequency scan range. + - :math:`\sigma`: [0, :math:`\Delta x`] where :math:`\Delta x` + represents frequency scan range. + + """ + + __series__ = [ + curve.SeriesDef( + fit_func=lambda x, a, sigma, freq, b: curve.fit_function.gaussian( + x, amp=a, sigma=sigma, x0=freq, baseline=b + ), + plot_color="blue", + model_description=r"a \exp(-(x-f)^2/(2\sigma^2)) + b", + ) + ] + + @classmethod + def _default_options(cls) -> Options: + options = super()._default_options() + options.result_parameters = [curve.ParameterRepr("freq", "f01", "Hz")] + options.normalization = True + options.xlabel = "Frequency" + options.ylabel = "Signal (arb. units)" + options.xval_unit = "Hz" + return options + + def _generate_fit_guesses( + self, user_opt: curve.FitOptions + ) -> Union[curve.FitOptions, List[curve.FitOptions]]: + """Compute the initial guesses. + + Args: + user_opt: Fit options filled with user provided guess and bounds. + + Returns: + List of fit options that are passed to the fitter function. + """ + curve_data = self._data() + max_abs_y, _ = curve.guess.max_height(curve_data.y, absolute=True) + + user_opt.bounds.set_if_empty( + a=(-2 * max_abs_y, 2 * max_abs_y), + sigma=(0, np.ptp(curve_data.x)), + freq=(min(curve_data.x), max(curve_data.x)), + b=(-max_abs_y, max_abs_y), + ) + user_opt.p0.set_if_empty(b=curve.guess.constant_spectral_offset(curve_data.y)) + + y_ = curve_data.y - user_opt.p0["b"] + + _, peak_idx = curve.guess.max_height(y_, absolute=True) + fwhm = curve.guess.full_width_half_max(curve_data.x, y_, peak_idx) + + user_opt.p0.set_if_empty( + a=curve_data.y[peak_idx] - user_opt.p0["b"], + freq=curve_data.x[peak_idx], + sigma=fwhm / np.sqrt(8 * np.log(2)), + ) + + return user_opt + + def _evaluate_quality(self, fit_data: curve.FitData) -> Union[str, None]: + """Algorithmic criteria for whether the fit is good or bad. + + A good fit has: + - a reduced chi-squared less than 3, + - a peak within the scanned frequency range, + - a standard deviation that is not larger than the scanned frequency range, + - a standard deviation that is wider than the smallest frequency increment, + - a signal-to-noise ratio, defined as the amplitude of the peak divided by the + square root of the median y-value less the fit offset, greater than a + threshold of two, and + - a standard error on the sigma of the Gaussian that is smaller than the sigma. + """ + curve_data = self._data() + + max_freq = np.max(curve_data.x) + min_freq = np.min(curve_data.x) + freq_increment = np.mean(np.diff(curve_data.x)) + + fit_a = fit_data.fitval("a").value + fit_b = fit_data.fitval("b").value + fit_freq = fit_data.fitval("freq").value + fit_sigma = fit_data.fitval("sigma").value + fit_sigma_err = fit_data.fitval("sigma").stderr + + snr = abs(fit_a) / np.sqrt(abs(np.median(curve_data.y) - fit_b)) + fit_width_ratio = fit_sigma / (max_freq - min_freq) + + criteria = [ + min_freq <= fit_freq <= max_freq, + 1.5 * freq_increment < fit_sigma, + fit_width_ratio < 0.25, + fit_data.reduced_chisq < 3, + (fit_sigma_err is None or fit_sigma_err < fit_sigma), + snr > 2, + ] + + if all(criteria): + return "good" + + return "bad" diff --git a/qiskit_experiments/curve_analysis/standard_analysis/resonance.py b/qiskit_experiments/curve_analysis/standard_analysis/resonance.py index 0fa3390225..e6997657b0 100644 --- a/qiskit_experiments/curve_analysis/standard_analysis/resonance.py +++ b/qiskit_experiments/curve_analysis/standard_analysis/resonance.py @@ -21,50 +21,50 @@ class ResonanceAnalysis(curve.CurveAnalysis): - r"""A class to analyze a resonance, typically seen as a peak. + r"""A class to analyze a resonance peak with a square rooted Lorentzian function. Overview - This analysis takes only single series. This series is fit by the Gaussian function. + This analysis takes only single series. This series is fit to the square root of + a Lorentzian function. Fit Model - The fit is based on the following Gaussian function. + The fit is based on the following Lorentzian function. .. math:: - F(x) = a \exp(-(x-f)^2/(2\sigma^2)) + b + F(x) = a{\rm abs}\left(\frac{1}{1 + 2i(x - x0)/\kappa}\right) + b Fit Parameters - :math:`a`: Peak height. - :math:`b`: Base line. - - :math:`f`: Center frequency. This is the fit parameter of main interest. - - :math:`\sigma`: Standard deviation of Gaussian function. + - :math:`x0`: Center value. This is typically the fit parameter of interest. + - :math:`\kappa`: Linewidth. Initial Guesses - :math:`a`: Calculated by :func:`~qiskit_experiments.curve_analysis.guess.max_height`. - :math:`b`: Calculated by :func:`~qiskit_experiments.curve_analysis.guess.\ constant_spectral_offset`. - - :math:`f`: Frequency at max height position calculated by + - :math:`x0`: The max height position is calculated by the function :func:`~qiskit_experiments.curve_analysis.guess.max_height`. - - :math:`\sigma`: Calculated from FWHM of peak :math:`w` - such that :math:`w / \sqrt{8} \ln{2}`, where FWHM is calculated by + - :math:`\kappa`: Calculated from FWHM of the peak using :func:`~qiskit_experiments.curve_analysis.guess.full_width_half_max`. Bounds - :math:`a`: [-2, 2] scaled with maximum signal value. - :math:`b`: [-1, 1] scaled with maximum signal value. - - :math:`f`: [min(x), max(x)] of frequency scan range. - - :math:`\sigma`: [0, :math:`\Delta x`] where :math:`\Delta x` - represents frequency scan range. + - :math:`f`: [min(x), max(x)] of x-value scan range. + - :math:`\kappa`: [0, :math:`\Delta x`] where :math:`\Delta x` + represents the x-value scan range. """ __series__ = [ curve.SeriesDef( - fit_func=lambda x, a, sigma, freq, b: curve.fit_function.gaussian( - x, amp=a, sigma=sigma, x0=freq, baseline=b + fit_func=lambda x, a, kappa, freq, b: curve.fit_function.sqrt_lorentzian( + x, amp=a, kappa=kappa, x0=freq, baseline=b ), plot_color="blue", - model_description=r"a \exp(-(x-f)^2/(2\sigma^2)) + b", + model_description=r"a abs(1 / (1 + 2i * (x - x_0) / \kappa)) + b", ) ] @@ -94,7 +94,7 @@ def _generate_fit_guesses( user_opt.bounds.set_if_empty( a=(-2 * max_abs_y, 2 * max_abs_y), - sigma=(0, np.ptp(curve_data.x)), + kappa=(0, np.ptp(curve_data.x)), freq=(min(curve_data.x), max(curve_data.x)), b=(-max_abs_y, max_abs_y), ) @@ -106,9 +106,9 @@ def _generate_fit_guesses( fwhm = curve.guess.full_width_half_max(curve_data.x, y_, peak_idx) user_opt.p0.set_if_empty( - a=curve_data.y[peak_idx] - user_opt.p0["b"], + a=(curve_data.y[peak_idx] - user_opt.p0["b"]), freq=curve_data.x[peak_idx], - sigma=fwhm / np.sqrt(8 * np.log(2)), + kappa=fwhm, ) return user_opt @@ -124,7 +124,7 @@ def _evaluate_quality(self, fit_data: curve.FitData) -> Union[str, None]: - a signal-to-noise ratio, defined as the amplitude of the peak divided by the square root of the median y-value less the fit offset, greater than a threshold of two, and - - a standard error on the sigma of the Gaussian that is smaller than the sigma. + - a standard error on the kappa of the Lorentzian that is smaller than the kappa. """ curve_data = self._data() @@ -135,18 +135,18 @@ def _evaluate_quality(self, fit_data: curve.FitData) -> Union[str, None]: fit_a = fit_data.fitval("a").value fit_b = fit_data.fitval("b").value fit_freq = fit_data.fitval("freq").value - fit_sigma = fit_data.fitval("sigma").value - fit_sigma_err = fit_data.fitval("sigma").stderr + fit_kappa = fit_data.fitval("kappa").value + fit_kappa_err = fit_data.fitval("kappa").stderr snr = abs(fit_a) / np.sqrt(abs(np.median(curve_data.y) - fit_b)) - fit_width_ratio = fit_sigma / (max_freq - min_freq) + fit_width_ratio = fit_kappa / (max_freq - min_freq) criteria = [ min_freq <= fit_freq <= max_freq, - 1.5 * freq_increment < fit_sigma, + 1.5 * freq_increment < fit_kappa, fit_width_ratio < 0.25, fit_data.reduced_chisq < 3, - (fit_sigma_err is None or fit_sigma_err < fit_sigma), + (fit_kappa_err is None or fit_kappa_err < fit_kappa), snr > 2, ] diff --git a/qiskit_experiments/data_processing/nodes.py b/qiskit_experiments/data_processing/nodes.py index 7fb6f7e991..943ceae8a2 100644 --- a/qiskit_experiments/data_processing/nodes.py +++ b/qiskit_experiments/data_processing/nodes.py @@ -13,6 +13,7 @@ """Different data analysis steps.""" from abc import abstractmethod +from enum import Enum from numbers import Number from typing import List, Union, Sequence @@ -374,6 +375,22 @@ def _process(self, data: np.ndarray) -> np.ndarray: return data[..., 1] * self.scale +class ToAbs(IQPart): + """IQ data post-processing. Take the absolute value of the IQ point.""" + + def _process(self, data: np.array) -> np.array: + """Take the absolute value of the IQ data. + + Args: + data: An N-dimensional array of complex IQ point as [real, imaginary]. + + Returns: + A N-1 dimensional array, each entry is the absolute value of the given IQ data. + """ + # pylint: disable=no-member + return unp.sqrt(data[..., 0] ** 2 + data[..., 1] ** 2) * self.scale + + class Probability(DataAction): r"""Compute the mean probability of a single measurement outcome from counts. @@ -555,3 +572,12 @@ def _process(self, data: np.ndarray) -> np.ndarray: The data that has been processed. """ return 2 * (0.5 - data) + + +class ProjectorType(Enum): + """Types of projectors for data dimensionality reduction.""" + + SVD = SVD + ABS = ToAbs + REAL = ToReal + IMAG = ToImag diff --git a/qiskit_experiments/data_processing/processor_library.py b/qiskit_experiments/data_processing/processor_library.py index 71f96b3b4a..27cb00d34f 100644 --- a/qiskit_experiments/data_processing/processor_library.py +++ b/qiskit_experiments/data_processing/processor_library.py @@ -17,6 +17,7 @@ from qiskit_experiments.framework import ExperimentData, Options from qiskit_experiments.data_processing.exceptions import DataProcessorError from qiskit_experiments.data_processing.data_processor import DataProcessor +from qiskit_experiments.data_processing.nodes import ProjectorType from qiskit_experiments.data_processing import nodes @@ -35,9 +36,15 @@ def get_processor( - normalization (bool): A boolean to specify if the data should be normalized to the interval [0, 1]. The default is True. This option is only relevant if kerneled data is used. - - outcome (string): The measurement outcome that will be passed to a Probability node. - The default value is a string of 1's where the length of the string is the number of - qubits, e.g. '111' for three qubits. + - dimensionality_reduction: An optional string or instance of :class:`ProjectorType` + to represent the dimensionality reduction node for Kerneled data. For the + supported nodes, see :class:`ProjectorType`. Typically, these nodes convert + complex IQ data to real data, for example by performing a singular-value + decomposition. This argument is only needed for Kerneled data (i.e. level 1) + and can thus be ignored if Classified data (the default) is used. + - outcome (string): The measurement outcome that will be passed to a Probability node. + The default value is a string of 1's where the length of the string is the number of + qubits, e.g. '111' for three qubits. index: The index of the job for which to get a data processor. The default value is -1. Returns: @@ -50,12 +57,15 @@ def get_processor( Raises: DataProcessorError: if the measurement level is not supported. + DataProcessorError: if the wrong dimensionality reduction for kerneled data + is specified. """ run_options = experiment_data.metadata["job_metadata"][index].get("run_options", {}) meas_level = run_options.get("meas_level", MeasLevel.CLASSIFIED) meas_return = run_options.get("meas_return", MeasReturnType.AVERAGE) normalize = analysis_options.get("normalization", True) + dimensionality_reduction = analysis_options.get("dimensionality_reduction", ProjectorType.SVD) if meas_level == MeasLevel.CLASSIFIED: num_qubits = experiment_data.metadata.get("num_qubits", 1) @@ -63,10 +73,24 @@ def get_processor( return DataProcessor("counts", [nodes.Probability(outcome)]) if meas_level == MeasLevel.KERNELED: - if meas_return == MeasReturnType.SINGLE: - processor = DataProcessor("memory", [nodes.AverageData(axis=1), nodes.SVD()]) + + try: + if isinstance(dimensionality_reduction, ProjectorType): + projector_name = dimensionality_reduction.name + else: + projector_name = dimensionality_reduction + + projector = ProjectorType[projector_name].value + + except KeyError as error: + raise DataProcessorError( + f"Invalid dimensionality reduction: {dimensionality_reduction}." + ) from error + + if meas_return == "single": + processor = DataProcessor("memory", [nodes.AverageData(axis=1), projector()]) else: - processor = DataProcessor("memory", [nodes.SVD()]) + processor = DataProcessor("memory", [projector()]) if normalize: processor.append(nodes.MinMaxNormalize()) diff --git a/qiskit_experiments/framework/base_analysis.py b/qiskit_experiments/framework/base_analysis.py index 37f898f25b..6e3cf0fe1a 100644 --- a/qiskit_experiments/framework/base_analysis.py +++ b/qiskit_experiments/framework/base_analysis.py @@ -144,13 +144,7 @@ def run( if not replace_results and _requires_copy(experiment_data): experiment_data = experiment_data.copy() - # Get experiment device components - if "physical_qubits" in experiment_data.metadata: - experiment_components = [ - Qubit(qubit) for qubit in experiment_data.metadata["physical_qubits"] - ] - else: - experiment_components = [] + experiment_components = self._get_experiment_components(experiment_data) # Set Analysis options if not options: @@ -179,6 +173,17 @@ def run_analysis(expdata): return experiment_data + def _get_experiment_components(self, experiment_data: ExperimentData): + """Subclasses may override this method to specify the experiment components.""" + if "physical_qubits" in experiment_data.metadata: + experiment_components = [ + Qubit(qubit) for qubit in experiment_data.metadata["physical_qubits"] + ] + else: + experiment_components = [] + + return experiment_components + def _format_analysis_result(self, data, experiment_id, experiment_components=None): """Format run analysis result to DbAnalysisResult""" device_components = [] diff --git a/qiskit_experiments/library/__init__.py b/qiskit_experiments/library/__init__.py index 08dd8b22dc..52023af4eb 100644 --- a/qiskit_experiments/library/__init__.py +++ b/qiskit_experiments/library/__init__.py @@ -71,6 +71,7 @@ ~characterization.RamseyXY ~characterization.FineFrequency ~characterization.ReadoutAngle + ~characterization.ResonatorSpectroscopy .. _calibration: @@ -103,21 +104,7 @@ class instance to manage parameters and pulse schedules. ~calibration.EFRoughXSXAmplitudeCal """ -from .calibration import ( - RoughDragCal, - FineDragCal, - FineXDragCal, - FineSXDragCal, - RoughAmplitudeCal, - RoughXSXAmplitudeCal, - EFRoughXSXAmplitudeCal, - FineAmplitudeCal, - FineXAmplitudeCal, - FineSXAmplitudeCal, - RoughFrequencyCal, - FrequencyCal, - FineFrequencyCal, -) + from .characterization import ( T1, T2Ramsey, @@ -138,7 +125,25 @@ class instance to manage parameters and pulse schedules. RamseyXY, FineFrequency, ReadoutAngle, + ResonatorSpectroscopy, ) + +from .calibration import ( + RoughDragCal, + FineDragCal, + FineXDragCal, + FineSXDragCal, + RoughAmplitudeCal, + RoughXSXAmplitudeCal, + EFRoughXSXAmplitudeCal, + FineAmplitudeCal, + FineXAmplitudeCal, + FineSXAmplitudeCal, + RoughFrequencyCal, + FrequencyCal, + FineFrequencyCal, +) + from .randomized_benchmarking import StandardRB, InterleavedRB from .tomography import StateTomography, ProcessTomography from .quantum_volume import QuantumVolume diff --git a/qiskit_experiments/library/characterization/__init__.py b/qiskit_experiments/library/characterization/__init__.py index 7fc9e63821..33541e1a86 100644 --- a/qiskit_experiments/library/characterization/__init__.py +++ b/qiskit_experiments/library/characterization/__init__.py @@ -42,6 +42,7 @@ FineDrag FineXDrag FineSXDrag + ResonatorSpectroscopy Analysis @@ -61,6 +62,7 @@ FineAmplitudeAnalysis RamseyXYAnalysis ReadoutAngleAnalysis + ResonatorSpectroscopyAnalysis """ from .analysis import ( @@ -74,6 +76,7 @@ T2HahnAnalysis, CrossResonanceHamiltonianAnalysis, ReadoutAngleAnalysis, + ResonatorSpectroscopyAnalysis, ) from .t1 import T1 @@ -90,3 +93,4 @@ from .drag import RoughDrag from .readout_angle import ReadoutAngle from .fine_drag import FineDrag, FineXDrag, FineSXDrag +from .resonator_spectroscopy import ResonatorSpectroscopy diff --git a/qiskit_experiments/library/characterization/analysis/__init__.py b/qiskit_experiments/library/characterization/analysis/__init__.py index 3c46ff6964..b8b3359b18 100644 --- a/qiskit_experiments/library/characterization/analysis/__init__.py +++ b/qiskit_experiments/library/characterization/analysis/__init__.py @@ -23,3 +23,4 @@ from .t1_analysis import T1Analysis from .cr_hamiltonian_analysis import CrossResonanceHamiltonianAnalysis from .readout_angle_analysis import ReadoutAngleAnalysis +from .resonator_spectroscopy_analysis import ResonatorSpectroscopyAnalysis diff --git a/qiskit_experiments/library/characterization/analysis/resonator_spectroscopy_analysis.py b/qiskit_experiments/library/characterization/analysis/resonator_spectroscopy_analysis.py new file mode 100644 index 0000000000..09d826b59c --- /dev/null +++ b/qiskit_experiments/library/characterization/analysis/resonator_spectroscopy_analysis.py @@ -0,0 +1,82 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2021. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +"""Spectroscopy analysis class for resonators.""" + +from typing import List, Tuple +import numpy as np + +import qiskit_experiments.curve_analysis as curve +from qiskit_experiments.curve_analysis import ResonanceAnalysis +from qiskit_experiments.framework import AnalysisResultData, ExperimentData +from qiskit_experiments.framework.matplotlib import get_non_gui_ax +from qiskit_experiments.data_processing.nodes import ProjectorType +from qiskit_experiments.database_service.device_component import Resonator + + +class ResonatorSpectroscopyAnalysis(ResonanceAnalysis): + """Class to analysis resonator spectroscopy.""" + + @classmethod + def _default_options(cls): + options = super()._default_options() + options.dimensionality_reduction = ProjectorType.ABS + options.result_parameters = [ + curve.ParameterRepr("freq", "res_freq0", "Hz"), + curve.ParameterRepr("kappa", "kappa", "Hz"), + ] + options.plot_iq_data = True + return options + + def _get_experiment_components(self, experiment_data: ExperimentData): + """Return resonators as experiment components.""" + return [Resonator(qubit) for qubit in experiment_data.metadata["physical_qubits"]] + + def _run_analysis( + self, experiment_data: ExperimentData + ) -> Tuple[List[AnalysisResultData], List["pyplot.Figure"]]: + """Wrap the analysis to optionally plot the IQ data.""" + analysis_results, figures = super()._run_analysis(experiment_data) + + if self.options.plot_iq_data: + axis = get_non_gui_ax() + figure = axis.get_figure() + figure.set_size_inches(*self.options.style.figsize) + + iqs = [] + + for datum in experiment_data.data(): + if "memory" in datum: + mem = np.array(datum["memory"]) + + # Average single-shot data. + if len(mem.shape) == 3: + for idx in range(mem.shape[1]): + iqs.append(np.average(mem[:, idx, :], axis=0)) + else: + iqs.append(mem) + + if len(iqs) > 0: + iqs = np.vstack(iqs) + axis.scatter(iqs[:, 0], iqs[:, 1], color="b") + axis.set_xlabel( + "In phase [arb. units]", fontsize=self.options.style.axis_label_size + ) + axis.set_ylabel( + "Quadrature [arb. units]", fontsize=self.options.style.axis_label_size + ) + axis.tick_params(labelsize=self.options.style.tick_label_size) + axis.grid(True) + + figures.append(figure) + + return analysis_results, figures diff --git a/qiskit_experiments/library/characterization/qubit_spectroscopy.py b/qiskit_experiments/library/characterization/qubit_spectroscopy.py index fd53dd2767..e9897b428d 100644 --- a/qiskit_experiments/library/characterization/qubit_spectroscopy.py +++ b/qiskit_experiments/library/characterization/qubit_spectroscopy.py @@ -12,21 +12,18 @@ """Spectroscopy experiment class.""" -from typing import Iterable, Optional, Tuple +from typing import Tuple import numpy as np import qiskit.pulse as pulse from qiskit import QuantumCircuit from qiskit.circuit import Gate, Parameter from qiskit.exceptions import QiskitError -from qiskit.providers import Backend -from qiskit.qobj.utils import MeasLevel -from qiskit_experiments.framework import BaseExperiment, Options -from qiskit_experiments.curve_analysis import ResonanceAnalysis +from qiskit_experiments.library.characterization.spectroscopy import Spectroscopy -class QubitSpectroscopy(BaseExperiment): +class QubitSpectroscopy(Spectroscopy): """Class that runs spectroscopy by sweeping the qubit frequency. # section: overview @@ -50,92 +47,47 @@ class QubitSpectroscopy(BaseExperiment): __spec_gate_name__ = "Spec" - @classmethod - def _default_run_options(cls) -> Options: - """Default options values for the experiment :meth:`run` method.""" - options = super()._default_run_options() + @property + def _backend_center_frequency(self) -> float: + """Returns the center frequency of the experiment. - options.meas_level = MeasLevel.KERNELED - options.meas_return = "single" - - return options - - @classmethod - def _default_experiment_options(cls) -> Options: - """Default option values used for the spectroscopy pulse. - - Experiment Options: - amp (float): The amplitude of the spectroscopy pulse. Defaults to 0.1. - duration (int): The duration of the spectroscopy pulse. Defaults to 1024 samples. - sigma (float): The standard deviation of the flanks of the spectroscopy pulse. - Defaults to 256. - width (int): The width of the flat-top part of the GaussianSquare pulse. - Defaults to 0. - """ - options = super()._default_experiment_options() - - options.amp = 0.1 - options.duration = 1024 - options.sigma = 256 - options.width = 0 - - return options - - def __init__( - self, - qubit: int, - frequencies: Iterable[float], - backend: Optional[Backend] = None, - absolute: bool = True, - ): - """ - A spectroscopy experiment run by setting the frequency of the qubit drive. - The parameters of the GaussianSquare spectroscopy pulse can be specified at run-time. - The spectroscopy pulse has the following parameters: - - amp: The amplitude of the pulse must be between 0 and 1, the default is 0.1. - - duration: The duration of the spectroscopy pulse in samples, the default is 1000 samples. - - sigma: The standard deviation of the pulse, the default is duration / 4. - - width: The width of the flat-top in the pulse, the default is 0, i.e. a Gaussian. - - Args: - qubit: The qubit on which to run spectroscopy. - frequencies: The frequencies to scan in the experiment, in Hz. - backend: Optional, the backend to run the experiment on. - absolute: Boolean to specify if the frequencies are absolute or relative to the - qubit frequency in the backend. + Returns: + The center frequency of the experiment. Raises: - QiskitError: if there are less than three frequency shifts. - + QiskitError: If the experiment does not have a backend set. """ - super().__init__([qubit], analysis=ResonanceAnalysis(), backend=backend) - - if len(frequencies) < 3: - raise QiskitError("Spectroscopy requires at least three frequencies.") + if self.backend is None: + raise QiskitError("backend not set. Cannot determine the center frequency.") - self._frequencies = frequencies - self._absolute = absolute + return self.backend.defaults().qubit_freq_est[self.physical_qubits[0]] - if not self._absolute: - self.analysis.set_options(xlabel="Frequency shift") - else: - self.analysis.set_options(xlabel="Frequency") + def _template_circuit(self, freq_param) -> QuantumCircuit: + """Return the template quantum circuit.""" + circuit = QuantumCircuit(1) + circuit.append(Gate(name=self.__spec_gate_name__, num_qubits=1, params=[freq_param]), (0,)) + circuit.measure_active() - self.analysis.set_options(ylabel="Signal [arb. unit]") + return circuit - def _spec_gate_schedule( - self, backend: Optional[Backend] = None - ) -> Tuple[pulse.ScheduleBlock, Parameter]: + def _schedule(self) -> Tuple[pulse.ScheduleBlock, Parameter]: """Create the spectroscopy schedule.""" freq_param = Parameter("frequency") - with pulse.build(backend=backend, name="spectroscopy") as schedule: + + dt, granularity = self._dt, self._granularity + + duration = int(granularity * (self.experiment_options.duration / dt // granularity)) + sigma = granularity * (self.experiment_options.sigma / dt // granularity) + width = granularity * (self.experiment_options.width / dt // granularity) + + with pulse.build(backend=self.backend, name="spectroscopy") as schedule: pulse.shift_frequency(freq_param, pulse.DriveChannel(self.physical_qubits[0])) pulse.play( pulse.GaussianSquare( - duration=self.experiment_options.duration, + duration=duration, amp=self.experiment_options.amp, - sigma=self.experiment_options.sigma, - width=self.experiment_options.width, + sigma=sigma, + width=width, ), pulse.DriveChannel(self.physical_qubits[0]), ) @@ -143,14 +95,6 @@ def _spec_gate_schedule( return schedule, freq_param - def _template_circuit(self, freq_param) -> QuantumCircuit: - """Return the template quantum circuit.""" - circuit = QuantumCircuit(1) - circuit.append(Gate(name=self.__spec_gate_name__, num_qubits=1, params=[freq_param]), (0,)) - circuit.measure_active() - - return circuit - def circuits(self): """Create the circuit for the spectroscopy experiment. @@ -159,54 +103,23 @@ def circuits(self): Returns: circuits: The circuits that will run the spectroscopy experiment. - - Raises: - QiskitError: - - If absolute frequencies are used but no backend is given. - - If the backend configuration does not define dt. - AttributeError: If backend to run on does not contain 'dt' configuration. """ - if self.backend is None and self._absolute: - raise QiskitError("Cannot run spectroscopy absolute to qubit without a backend.") # Create a template circuit - sched, freq_param = self._spec_gate_schedule(self.backend) + sched, freq_param = self._schedule() circuit = self._template_circuit(freq_param) - circuit.add_calibration("Spec", (self.physical_qubits[0],), sched, params=[freq_param]) - - # Get dt - try: - dt_factor = getattr(self.backend.configuration(), "dt") - except AttributeError as no_dt: - raise AttributeError("dt parameter is missing in backend configuration") from no_dt - - # Get center frequency from backend - if self._absolute: - center_freq = self.backend.defaults().qubit_freq_est[self.physical_qubits[0]] - else: - center_freq = None + circuit.add_calibration( + self.__spec_gate_name__, self.physical_qubits, sched, params=[freq_param] + ) # Create the circuits to run circs = [] for freq in self._frequencies: - freq_shift = freq - if self._absolute: - freq_shift -= center_freq + freq_shift = freq - self._backend_center_frequency if self._absolute else freq freq_shift = np.round(freq_shift, decimals=3) assigned_circ = circuit.assign_parameters({freq_param: freq_shift}, inplace=False) - assigned_circ.metadata = { - "experiment_type": self._type, - "qubits": (self.physical_qubits[0],), - "xval": np.round(freq, decimals=3), - "unit": "Hz", - "amplitude": self.experiment_options.amp, - "duration": self.experiment_options.duration, - "sigma": self.experiment_options.sigma, - "width": self.experiment_options.width, - "schedule": str(sched), - "dt": dt_factor, - } + self._add_metadata(assigned_circ, freq, sched) circs.append(assigned_circ) diff --git a/qiskit_experiments/library/characterization/resonator_spectroscopy.py b/qiskit_experiments/library/characterization/resonator_spectroscopy.py new file mode 100644 index 0000000000..df4ef21822 --- /dev/null +++ b/qiskit_experiments/library/characterization/resonator_spectroscopy.py @@ -0,0 +1,220 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2021. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +"""Spectroscopy experiment class for resonators.""" + +from typing import Iterable, Optional, Tuple +import numpy as np + +from qiskit import QuantumCircuit +from qiskit.circuit import Parameter +from qiskit.exceptions import QiskitError +from qiskit.providers import Backend +import qiskit.pulse as pulse + +from qiskit_experiments.framework import Options +from qiskit_experiments.library.characterization.spectroscopy import Spectroscopy +from .analysis.resonator_spectroscopy_analysis import ResonatorSpectroscopyAnalysis + + +class ResonatorSpectroscopy(Spectroscopy): + """Perform spectroscopy on the readout resonator. + + # section: overview + This experiment does spectroscopy on the readout resonator. It applies the following + circuit + + .. parsed-literal:: + + ┌─┐ + q: ┤M├ + └╥┘ + c: 1/═╩═ + 0 + + where a spectroscopy pulse is attached to the measurement instruction. + + Side note: when doing readout resonator spectroscopy, each measured IQ point has a + frequency dependent phase. Close to the resonance, the IQ points start rotating around + in the IQ plan. This effect must be accounted for in the data processing to produce a + meaningful signal. The default data processing workflow will therefore reduce the two- + dimensional IQ data to one-dimensional data using the magnitude of each IQ point. + + # section: warning + Some backends may not have the required functionality to properly support resonator + spectroscopy experiments. The experiment may not work or the resulting resonance + may not properly reflect the properties of the readout resonator. + + # section: example + + The resonator spectroscopy experiment can be run by doing: + + .. code:: python + + qubit = 1 + spec = ResonatorSpectroscopy(qubit, backend) + exp_data = spec.run().block_for_results() + exp_data.figure(0) + + This will measure the resonator attached to qubit 1 and report the resonance frequency + as well as the kappa, i.e. the line width, of the resonator. + + # section: analysis_ref + :py:class:`ResonatorSpectroscopyAnalysis` + + # section: see_also + qiskit_experiments.library.characterization.qubit_spectroscopy.QubitSpectroscopy + """ + + @classmethod + def _default_experiment_options(cls) -> Options: + """Default option values used for the spectroscopy pulse. + + All units of the resonator spectroscopy experiment are given in seconds. + + Experiment Options: + amp (float): The amplitude of the spectroscopy pulse. Defaults to 1 and must + be between 0 and 1. + duration (float): The duration in seconds of the spectroscopy pulse. + sigma (float): The standard deviation of the spectroscopy pulse in seconds. + width (float): The width of the flat-top part of the GaussianSquare pulse in + seconds. Defaults to 0. + """ + options = super()._default_experiment_options() + + options.amp = 1 + options.duration = 480e-9 + options.sigma = 60e-9 + options.width = 360e-9 + + return options + + def __init__( + self, + qubit: int, + backend: Optional[Backend] = None, + frequencies: Optional[Iterable[float]] = None, + absolute: bool = True, + **experiment_options, + ): + """Initialize a resonator spectroscopy experiment. + + A spectroscopy experiment run by setting the frequency of the readout drive. + The parameters of the GaussianSquare spectroscopy pulse can be specified at run-time + through the experiment options. + + Args: + qubit: The qubit on which to run readout spectroscopy. + backend: Optional, the backend to run the experiment on. + frequencies: The frequencies to scan in the experiment, in Hz. The default values + range from -20 MHz to 20 MHz in 51 steps. If the ``absolute`` variable is + set to True then a center frequency obtained from the backend's defaults is + added to each value of this range. + absolute: Boolean to specify if the frequencies are absolute or relative to the + resonator frequency in the backend. The default value is True. + experiment_options: Key word arguments used to set the experiment options. + + Raises: + QiskitError: if no frequencies are given and absolute frequencies are desired and + no backend is given. + """ + analysis = ResonatorSpectroscopyAnalysis() + + if frequencies is None: + frequencies = np.linspace(-20.0e6, 20.0e6, 51) + + if absolute: + if backend is None: + raise QiskitError( + "Cannot automatically compute absolute frequencies without a backend." + ) + + center_freq = backend.defaults().meas_freq_est[qubit] + frequencies += center_freq + + super().__init__(qubit, frequencies, backend, absolute, analysis, **experiment_options) + + @property + def _backend_center_frequency(self) -> float: + """Returns the center frequency of the experiment. + + Returns: + The center frequency of the experiment. + + Raises: + QiskitError: If the experiment does not have a backend set. + """ + if self.backend is None: + raise QiskitError("backend not set. Cannot call center_frequency.") + + return self.backend.defaults().meas_freq_est[self.physical_qubits[0]] + + def _template_circuit(self) -> QuantumCircuit: + """Return the template quantum circuit.""" + circuit = QuantumCircuit(1, 1) + circuit.measure(0, 0) + + return circuit + + def _schedule(self) -> Tuple[pulse.ScheduleBlock, Parameter]: + """Create the spectroscopy schedule.""" + + dt, granularity = self._dt, self._granularity + + duration = int(granularity * (self.experiment_options.duration / dt // granularity)) + sigma = granularity * (self.experiment_options.sigma / dt // granularity) + width = granularity * (self.experiment_options.width / dt // granularity) + + qubit = self.physical_qubits[0] + + freq_param = Parameter("frequency") + + with pulse.build(backend=self.backend, name="spectroscopy") as schedule: + pulse.shift_frequency(freq_param, pulse.MeasureChannel(qubit)) + pulse.play( + pulse.GaussianSquare( + duration=duration, + amp=self.experiment_options.amp, + sigma=sigma, + width=width, + ), + pulse.MeasureChannel(qubit), + ) + pulse.acquire(duration, qubit, pulse.MemorySlot(0)) + + return schedule, freq_param + + def circuits(self): + """Create the circuit for the spectroscopy experiment. + + The circuits are based on a GaussianSquare pulse and a frequency_shift instruction + encapsulated in a measurement instruction. + + Returns: + circuits: The circuits that will run the spectroscopy experiment. + """ + sched, freq_param = self._schedule() + + circs = [] + for freq in self._frequencies: + freq_shift = freq - self._backend_center_frequency if self._absolute else freq + freq_shift = np.round(freq_shift, decimals=3) + + sched_ = sched.assign_parameters({freq_param: freq_shift}, inplace=False) + + circuit = self._template_circuit() + circuit.add_calibration("measure", self.physical_qubits, sched_) + self._add_metadata(circuit, freq, sched) + + circs.append(circuit) + + return circs diff --git a/qiskit_experiments/library/characterization/spectroscopy.py b/qiskit_experiments/library/characterization/spectroscopy.py new file mode 100644 index 0000000000..afc8dd3ebe --- /dev/null +++ b/qiskit_experiments/library/characterization/spectroscopy.py @@ -0,0 +1,135 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2022. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +"""Abstract spectroscopy experiment base class.""" + +from abc import ABC, abstractmethod +from typing import Iterable, Optional + +import numpy as np +import qiskit.pulse as pulse +from qiskit import QuantumCircuit +from qiskit.exceptions import QiskitError +from qiskit.providers import Backend +from qiskit.qobj.utils import MeasLevel + +from qiskit_experiments.framework import BaseAnalysis, BaseExperiment, Options +from qiskit_experiments.curve_analysis import ResonanceAnalysis + + +class Spectroscopy(BaseExperiment, ABC): + """An abstract class for spectroscopy experiments.""" + + @classmethod + def _default_experiment_options(cls) -> Options: + """Default option values used for the spectroscopy pulse. + + Experiment Options: + amp (float): The amplitude of the spectroscopy pulse. Defaults to 0.1 and must + be between 0 and 1. + duration (int): The duration of the spectroscopy pulse. Defaults to 1024 samples. + sigma (float): The standard deviation of the flanks of the spectroscopy pulse. + Defaults to 256. + width (int): The width of the flat-top part of the GaussianSquare pulse. + Defaults to 0. + """ + options = super()._default_experiment_options() + + options.amp = 0.1 + options.duration = 240e-9 + options.sigma = 60e-9 + options.width = 0 + + return options + + @classmethod + def _default_run_options(cls) -> Options: + """Default options values for the experiment :meth:`run` method.""" + options = super()._default_run_options() + + options.meas_level = MeasLevel.KERNELED + options.meas_return = "avg" + + return options + + def __init__( + self, + qubit: int, + frequencies: Iterable[float], + backend: Optional[Backend] = None, + absolute: bool = True, + analysis: Optional[BaseAnalysis] = None, + **experiment_options, + ): + """A spectroscopy experiment where the frequency of a pulse is scanned. + + Args: + qubit: The qubit on which to run spectroscopy. + frequencies: The frequencies to scan in the experiment, in Hz. + backend: Optional, the backend to run the experiment on. + absolute: Boolean to specify if the frequencies are absolute or relative to the + qubit frequency in the backend. + analysis: An instance of the analysis class to use. + experiment_options: Key word arguments used to set the experiment options. + + Raises: + QiskitError: if there are less than three frequency shifts. + + """ + analysis = analysis or ResonanceAnalysis() + + super().__init__([qubit], analysis=analysis, backend=backend) + + if len(frequencies) < 3: + raise QiskitError("Spectroscopy requires at least three frequencies.") + + self._frequencies = frequencies + self._absolute = absolute + + self.set_experiment_options(**experiment_options) + + def _set_backend(self, backend: Backend): + """Set the backend for the experiment and extract config information.""" + super()._set_backend(backend) + + self._dt = getattr(self.backend.configuration(), "dt", None) + constraints = getattr(self.backend.configuration(), "timing_constraints", {}) + self._granularity = constraints.get("granularity", None) + + if self._dt is None or self._granularity is None: + raise QiskitError(f"{self.__class__.__name__} needs both dt and sample granularity.") + + @property + @abstractmethod + def _backend_center_frequency(self) -> float: + """The default frequency for the channel of the spectroscopy pulse. + + This frequency is used to calculate the appropriate frequency shifts to apply to the + spectroscopy pulse as its frequency is scanned in the experiment. Spectroscopy experiments + should implement schedules using frequency shifts. Therefore, if an absolute frequency + range is given the frequency shifts need to be corrected by the backend default frequency + which depends on the nature of the spectroscopy experiment. + """ + + def _add_metadata(self, circuit: QuantumCircuit, freq: float, sched: pulse.ScheduleBlock): + """Helper method to add the metadata to avoid code duplication with subclasses.""" + + if not self._absolute: + freq += self._backend_center_frequency + + circuit.metadata = { + "experiment_type": self._type, + "qubits": self.physical_qubits, + "xval": np.round(freq, decimals=3), + "unit": "Hz", + "schedule": str(sched), + } diff --git a/qiskit_experiments/test/mock_iq_backend.py b/qiskit_experiments/test/mock_iq_backend.py index e8b561a471..6d471ada5d 100644 --- a/qiskit_experiments/test/mock_iq_backend.py +++ b/qiskit_experiments/test/mock_iq_backend.py @@ -52,7 +52,7 @@ def _default_options(self): meas_return="single", ) - def _draw_iq_shots(self, prob, shots) -> List[List[List[float]]]: + def _draw_iq_shots(self, prob, shots, phase: float = 0.0) -> List[List[List[float]]]: """Produce an IQ shot.""" rand_i = self._rng.normal(0, self._iq_cluster_width, size=shots) @@ -68,6 +68,10 @@ def _draw_iq_shots(self, prob, shots) -> List[List[List[float]]]: point_i = self._iq_cluster_centers[2] + rand_i[idx] point_q = self._iq_cluster_centers[3] + rand_q[idx] + if not np.allclose(phase, 0.0): + complex_iq = (point_i + 1.0j * point_q) * np.exp(1.0j * phase) + point_i, point_q = np.real(complex_iq), np.imag(complex_iq) + memory.append([[point_i, point_q]]) return memory @@ -86,6 +90,15 @@ def _compute_probability(self, circuit: QuantumCircuit) -> float: The probability that the binaomial distribution will use to generate an IQ shot. """ + # pylint: disable=unused-argument + def _iq_phase(self, circuit: QuantumCircuit) -> float: + """Sub-classes can override this method to introduce a phase in the IQ plan. + + This is needed, to test the resonator spectroscopy where the point in the IQ + plan has a frequency-dependent phase rotation. + """ + return 0.0 + def run(self, run_input, **options): """Run the IQ backend.""" @@ -117,7 +130,8 @@ def run(self, run_input, **options): ones = np.sum(self._rng.binomial(1, prob, size=shots)) run_result["data"] = {"counts": {"1": ones, "0": shots - ones}} else: - memory = self._draw_iq_shots(prob, shots) + phase = self._iq_phase(circ) + memory = self._draw_iq_shots(prob, shots, phase) if meas_return == "avg": memory = np.average(np.array(memory), axis=0).tolist() diff --git a/releasenotes/notes/resonator-spectroscopy-89f790412838ba5b.yaml b/releasenotes/notes/resonator-spectroscopy-89f790412838ba5b.yaml new file mode 100644 index 0000000000..63a9c378be --- /dev/null +++ b/releasenotes/notes/resonator-spectroscopy-89f790412838ba5b.yaml @@ -0,0 +1,28 @@ +--- +fixes: + - | + The ResonanceAnalysis class has been switched from a Gaussian fit to a Lorentzian + fit function. Furthermore, the Gaussian fitting capability is preserved by moving + the Gaussian fitting to a new class called GaussianAnalysis. Note that the + previous analysis can be used by doing: + + .. code:: python + + spec = ResonatorSpectroscopy(qubit, backend) + spec.analysis = GaussianAnalysis() + + where :code:`GaussianAnalysis` is imported from ``curve_analysis``. +features: + - | + This change introduces the new experiment + :py:class:`~qiskit_experiments.library.ResonatorSpectroscopy` to run spectroscopy + on readout resonators. This is done by attaching a custom pulse-schedule to + the measure instruction. Note that the resonator spectroscopy experiment may + cause errors on backends that do not support circuit instructions with measurement + schedules attached to them. + - | + Furthermore, a new data processing node + :py:class`~qiskit_experiments.data_processing.nodes.ToAbs` is introduced to + take the absolute value of the IQ points. This node is needed to analyse readout + resonator spectroscopy IQ data since it rotates around in the IQ plane but can + also be used in other contexts. diff --git a/test/data_processing/test_nodes.py b/test/data_processing/test_nodes.py index 2fb520c188..44a80295a3 100644 --- a/test/data_processing/test_nodes.py +++ b/test/data_processing/test_nodes.py @@ -15,10 +15,11 @@ from test.base import QiskitExperimentsTestCase import numpy as np -from uncertainties import unumpy as unp +from uncertainties import unumpy as unp, ufloat from qiskit_experiments.data_processing.nodes import ( SVD, + ToAbs, AverageData, MinMaxNormalize, Probability, @@ -137,6 +138,44 @@ def test_iq_averaging(self): ) +class TestToAbs(QiskitExperimentsTestCase): + """Test the ToAbs node.""" + + def test_simple(self): + """Simple test to check the it runs.""" + + data = [ + [[ufloat(2.0, np.nan), ufloat(2.0, np.nan)]], + [[ufloat(1.0, np.nan), ufloat(2.0, np.nan)]], + [[ufloat(2.0, 0.2), ufloat(3.0, 0.3)]], + ] + + processed = ToAbs()(np.array(data)) + + val = np.sqrt(2**2 + 3**2) + val_err = np.sqrt(2**2 * 0.2**2 + 2**2 * 0.3**2) / val + + expected = np.array( + [ + [ufloat(np.sqrt(8), np.nan)], + [ufloat(np.sqrt(5), np.nan)], + [ufloat(val, val_err)], + ] + ) + + np.testing.assert_array_almost_equal( + unp.nominal_values(processed), + unp.nominal_values(expected), + decimal=-8, + ) + + np.testing.assert_array_almost_equal( + unp.std_devs(processed), + unp.std_devs(expected), + decimal=-8, + ) + + class TestNormalize(QiskitExperimentsTestCase): """Test the normalization node.""" diff --git a/test/test_qubit_spectroscopy.py b/test/test_qubit_spectroscopy.py index 61a84a3ae4..7c44bbcf1f 100644 --- a/test/test_qubit_spectroscopy.py +++ b/test/test_qubit_spectroscopy.py @@ -36,18 +36,17 @@ def __init__( super().__init__(iq_cluster_centers, iq_cluster_width) - self.configuration().basis_gates = ["x"] - + self._configuration.basis_gates = ["x"] + self._configuration.timing_constraints = {"granularity": 16} self._linewidth = line_width self._freq_offset = freq_offset - super().__init__(iq_cluster_centers, iq_cluster_width) - def _compute_probability(self, circuit: QuantumCircuit) -> float: """Returns the probability based on the frequency.""" freq_shift = next(iter(circuit.calibrations["Spec"]))[1][0] delta_freq = freq_shift - self._freq_offset - return np.exp(-(delta_freq**2) / (2 * self._linewidth**2)) + + return np.abs(1 / (1 + 2.0j * delta_freq / self._linewidth)) class TestQubitSpectroscopy(QiskitExperimentsTestCase): @@ -70,6 +69,7 @@ def test_spectroscopy_end2end_classified(self): self.assertTrue(4.999e9 < value < 5.001e9) self.assertEqual(result.quality, "good") + self.assertEqual(str(result.device_components[0]), f"Q{qubit}") # Test if we find still find the peak when it is shifted by 5 MHz. backend = SpectroscopyBackend(line_width=2e6, freq_offset=5.0e6) @@ -87,7 +87,7 @@ def test_spectroscopy_end2end_classified(self): def test_spectroscopy_end2end_kerneled(self): """End to end test of the spectroscopy experiment on IQ data.""" - backend = SpectroscopyBackend(line_width=2e6) + backend = SpectroscopyBackend(line_width=2e6, iq_cluster_centers=(-1, -1, 1, 1)) qubit = 0 freq01 = backend.defaults().qubit_freq_est[qubit] frequencies = np.linspace(freq01 - 10.0e6, freq01 + 10.0e6, 21) @@ -102,7 +102,9 @@ def test_spectroscopy_end2end_kerneled(self): self.assertEqual(result.quality, "good") # Test if we find still find the peak when it is shifted by 5 MHz. - backend = SpectroscopyBackend(line_width=2e6, freq_offset=5.0e6) + backend = SpectroscopyBackend( + line_width=2e6, freq_offset=5.0e6, iq_cluster_centers=(-1, -1, 1, 1) + ) spec = QubitSpectroscopy(qubit, frequencies) expdata = spec.run(backend) diff --git a/test/test_resonator_spectroscopy.py b/test/test_resonator_spectroscopy.py new file mode 100644 index 0000000000..a4326f144d --- /dev/null +++ b/test/test_resonator_spectroscopy.py @@ -0,0 +1,96 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2021. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +"""Spectroscopy tests for resonator spectroscop experiment.""" + +from test.base import QiskitExperimentsTestCase +from typing import Tuple +import numpy as np +from ddt import ddt, data + +from qiskit import QuantumCircuit + +from qiskit_experiments.library import ResonatorSpectroscopy +from qiskit_experiments.test.mock_iq_backend import MockIQBackend + + +class ResonatorSpectroscopyBackend(MockIQBackend): + """A simple and primitive backend to test spectroscopy experiments.""" + + def __init__( + self, + line_width: float = 2e6, + freq_offset: float = 0.0, + iq_cluster_centers: Tuple[float, float, float, float] = (-1.0, 0.0, 0.0, 0.0), + iq_cluster_width: float = 0.2, + ): + """Initialize the spectroscopy backend.""" + + super().__init__(iq_cluster_centers, iq_cluster_width) + + self._linewidth = line_width + self._freq_offset = freq_offset + self._configuration.timing_constraints = {"granularity": 16} + + def _compute_probability(self, circuit: QuantumCircuit) -> float: + """Returns the probability based on the frequency.""" + freq_shift = next(iter(circuit.calibrations["measure"].values())).blocks[0].frequency + delta_freq = freq_shift - self._freq_offset + + return np.abs(1 / (1 + 2.0j * delta_freq / self._linewidth)) + + def _iq_phase(self, circuit: QuantumCircuit) -> float: + """Add a phase to the IQ point depending on how far we are from the resonance. + + This will cause the IQ points to rotate around in the IQ plane when we approach the + resonance which introduces and extra complication that the data processor needs to + properly handle. + """ + freq_shift = next(iter(circuit.calibrations["measure"].values())).blocks[0].frequency + delta_freq = freq_shift - self._freq_offset + + return delta_freq / self._linewidth + + +@ddt +class TestResonatorSpectroscopy(QiskitExperimentsTestCase): + """Tests for the resonator spectroscopy experiment.""" + + @data(-5e6, -2e6, 0, 1e6, 3e6) + def test_end_to_end(self, freq_shift): + """Test the experiment from end to end.""" + + qubit = 1 + backend = ResonatorSpectroscopyBackend(freq_offset=freq_shift) + res_freq = backend.defaults().meas_freq_est[qubit] + + frequencies = np.linspace(res_freq - 20e6, res_freq + 20e6, 51) + spec = ResonatorSpectroscopy(qubit, backend=backend, frequencies=frequencies) + + expdata = spec.run(backend) + result = expdata.analysis_results(1) + value = result.value.value + + self.assertAlmostEqual(value, res_freq + freq_shift, delta=0.1e6) + self.assertEqual(str(result.device_components[0]), f"R{qubit}") + + def test_experiment_config(self): + """Test converting to and from config works""" + exp = ResonatorSpectroscopy(1, frequencies=np.linspace(100, 150, 20) * 1e6) + loaded_exp = ResonatorSpectroscopy.from_config(exp.config()) + self.assertNotEqual(exp, loaded_exp) + self.assertTrue(self.json_equiv(exp, loaded_exp)) + + def test_roundtrip_serializable(self): + """Test round trip JSON serialization""" + exp = ResonatorSpectroscopy(1, frequencies=np.linspace(int(100e6), int(150e6), int(20e6))) + self.assertRoundTripSerializable(exp, self.json_equiv)