diff --git a/qiskit_experiments/calibration/backend_calibrations.py b/qiskit_experiments/calibration/backend_calibrations.py index 99414a8a19..eee4d3150b 100644 --- a/qiskit_experiments/calibration/backend_calibrations.py +++ b/qiskit_experiments/calibration/backend_calibrations.py @@ -40,19 +40,33 @@ class BackendCalibrations(Calibrations): any schedule. """ + __qubit_freq_parameter__ = "qubit_lo_freq" + __readout_freq_parameter__ = "meas_lo_freq" + def __init__(self, backend: Backend): """Setup an instance to manage the calibrations of a backend.""" - super().__init__(backend.configuration().control_channels) + if hasattr(backend.configuration(), "control_channels"): + control_channels = backend.configuration().control_channels + else: + control_channels = None + + super().__init__(control_channels) # Use the same naming convention as in backend.defaults() - self.qubit_freq = Parameter("qubit_lo_freq") - self.meas_freq = Parameter("meas_lo_freq") + self.qubit_freq = Parameter(self.__qubit_freq_parameter__) + self.meas_freq = Parameter(self.__readout_freq_parameter__) self._register_parameter(self.qubit_freq, ()) self._register_parameter(self.meas_freq, ()) self._qubits = set(range(backend.configuration().n_qubits)) self._backend = backend + for qubit, freq in enumerate(backend.defaults().qubit_freq_est): + self.add_parameter_value(freq, self.qubit_freq, qubit) + + for meas, freq in enumerate(backend.defaults().meas_freq_est): + self.add_parameter_value(freq, self.meas_freq, meas) + def _get_frequencies( self, element: FrequencyElement, @@ -70,8 +84,9 @@ def _get_frequencies( freqs = [] for qubit in self._qubits: - if ParameterKey(None, param, (qubit,)) in self._params: - freq = self.get_parameter_value(param, (qubit,), None, True, group, cutoff_date) + schedule = None # A qubit frequency is not attached to a schedule. + if ParameterKey(param, (qubit,), schedule) in self._params: + freq = self.get_parameter_value(param, (qubit,), schedule, True, group, cutoff_date) else: if element == FrequencyElement.READOUT: freq = self._backend.defaults().meas_freq_est[qubit] diff --git a/qiskit_experiments/calibration/calibration_key_types.py b/qiskit_experiments/calibration/calibration_key_types.py new file mode 100644 index 0000000000..a1bdcccbc5 --- /dev/null +++ b/qiskit_experiments/calibration/calibration_key_types.py @@ -0,0 +1,23 @@ +# 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. + +"""Types used by the calibration module.""" + +from typing import Union +from collections import namedtuple + +from qiskit.circuit import ParameterExpression + + +ParameterKey = namedtuple("ParameterKey", ["parameter", "qubits", "schedule"]) +ScheduleKey = namedtuple("ScheduleKey", ["schedule", "qubits"]) +ParameterValueType = Union[ParameterExpression, float, int, complex] diff --git a/qiskit_experiments/calibration/calibrations.py b/qiskit_experiments/calibration/calibrations.py index 228ff93808..015dc78f12 100644 --- a/qiskit_experiments/calibration/calibrations.py +++ b/qiskit_experiments/calibration/calibrations.py @@ -13,7 +13,7 @@ """Class to store and manage the results of calibration experiments.""" import os -from collections import namedtuple, defaultdict +from collections import defaultdict from datetime import datetime from typing import Any, Dict, Set, Tuple, Union, List, Optional import csv @@ -37,10 +37,11 @@ from qiskit.circuit import Parameter, ParameterExpression from qiskit_experiments.calibration.exceptions import CalibrationError from qiskit_experiments.calibration.parameter_value import ParameterValue - -ParameterKey = namedtuple("ParameterKey", ["parameter", "qubits", "schedule"]) -ScheduleKey = namedtuple("ScheduleKey", ["schedule", "qubits"]) -ParameterValueType = Union[ParameterExpression, float, int, complex] +from qiskit_experiments.calibration.calibration_key_types import ( + ParameterKey, + ParameterValueType, + ScheduleKey, +) class Calibrations: @@ -545,7 +546,7 @@ def get_parameter_value( raise CalibrationError(msg) # 5) Return the most recent parameter. - return max(candidates, key=lambda x: x.date_time).value + return max(enumerate(candidates), key=lambda x: (x[1].date_time, x[0]))[1].value def get_schedule( self, @@ -1025,7 +1026,7 @@ def _to_tuple(qubits: Union[str, int, Tuple[int, ...]]) -> Tuple[int, ...]: CalibrationError: If the given input does not conform to an int or tuple of ints. """ - if not qubits: + if qubits is None: return tuple() if isinstance(qubits, str): @@ -1037,6 +1038,9 @@ def _to_tuple(qubits: Union[str, int, Tuple[int, ...]]) -> Tuple[int, ...]: if isinstance(qubits, int): return (qubits,) + if isinstance(qubits, list): + return tuple(qubits) + if isinstance(qubits, tuple): if all(isinstance(n, int) for n in qubits): return qubits diff --git a/qiskit_experiments/calibration/experiments/rabi.py b/qiskit_experiments/calibration/experiments/rabi.py index 43c1abdf78..cdaf85a946 100644 --- a/qiskit_experiments/calibration/experiments/rabi.py +++ b/qiskit_experiments/calibration/experiments/rabi.py @@ -272,7 +272,7 @@ def circuits(self, backend: Optional[Backend] = None) -> List[QuantumCircuit]: assigned_circ = circuit.assign_parameters({param: amp}, inplace=False) assigned_circ.metadata = { "experiment_type": self._type, - "qubit": self.physical_qubits[0], + "qubits": (self.physical_qubits[0],), "xval": amp, "unit": "arb. unit", "amplitude": amp, diff --git a/qiskit_experiments/calibration/update_library.py b/qiskit_experiments/calibration/update_library.py new file mode 100644 index 0000000000..aeb45df7c5 --- /dev/null +++ b/qiskit_experiments/calibration/update_library.py @@ -0,0 +1,183 @@ +# 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. + +"""A library of experiment calibrations.""" + +from abc import ABC, abstractmethod +from datetime import datetime +from typing import List, Tuple, Union +import numpy as np + +from qiskit.circuit import Parameter +from qiskit.pulse import ScheduleBlock + +from qiskit_experiments.experiment_data import ExperimentData +from qiskit_experiments.calibration.backend_calibrations import BackendCalibrations +from qiskit_experiments.calibration.calibrations import Calibrations +from qiskit_experiments.calibration.parameter_value import ParameterValue +from qiskit_experiments.calibration.exceptions import CalibrationError +from qiskit_experiments.calibration.calibration_key_types import ParameterValueType + + +class BaseUpdater(ABC): + """A base class to update calibrations.""" + + def __init__(self): + """Updaters are not meant to be instantiated.""" + raise CalibrationError( + "Calibration updaters are not meant to be instantiated. The intended usage" + "is Updater.update(calibrations, exp_data, ...)." + ) + + @staticmethod + def _time_stamp(exp_data: ExperimentData) -> datetime: + """Helper method to extract the datetime.""" + all_times = exp_data.completion_times.values() + if all_times: + return max(all_times) + + return datetime.now() + + @classmethod + def _add_parameter_value( + cls, + cal: Calibrations, + exp_data: ExperimentData, + value: ParameterValueType, + param: Union[Parameter, str], + schedule: Union[ScheduleBlock, str] = None, + group: str = "default", + ): + """Update the calibrations with the given value. + + Args: + cal: The Calibrations instance to update. + exp_data: The ExperimentData instance that contains the result and the experiment data. + value: The value extracted by the subclasses in the :meth:`update` method. + param: The name of the parameter, or the parameter instance, which will receive an + updated value. + schedule: The ScheduleBlock instance or the name of the instance to which the parameter + is attached. + group: The calibrations group to update. + """ + + qubits = exp_data.data(0)["metadata"]["qubits"] + + param_value = ParameterValue( + value=value, + date_time=cls._time_stamp(exp_data), + group=group, + exp_id=exp_data.experiment_id, + ) + + cal.add_parameter_value(param_value, param, qubits, schedule) + + @classmethod + @abstractmethod + def update(cls, calibrations: BackendCalibrations, exp_data: ExperimentData, **options): + """Update the calibrations based on the data. + + Child update classes must implement this function. This function defines how the data + is extracted from an experiment and then used to update the values of one or more + parameters in the calibrations. + """ + + +class Frequency(BaseUpdater): + """Update frequencies.""" + + # pylint: disable=arguments-differ + @classmethod + def update( + cls, + calibrations: BackendCalibrations, + exp_data: ExperimentData, + result_index: int = -1, + group: str = "default", + parameter: str = BackendCalibrations.__qubit_freq_parameter__, + ): + """Update a qubit frequency from, e.g., QubitSpectroscopy. + + Args: + calibrations: The calibrations to update. + exp_data: The experiment data from which to update. + result_index: The result index to use, defaults to -1. + group: The calibrations group to update. Defaults to "default." + parameter: The name of the parameter to update. If it is not specified + this will default to the qubit frequency. + + Raises: + CalibrationError: If the analysis result does not contain a frequency variable. + """ + + from qiskit_experiments.characterization.qubit_spectroscopy import SpectroscopyAnalysis + + result = exp_data.analysis_result(result_index) + + if "freq" not in result["popt_keys"]: + raise CalibrationError( + f"{cls.__name__} updates from analysis classes such as " + f'{type(SpectroscopyAnalysis.__name__)} which report "freq" in popt.' + ) + + param = parameter + value = result["popt"][result["popt_keys"].index("freq")] + + cls._add_parameter_value(calibrations, exp_data, value, param, schedule=None, group=group) + + +class Amplitude(BaseUpdater): + """Update pulse amplitudes.""" + + # pylint: disable=arguments-differ + @classmethod + def update( + cls, + calibrations: Calibrations, + exp_data: ExperimentData, + result_index: int = -1, + group: str = "default", + angles_schedules: List[Tuple[float, str, Union[str, ScheduleBlock]]] = None, + ): + """Update the amplitude of pulses. + + Args: + calibrations: The calibrations to update. + exp_data: The experiment data from which to update. + result_index: The result index to use, defaults to -1. + group: The calibrations group to update. Defaults to "default." + angles_schedules: A list of tuples specifying which angle to update for which + pulse schedule. Each tuple is of the form: (angle, parameter_name, + schedule). Here, angle is the rotation angle for which to extract the amplitude, + parameter_name is the name of the parameter whose value is to be updated, and + schedule is the schedule or its name that contains the parameter. + + Raises: + CalibrationError: If the experiment is not of the supported type. + """ + from qiskit_experiments.calibration.experiments.rabi import Rabi + + if angles_schedules is None: + angles_schedules = [(np.pi, "amp", "xp")] + + if isinstance(exp_data.experiment, Rabi): + result = exp_data.analysis_result(result_index) + + freq = result["popt"][result["popt_keys"].index("freq")] + rate = 2 * np.pi * freq + + for angle, param, schedule in angles_schedules: + value = np.round(angle / rate, decimals=8) + + cls._add_parameter_value(calibrations, exp_data, value, param, schedule, group) + else: + raise CalibrationError(f"{cls.__name__} updates from {type(Rabi.__name__)}.") diff --git a/qiskit_experiments/characterization/qubit_spectroscopy.py b/qiskit_experiments/characterization/qubit_spectroscopy.py index 34acf7814d..d22f9dc38b 100644 --- a/qiskit_experiments/characterization/qubit_spectroscopy.py +++ b/qiskit_experiments/characterization/qubit_spectroscopy.py @@ -316,10 +316,12 @@ def circuits(self, backend: Optional[Backend] = None): if not self._absolute: freq += center_freq + freq = np.round(freq, decimals=3) + assigned_circ = circuit.assign_parameters({freq_param: freq}, inplace=False) assigned_circ.metadata = { "experiment_type": self._type, - "qubit": self.physical_qubits[0], + "qubits": (self.physical_qubits[0],), "xval": freq, "unit": "Hz", "amplitude": self.experiment_options.amp, diff --git a/qiskit_experiments/experiment_data.py b/qiskit_experiments/experiment_data.py index 032cf13442..f60f4b1ba7 100644 --- a/qiskit_experiments/experiment_data.py +++ b/qiskit_experiments/experiment_data.py @@ -17,6 +17,7 @@ import os import uuid from collections import OrderedDict +from datetime import datetime from qiskit.result import Result from qiskit.providers import Backend @@ -116,6 +117,16 @@ def job_ids(self) -> List[str]: """ return list(self._jobs.keys()) + @property + def completion_times(self) -> Dict[str, datetime]: + """Returns the completion times of the jobs.""" + job_times = {} + for job_id, job in self._jobs.items(): + if job is not None and "COMPLETED" in job.time_per_step(): + job_times[job_id] = job.time_per_step().get("COMPLETED") + + return job_times + @property def backend(self) -> Backend: """Return backend. diff --git a/test/calibration/test_calibrations.py b/test/calibration/test_calibrations.py index da7e085a57..0e9d14def1 100644 --- a/test/calibration/test_calibrations.py +++ b/test/calibration/test_calibrations.py @@ -253,9 +253,6 @@ def test_qubit_input(self): with self.assertRaises(CalibrationError): self.cals.get_parameter_value("amp", "(1, a)", "xp") - with self.assertRaises(CalibrationError): - self.cals.get_parameter_value("amp", [3], "xp") - class TestOverrideDefaults(QiskitTestCase): """ @@ -441,6 +438,32 @@ def test_parameter_filtering(self): self.assertEqual(len(amp_values), 2) +class TestConcurrentParameters(QiskitTestCase): + """Test a particular edge case with the time in the parameter values.""" + + def test_concurrent_values(self): + """ + Ensure that if the max time has multiple entries we take the most recent appended one. + """ + + cals = Calibrations() + + amp = Parameter("amp") + ch0 = Parameter("ch0") + with pulse.build(name="xp") as xp: + pulse.play(Gaussian(160, amp, 40), DriveChannel(ch0)) + + cals.add_schedule(xp) + + date_time = datetime.strptime("15/09/19 10:21:35", "%d/%m/%y %H:%M:%S") + + cals.add_parameter_value(ParameterValue(0.25, date_time), "amp", (3,), "xp") + cals.add_parameter_value(ParameterValue(0.35, date_time), "amp", (3,), "xp") + cals.add_parameter_value(ParameterValue(0.45, date_time), "amp", (3,), "xp") + + self.assertEqual(cals.get_parameter_value("amp", 3, "xp"), 0.45) + + class TestMeasurements(QiskitTestCase): """Test that schedules on measure channels are handled properly.""" diff --git a/test/calibration/test_update_library.py b/test/calibration/test_update_library.py new file mode 100644 index 0000000000..40c3efde07 --- /dev/null +++ b/test/calibration/test_update_library.py @@ -0,0 +1,108 @@ +# 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. + +"""Test the calibration update library.""" + +from test.calibration.experiments.test_rabi import RabiBackend +from test.test_qubit_spectroscopy import SpectroscopyBackend +import numpy as np + +from qiskit.circuit import Parameter +from qiskit.test import QiskitTestCase +from qiskit.qobj.utils import MeasLevel +import qiskit.pulse as pulse +from qiskit.test.mock import FakeAthens + +from qiskit_experiments.calibration.experiments.rabi import Rabi +from qiskit_experiments.calibration.calibrations import Calibrations +from qiskit_experiments.calibration.exceptions import CalibrationError +from qiskit_experiments.calibration.update_library import Frequency, Amplitude +from qiskit_experiments.calibration.backend_calibrations import BackendCalibrations +from qiskit_experiments.characterization.qubit_spectroscopy import QubitSpectroscopy +from qiskit_experiments.analysis import get_opt_value + + +class TestCalibrationsUpdate(QiskitTestCase): + """Test the update functions in the update library.""" + + def test_amplitude(self): + """Test amplitude update from Rabi.""" + + cals = Calibrations() + + amp = Parameter("amp") + chan = Parameter("ch0") + with pulse.build(name="xp") as xp: + pulse.play(pulse.Gaussian(duration=160, amp=amp, sigma=40), pulse.DriveChannel(chan)) + + amp = Parameter("amp") + with pulse.build(name="x90p") as x90p: + pulse.play(pulse.Gaussian(duration=160, amp=amp, sigma=40), pulse.DriveChannel(chan)) + + cals.add_schedule(xp) + cals.add_schedule(x90p) + + rabi = Rabi(3) + rabi.set_experiment_options(amplitudes=np.linspace(-0.95, 0.95, 21)) + exp_data = rabi.run(RabiBackend()) + + for qubit in [0, 3]: + with self.assertRaises(CalibrationError): + cals.get_schedule("xp", qubits=qubit) + + to_update = [(np.pi, "amp", "xp"), (np.pi / 2, "amp", x90p)] + + self.assertEqual(len(cals.parameters_table()), 0) + + Amplitude.update(cals, exp_data, angles_schedules=to_update) + + with self.assertRaises(CalibrationError): + cals.get_schedule("xp", qubits=0) + + self.assertEqual(len(cals.parameters_table()), 2) + + # Now check the corresponding schedules + result = exp_data.analysis_result(-1) + rate = 2 * np.pi * result["popt"][1] + amp = np.round(np.pi / rate, decimals=8) + with pulse.build(name="xp") as expected: + pulse.play(pulse.Gaussian(160, amp, 40), pulse.DriveChannel(3)) + + self.assertEqual(cals.get_schedule("xp", qubits=3), expected) + + amp = np.round(0.5 * np.pi / rate, decimals=8) + with pulse.build(name="xp") as expected: + pulse.play(pulse.Gaussian(160, amp, 40), pulse.DriveChannel(3)) + + self.assertEqual(cals.get_schedule("x90p", qubits=3), expected) + + def test_frequency(self): + """Test calibrations update from spectroscopy.""" + + backend = SpectroscopyBackend(line_width=2e6, freq_offset=5.0e6) + + spec = QubitSpectroscopy(3, np.linspace(-10.0, 10.0, 21), unit="MHz") + spec.set_run_options(meas_level=MeasLevel.CLASSIFIED) + exp_data = spec.run(backend) + result = exp_data.analysis_result(0) + + value = get_opt_value(result, "freq") + + self.assertTrue(value < 5.1e6) + self.assertTrue(value > 4.9e6) + self.assertEqual(result["quality"], "computer_good") + + # Test the integration with the BackendCalibrations + cals = BackendCalibrations(FakeAthens()) + self.assertNotEqual(cals.get_qubit_frequencies()[3], result["popt"][2]) + Frequency.update(cals, exp_data) + self.assertEqual(cals.get_qubit_frequencies()[3], result["popt"][2]) diff --git a/test/mock_job.py b/test/mock_job.py index c3c5163180..0c0b7d1641 100644 --- a/test/mock_job.py +++ b/test/mock_job.py @@ -15,6 +15,9 @@ Mock Job class for test backends """ import uuid +from datetime import datetime +from typing import Dict + from qiskit.providers import JobV1 as Job from qiskit.providers import JobStatus from qiskit.result import Result @@ -42,6 +45,11 @@ def cancel(self): """Attempt to cancel the job.""" pass + @staticmethod + def time_per_step() -> Dict[str, datetime]: + """Return the completion time.""" + return {"COMPLETED": datetime.now()} + def status(self): """Return the status of the job, among the values of ``JobStatus``.""" return JobStatus.DONE diff --git a/test/test_qubit_spectroscopy.py b/test/test_qubit_spectroscopy.py index e49c16f1a9..223f02c79f 100644 --- a/test/test_qubit_spectroscopy.py +++ b/test/test_qubit_spectroscopy.py @@ -73,7 +73,8 @@ def test_spectroscopy_end2end_classified(self): spec = QubitSpectroscopy(3, np.linspace(-10.0, 10.0, 21), unit="MHz") spec.set_run_options(meas_level=MeasLevel.CLASSIFIED) - result = spec.run(backend).analysis_result(0) + exp_data = spec.run(backend) + result = exp_data.analysis_result(0) value = get_opt_value(result, "freq")