From ecab772aa82965cb885b3a8a948c617d87ef2aea Mon Sep 17 00:00:00 2001 From: Will Shanks Date: Mon, 22 Jan 2024 18:20:53 -0500 Subject: [PATCH 01/15] Temporarily pin pandas to <2.2 (#1364) 2.2 deprecates APIs currently used by ScatterTable. This is a temporary pin to get CI working again until ScatterTable can be updated. --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 54ea5ea51c..2bda6ad888 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,4 +6,4 @@ matplotlib>=3.4 uncertainties lmfit rustworkx -pandas>=1.1.5 +pandas>=1.1.5,<2.2.0 From b60547299cdaec5d6c1e90b35b3f0c4a921b09f1 Mon Sep 17 00:00:00 2001 From: Arnau Casau <47946624+arnaucasau@users.noreply.github.com> Date: Tue, 23 Jan 2024 01:10:48 +0100 Subject: [PATCH 02/15] Deploy docs to GitHub Pages (#1356) ## Summary This PR changes the `docs.yml` and `docs_dev.yml` workflows to deploy the documentation to GitHub Pages to migrate the project away from the qiskit.org domain. More information about the change and a quick guide to set up GitHub pages can be found at: https://github.com/Qiskit/ecosystem/issues/578 Reminder task for maintainers to update the docs link in the About text for the repo on the main page once this change happens and the docs are re-deployed. This will need backporting to stable branches. - [ ] Update About text doc link ## Details The PR adds three new workflows, replacing the ones used to deploy to qiskit.org (`docs.yml` and `docs_dev.yml`). The new workflows replicate the same structure used by the old workflows when the documentation was pushed to qiskit.org using the `rclone` command. The documentation will be deployed into the `gh-pages` branch where we will find the latest release in the root of the branch. In addition, we will have one folder named `stable/` for all the previous releases and a second one for the dev docs named `dev/`. gh-pages branch structure example: ``` |- unversioned docs |- stable/ |- stable version1/ |- stable docs |- stable version2/ |- stable docs |- dev/ |- dev docs ``` The three new workflows are: **docs_release.yml** (Docs Publish): This workflow allows us to deploy the docs to GitHub pages into the root of the `gh-pages` branch. This will be the unversioned release we will find on the website. **docs_stable.yml** (Stable Docs Publish): This workflow deploys the release we choose into the `stable/` folder. The documentation will be accessible using the `Previous Releases` collapsible menu in the sidebar. **docs_dev.yml** (Dev Docs Publish): Same workflow as `docs_dev.yml` but deploying the documentation to the `dev/` folder in the `gh-pages` branch. The `docs_release.yml` and the `docs_stable.yml` workflows split the current `docs.yml` workflow into two phases. This change is useful to have more control over what versions we want to deploy. With the old workflow, we needed to deploy everything in a specific order to have the latest version as the unversioned one on the website. With the two workflows, we will be able to re-deploy stable versions when necessary. --------- Co-authored-by: Helena Zhang --- .github/workflows/docs_dev.yml | 15 ++++--- .../workflows/{docs.yml => docs_release.yml} | 16 ++++--- .github/workflows/docs_stable.yml | 38 +++++++++++++++++ docs/conf.py | 2 +- tools/deploy_documentation.sh | 39 ------------------ tools/deploy_documentation_dev.sh | 32 -------------- tools/rclone.conf.enc | Bin 304 -> 0 bytes 7 files changed, 59 insertions(+), 83 deletions(-) rename .github/workflows/{docs.yml => docs_release.yml} (61%) create mode 100644 .github/workflows/docs_stable.yml delete mode 100755 tools/deploy_documentation.sh delete mode 100755 tools/deploy_documentation_dev.sh delete mode 100644 tools/rclone.conf.enc diff --git a/.github/workflows/docs_dev.yml b/.github/workflows/docs_dev.yml index 62efff9243..d8b9fece87 100644 --- a/.github/workflows/docs_dev.yml +++ b/.github/workflows/docs_dev.yml @@ -21,9 +21,12 @@ jobs: python -m pip install --upgrade pip pip install -U virtualenv setuptools wheel tox sudo apt-get install graphviz pandoc - - name: Build and publish - env: - encrypted_rclone_key: ${{ secrets.encrypted_rclone_key }} - encrypted_rclone_iv: ${{ secrets.encrypted_rclone_iv }} - run: | - tools/deploy_documentation_dev.sh \ No newline at end of file + - name: Build docs dev + run: EXPERIMENTS_DEV_DOCS=1 PROD_BUILD=1 RELEASE_STRING=`git describe` tox -edocs + - name: Bypass Jekyll Processing # Necessary for setting the correct css path + run: touch docs/_build/html/.nojekyll + - name: Deploy + uses: JamesIves/github-pages-deploy-action@v4 + with: + folder: docs/_build/html/ + target-folder: dev/ diff --git a/.github/workflows/docs.yml b/.github/workflows/docs_release.yml similarity index 61% rename from .github/workflows/docs.yml rename to .github/workflows/docs_release.yml index 0607301851..e201841b40 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs_release.yml @@ -22,10 +22,16 @@ jobs: python -m pip install --upgrade pip pip install -U virtualenv setuptools wheel tox sudo apt-get install graphviz pandoc - - name: Build and publish + - name: Build docs env: - encrypted_rclone_key: ${{ secrets.encrypted_rclone_key }} - encrypted_rclone_iv: ${{ secrets.encrypted_rclone_iv }} QISKIT_DOCS_BUILD_TUTORIALS: 'always' - run: | - tools/deploy_documentation.sh + run: EXPERIMENTS_DEV_DOCS=1 PROD_BUILD=1 tox -edocs + - name: Bypass Jekyll Processing # Necessary for setting the correct css path + run: touch docs/_build/html/.nojekyll + - name: Deploy + uses: JamesIves/github-pages-deploy-action@v4 + with: + folder: docs/_build/html/ + clean-exclude: | + stable/* + dev/* diff --git a/.github/workflows/docs_stable.yml b/.github/workflows/docs_stable.yml new file mode 100644 index 0000000000..58a2767d90 --- /dev/null +++ b/.github/workflows/docs_stable.yml @@ -0,0 +1,38 @@ +name: Stable Docs Publish +on: + workflow_dispatch: + push: + tags: + - "*" + +jobs: + deploy: + if: github.repository_owner == 'Qiskit-Extensions' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.8' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -U virtualenv setuptools wheel tox + sudo apt-get install graphviz pandoc + - name: Build docs stable + env: + QISKIT_DOCS_BUILD_TUTORIALS: 'always' + run: EXPERIMENTS_DEV_DOCS=1 PROD_BUILD=1 tox -e docs + - name: Bypass Jekyll Processing # Necessary for setting the correct css path + run: touch docs/_build/html/.nojekyll + - name: Set current version + run: | + echo "version=$(git describe --abbrev=0 | cut -d'.' -f1,2)" >> "$GITHUB_ENV" + - name: Deploy stable + uses: JamesIves/github-pages-deploy-action@v4 + with: + folder: docs/_build/html + target-folder: stable/${{ env.version }} \ No newline at end of file diff --git a/docs/conf.py b/docs/conf.py index b2165afb6b..33130e4a65 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -162,7 +162,7 @@ html_title = f"{project} {release}" -docs_url_prefix = "ecosystem/experiments" +docs_url_prefix = "qiskit-experiments" html_last_updated_fmt = "%Y/%m/%d" diff --git a/tools/deploy_documentation.sh b/tools/deploy_documentation.sh deleted file mode 100755 index db60323c18..0000000000 --- a/tools/deploy_documentation.sh +++ /dev/null @@ -1,39 +0,0 @@ -#!/bin/bash - -# This code is part of Qiskit. -# -# (C) Copyright IBM 2018, 2023. -# -# 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. - -# Script for pushing the documentation to the qiskit.org/ecosystem -set -e - -curl https://downloads.rclone.org/rclone-current-linux-amd64.deb -o rclone.deb -sudo apt-get install -y ./rclone.deb - -RCLONE_CONFIG_PATH=$(rclone config file | tail -1) - -# Build the documentation. -EXPERIMENTS_DEV_DOCS=1 PROD_BUILD=1 tox -edocs - -echo "show current dir: " -pwd - -CURRENT_TAG=`git describe --abbrev=0` -IFS=. read -ra VERSION <<< "$CURRENT_TAG" -STABLE_VERSION=${VERSION[0]}.${VERSION[1]} -echo "Building for stable version $STABLE_VERSION" - -# Push to qiskit.org/ecosystem -openssl aes-256-cbc -K $encrypted_rclone_key -iv $encrypted_rclone_iv -in tools/rclone.conf.enc -out $RCLONE_CONFIG_PATH -d -echo "Pushing built docs to website" -rclone sync --progress --exclude-from ./tools/other-builds.txt ./docs/_build/html IBMCOS:qiskit-org-web-resources/ecosystem/experiments -echo "Pushing $STABLE_VERSION built docs to website" -rclone sync --progress ./docs/_build/html IBMCOS:qiskit-org-web-resources/ecosystem/experiments/stable/"$STABLE_VERSION" diff --git a/tools/deploy_documentation_dev.sh b/tools/deploy_documentation_dev.sh deleted file mode 100755 index 1f3255fc03..0000000000 --- a/tools/deploy_documentation_dev.sh +++ /dev/null @@ -1,32 +0,0 @@ -#!/bin/bash - -# This code is part of Qiskit. -# -# (C) Copyright IBM 2018, 2019. -# -# 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. - -# Script for pushing the documentation to the qiskit.org repository. -set -e - -curl https://downloads.rclone.org/rclone-current-linux-amd64.deb -o rclone.deb -sudo apt-get install -y ./rclone.deb - -RCLONE_CONFIG_PATH=$(rclone config file | tail -1) - -# Build the documentation. -EXPERIMENTS_DEV_DOCS=1 PROD_BUILD=1 RELEASE_STRING=`git describe` tox -edocs - -echo "show current dir: " -pwd - -# Push to qiskit.org website -openssl aes-256-cbc -K $encrypted_rclone_key -iv $encrypted_rclone_iv -in tools/rclone.conf.enc -out $RCLONE_CONFIG_PATH -d -echo "Pushing built docs to dev website" -rclone sync --progress ./docs/_build/html IBMCOS:qiskit-org-web-resources/ecosystem/experiments/dev diff --git a/tools/rclone.conf.enc b/tools/rclone.conf.enc deleted file mode 100644 index 985bd728abc0a83d8ea98cd4d9561b7fa124842f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 304 zcmV-00nh$7&RTYTNLa46ND6UrOuMoPNp}L^N21;+KWICI2ddxLf?x*g*GAzexAhvW z5rTO-?xi4$c>vaY~!DfD~lI0H5)o5;H>qj7M~)ZT{14Fvc91%J)Ycl~B`S zR;dTAK}Qz7!C#ExhwZKgVKh_&DPch2pvl7`Df`TB7^fDm2w+?}@Ltb_s9A^-JfyD- zcV@+wP8bfhSO=k!OfNS+tVO*B2xkEIky>2YRz;z0Ar#-=dP|4$ar~If5$=F}D=bc3 C!HCcR From b66f662702ae6ff9295d608d15d2f80d23cbc642 Mon Sep 17 00:00:00 2001 From: Toshinari Itoko <15028342+itoko@users.noreply.github.com> Date: Wed, 24 Jan 2024 00:01:41 +0900 Subject: [PATCH 03/15] Fix Clifford synthesis not to use the old PassManager API (#1363) ### Summary Change to use the new PassManager API to make Clifford synthesis for 3Q+ RB work with qiskit 1.0. ### Details and comments The old interface of `PassManager.append` has been removed since qiskit 1.0. It's been used to transpile 3Q+ RB circuits. This PR changes to use the new interface that uses `ConditionalController`. --------- Co-authored-by: Will Shanks --- .../library/randomized_benchmarking/clifford_synthesis.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/qiskit_experiments/library/randomized_benchmarking/clifford_synthesis.py b/qiskit_experiments/library/randomized_benchmarking/clifford_synthesis.py index 29873fb37f..d1a88d7480 100644 --- a/qiskit_experiments/library/randomized_benchmarking/clifford_synthesis.py +++ b/qiskit_experiments/library/randomized_benchmarking/clifford_synthesis.py @@ -19,6 +19,7 @@ from qiskit.circuit import QuantumCircuit, Operation from qiskit.circuit.equivalence_library import SessionEquivalenceLibrary as sel from qiskit.exceptions import QiskitError +from qiskit.passmanager.flow_controllers import ConditionalController from qiskit.synthesis.clifford import synth_clifford_full from qiskit.transpiler import PassManager, CouplingMap, Layout, Target from qiskit.transpiler.passes import ( @@ -94,8 +95,8 @@ def _direction_condition(property_set): undo_layout_change, BasisTranslator(sel, basis_gates), CheckGateDirection(coupling_map), + ConditionalController(GateDirection(coupling_map), condition=_direction_condition), + Optimize1qGatesDecomposition(basis=basis_gates), ] ) - pm.append([GateDirection(coupling_map)], condition=_direction_condition) - pm.append([Optimize1qGatesDecomposition(basis=basis_gates)]) return pm.run(circ) From 91ec4ead58a919fc42ff9bcc132a15241de56e67 Mon Sep 17 00:00:00 2001 From: Will Shanks Date: Tue, 23 Jan 2024 17:18:14 -0500 Subject: [PATCH 04/15] Update old Aer imports in documentation (#1370) These changes were overlooked in c536026ddb0c8bbd21b03578989c84eb113117ad --- docs/manuals/verification/quantum_volume.rst | 1 - docs/tutorials/custom_experiment.rst | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/manuals/verification/quantum_volume.rst b/docs/manuals/verification/quantum_volume.rst index 2b01b515fa..5c06e5f1e5 100644 --- a/docs/manuals/verification/quantum_volume.rst +++ b/docs/manuals/verification/quantum_volume.rst @@ -32,7 +32,6 @@ z_value = 2), and at least 100 trials have been ran. from qiskit_experiments.framework import BatchExperiment from qiskit_experiments.library import QuantumVolume - from qiskit import Aer from qiskit_aer import AerSimulator # For simulation diff --git a/docs/tutorials/custom_experiment.rst b/docs/tutorials/custom_experiment.rst index c97d07b9ce..a094d9d91a 100644 --- a/docs/tutorials/custom_experiment.rst +++ b/docs/tutorials/custom_experiment.rst @@ -564,7 +564,7 @@ To test our code, we first simulate a noisy backend with asymmetric readout erro .. jupyter-execute:: - from qiskit.providers.aer import AerSimulator, noise + from qiskit_aer import AerSimulator, noise backend_ideal = AerSimulator() @@ -653,4 +653,4 @@ unaffected by the added randomized measurements, which use its own classical reg qc.cx(i-1, i) exp = RandomizedMeasurement(qc, num_samples=num_samples) - exp.circuits()[0].draw(output="mpl", style="iqp") \ No newline at end of file + exp.circuits()[0].draw(output="mpl", style="iqp") From e648bc5ad7e898ca799cf997a20aae38bb9d9c7d Mon Sep 17 00:00:00 2001 From: Will Shanks Date: Wed, 24 Jan 2024 20:51:39 -0500 Subject: [PATCH 05/15] Remove qiskit-terra and qiskit-ibm-provider when testing against qiskit main (#1372) Other packages that depend on qiskit<1.0 will pull in qiskit and qiskit-terra together and qiskit-terra will conflict with qiskit main, so we remove it before installing qiskit main. qiskit-ibm-provider 0.8.0 (current stable) tries to import `qiskit.extensions` for a basic `import qiskit_ibm_provider` which does not exist in qiskit main any more. qiskit-ibm-provider registers a qiskit plugin which causes it to get automatically imported under some conditions, triggering a warning in jupyter execute blocks which then cause a strict sphinx build to fail. --- tox.ini | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/tox.ini b/tox.ini index 5e12d82c8f..9ae746d1e8 100644 --- a/tox.ini +++ b/tox.ini @@ -35,10 +35,13 @@ commands = [testenv:qiskit-main] usedevelop = True install_command = pip install -U {opts} {packages} -deps = - git+https://github.com/Qiskit/qiskit - -r{toxinidir}/requirements-dev.txt - -r{toxinidir}/requirements-extras.txt +commands_pre = + # We must remove qiskit-terra because some dependencies pull it in and it + # conflicts with qiskit main. We must remove qiskit-ibm-provider because it gives + # an import error for qiskit main and it gets automatically imported by qiskit's + # plugin mechanism. + pip uninstall -y qiskit qiskit-terra qiskit-ibm-provider + pip install git+https://github.com/Qiskit/qiskit commands = stestr run {posargs} @@ -102,10 +105,10 @@ passenv = PROD_BUILD RELEASE_STRING VERSION_STRING -deps = - git+https://github.com/Qiskit/qiskit - -r{toxinidir}/requirements-dev.txt - -r{toxinidir}/requirements-extras.txt +commands_pre = + # See comment for qiskit-main + pip uninstall -y qiskit qiskit-terra qiskit-ibm-provider + pip install git+https://github.com/Qiskit/qiskit commands = sphinx-build -j auto -T -W --keep-going -b html {posargs} docs/ docs/_build/html From 4ea9cf630aea71e868857b2dfd1db2731e24ebae Mon Sep 17 00:00:00 2001 From: Helena Zhang Date: Thu, 25 Jan 2024 12:04:41 -0500 Subject: [PATCH 06/15] Deprecate `qiskit-ibm-provider` in favor of `qiskit-ibm-runtime` (#1371) ### Summary Closes #1346 by adding logic to handle the HGP and service for a `qiskit-ibm-runtime` backend object and replacing mentions of `qiskit-ibm-provider` in docs with `qiskit-ibm-runtime` syntax. ### Details and comments - Updates sessions howto with the new sessions syntax supported by `qiskit-ibm-runtime` (requires >=15.0) - Removes old code to handle `qiskit-ibmq-runtime` backends - Replaces non-free/deprecated backends in docs with `ibm-osaka` --------- Co-authored-by: Will Shanks --- docs/conf.py | 1 - docs/howtos/cloud_service.rst | 13 ++-- docs/howtos/rerun_analysis.rst | 6 +- docs/howtos/runtime_sessions.rst | 29 +++++---- .../framework/experiment_data.py | 34 +++++++---- ...ime-provider-support-5358b72ec0035419.yaml | 11 ++++ requirements-extras.txt | 1 - .../test_db_experiment_data.py | 61 +++++++++++-------- 8 files changed, 95 insertions(+), 61 deletions(-) create mode 100644 releasenotes/notes/runtime-provider-support-5358b72ec0035419.yaml diff --git a/docs/conf.py b/docs/conf.py index 33130e4a65..ff73d37f04 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -171,7 +171,6 @@ "matplotlib": ("https://matplotlib.org/stable/", None), "qiskit": ("https://qiskit.org/documentation/", None), "uncertainties": ("https://pythonhosted.org/uncertainties", None), - "qiskit_ibm_provider": ("https://qiskit.org/ecosystem/ibm-provider/", None), "qiskit_aer": ("https://qiskit.org/ecosystem/aer", None), "qiskit_dynamics": ("https://qiskit.org/documentation/dynamics", None), "qiskit_ibm_runtime": ("https://qiskit.org/ecosystem/ibm-runtime/", None), diff --git a/docs/howtos/cloud_service.rst b/docs/howtos/cloud_service.rst index 11bce3f7f8..b69fcd5c98 100644 --- a/docs/howtos/cloud_service.rst +++ b/docs/howtos/cloud_service.rst @@ -18,8 +18,9 @@ Saving ~~~~~~ .. note:: - This guide requires :mod:`qiskit-ibm-provider`. For how to migrate from the deprecated :mod:`qiskit-ibmq-provider` to :mod:`qiskit-ibm-provider`, - consult the `migration guide `_.\ + This guide requires :mod:`qiskit-ibm-runtime` version 0.15 and up, which can be installed with ``python -m pip install qiskit-ibm-runtime``. + For how to migrate from the older :mod:`qiskit-ibm-provider` to :mod:`qiskit-ibm-runtime`, + consult the `migration guide `_.\ You must run the experiment on a real IBM backend and not a simulator to be able to save the experiment data. This is done by calling @@ -27,12 +28,12 @@ backend and not a simulator to be able to save the experiment data. This is done .. jupyter-input:: - from qiskit_ibm_provider import IBMProvider + from qiskit_ibm_runtime import QiskitRuntimeService from qiskit_experiments.library.characterization import T1 import numpy as np - provider = IBMProvider() - backend = provider.get_backend("ibmq_lima") + service = QiskitRuntimeService(channel="ibm_quantum") + backend = service.backend("ibm_osaka") t1_delays = np.arange(1e-6, 600e-6, 50e-6) @@ -142,7 +143,7 @@ The :meth:`~.ExperimentData.auto_save` feature automatically saves changes to th .. jupyter-output:: You can view the experiment online at https://quantum.ibm.com/experiments/cdaff3fa-f621-4915-a4d8-812d05d9a9ca - + Setting ``auto_save = True`` works by triggering :meth:`.ExperimentData.save`. diff --git a/docs/howtos/rerun_analysis.rst b/docs/howtos/rerun_analysis.rst index b1a7107b6f..532d968cf2 100644 --- a/docs/howtos/rerun_analysis.rst +++ b/docs/howtos/rerun_analysis.rst @@ -12,9 +12,9 @@ Solution -------- .. note:: - Some of this guide uses the :mod:`qiskit-ibm-provider` package. For how to migrate from - the deprecated ``qiskit-ibmq-provider`` to ``qiskit-ibm-provider``, consult the - `migration guide `_.\ + This guide requires :mod:`qiskit-ibm-runtime` version 0.15 and up, which can be installed with ``python -m pip install qiskit-ibm-runtime``. + For how to migrate from the older :mod:`qiskit-ibm-provider` to :mod:`qiskit-ibm-runtime`, + consult the `migration guide `_.\ Once you recreate the exact experiment you ran and all of its parameters and options, you can call the :meth:`.add_jobs` method with a list of :class:`Job diff --git a/docs/howtos/runtime_sessions.rst b/docs/howtos/runtime_sessions.rst index a9519ba3a2..b7ecc63383 100644 --- a/docs/howtos/runtime_sessions.rst +++ b/docs/howtos/runtime_sessions.rst @@ -10,7 +10,12 @@ You want to run experiments in a `Runtime session Solution -------- -Use the :class:`~qiskit_ibm_provider.IBMBackend` object in ``qiskit-ibm-provider``, which supports sessions. +.. note:: + This guide requires :mod:`qiskit-ibm-runtime` version 0.15 and up, which can be installed with ``python -m pip install qiskit-ibm-runtime``. + For how to migrate from the older :mod:`qiskit-ibm-provider` to :mod:`qiskit-ibm-runtime`, + consult the `migration guide `_.\ + +Use the :class:`~qiskit_ibm_runtime.IBMBackend` object in :mod:`qiskit-ibm-runtime`, which supports sessions. In this example, we will set the ``max_circuits`` property to an artificially low value so that the experiment will be split into multiple jobs that run sequentially in a single session. When running real experiments with a @@ -18,24 +23,22 @@ large number of circuits that can't fit in a single job, it may be helpful to fo .. jupyter-input:: - from qiskit_ibm_provider import IBMProvider + from qiskit_ibm_runtime import QiskitRuntimeService from qiskit_experiments.library.tomography import ProcessTomography from qiskit import QuantumCircuit - provider = IBMProvider() - backend = provider.get_backend("ibm_nairobi") + service = QiskitRuntimeService(channel="ibm_quantum") + backend = service.backend("ibm_osaka") qc = QuantumCircuit(1) qc.x(0) - with backend.open_session() as session: - exp = ProcessTomography(qc) - exp.set_experiment_options(max_circuits=3) - exp_data = exp.run(backend) - exp_data.block_for_results() - # Calling cancel because session.close() is not available for qiskit-ibm-provider<=0.7.2. - # It is safe to call cancel since block_for_results() ensures there are no outstanding jobs - # still running that would be canceled. - session.cancel() + backend.open_session() + exp = ProcessTomography(qc) + # Artificially lower circuits per job, adjust value for your own application + exp.set_experiment_options(max_circuits=3) + exp_data = exp.run(backend) + # This will prevent further jobs from being submitted without terminating current jobs + backend.close_session() Note that runtime primitives are not currently supported natively in Qiskit Experiments, so the ``backend.run()`` path is required to run experiments. diff --git a/qiskit_experiments/framework/experiment_data.py b/qiskit_experiments/framework/experiment_data.py index b6dfff5793..9913ec24ec 100644 --- a/qiskit_experiments/framework/experiment_data.py +++ b/qiskit_experiments/framework/experiment_data.py @@ -32,6 +32,7 @@ import sys import json import traceback +import warnings import numpy as np import pandas as pd from dateutil import tz @@ -652,16 +653,15 @@ def _set_backend(self, new_backend: Backend, recursive: bool = True) -> None: provider = self._backend_data.provider if provider is not None: self._set_hgp_from_provider(provider) + # qiskit-ibm-runtime style + elif hasattr(self._backend, "_instance"): + self.hgp = self._backend._instance if recursive: for data in self.child_data(): data._set_backend(new_backend) def _set_hgp_from_provider(self, provider): try: - # qiskit-ibmq-provider style - if hasattr(provider, "credentials"): - creds = provider.credentials - self.hgp = f"{creds.hub}/{creds.group}/{creds.project}" # qiskit-ibm-provider style if hasattr(provider, "_hgps"): for hgp_string, hgp in provider._hgps.items(): @@ -2528,21 +2528,31 @@ def __getstate__(self): @staticmethod def get_service_from_backend(backend): """Initializes the service from the backend data""" - return ExperimentData.get_service_from_provider(backend.provider) + # qiskit-ibm-runtime style + try: + if hasattr(backend, "service"): + token = backend.service._account.token + return IBMExperimentService(token=token, url=backend.service._account.url) + return ExperimentData.get_service_from_provider(backend.provider) + except Exception: # pylint: disable=broad-except + return None @staticmethod def get_service_from_provider(provider): """Initializes the service from the provider data""" - db_url = "https://auth.quantum.ibm.com/api" try: - # qiskit-ibmq-provider style - if hasattr(provider, "credentials"): - token = provider.credentials.token # qiskit-ibm-provider style if hasattr(provider, "_account"): - token = provider._account.token - service = IBMExperimentService(token=token, url=db_url) - return service + warnings.warn( + "qiskit-ibm-provider has been deprecated in favor of qiskit-ibm-runtime. Support" + "for qiskit-ibm-provider backends will be removed in Qiskit Experiments 0.6.", + DeprecationWarning, + stacklevel=2, + ) + return IBMExperimentService( + token=provider._account.token, url=provider._account.url + ) + return None except Exception: # pylint: disable=broad-except return None diff --git a/releasenotes/notes/runtime-provider-support-5358b72ec0035419.yaml b/releasenotes/notes/runtime-provider-support-5358b72ec0035419.yaml new file mode 100644 index 0000000000..8a879f49d7 --- /dev/null +++ b/releasenotes/notes/runtime-provider-support-5358b72ec0035419.yaml @@ -0,0 +1,11 @@ +--- +features: + - | + Experiments run via the ``qiskit-ibm-runtime`` provider can now be saved + to the cloud service. +upgrade: + - | + With the impending deprecation of the ``qiskit-ibm-provider`` package, support for + ``qiskit-ibm-provider`` is now deprecated and will be removed + in the next release. Users should migrate to ``qiskit-ibm-runtime`` following the + `migration guide `. diff --git a/requirements-extras.txt b/requirements-extras.txt index 20acc7d83f..707149b52d 100644 --- a/requirements-extras.txt +++ b/requirements-extras.txt @@ -1,4 +1,3 @@ -qiskit-ibm-provider>=0.6.1 # for submitting experiments to backends through the IBM provider cvxpy>=1.3.2 # for tomography scikit-learn # for discriminators qiskit-aer>=0.13.2 diff --git a/test/database_service/test_db_experiment_data.py b/test/database_service/test_db_experiment_data.py index 003d21af0f..164e5160f7 100644 --- a/test/database_service/test_db_experiment_data.py +++ b/test/database_service/test_db_experiment_data.py @@ -33,6 +33,7 @@ from qiskit.result import Result from qiskit.providers import JobV1 as Job from qiskit.providers import JobStatus +from qiskit.providers.backend import Backend from qiskit_ibm_experiment import IBMExperimentService from qiskit_experiments.framework import ExperimentData from qiskit_experiments.framework import AnalysisResult @@ -57,6 +58,15 @@ def setUp(self): super().setUp() self.backend = FakeMelbourneV2() + def generate_mock_job(self): + """Helper method to generate a mock job.""" + job = mock.create_autospec(Job, instance=True) + # mock a backend without provider + backend = mock.create_autospec(Backend, instance=True) + backend.provider = None + job.backend.return_value = backend + return job + def test_db_experiment_data_attributes(self): """Test DB experiment data attributes.""" attrs = { @@ -119,12 +129,12 @@ def test_add_data_result_metadata(self): def test_add_data_job(self): """Test add job data.""" - a_job = mock.create_autospec(Job, instance=True) + a_job = self.generate_mock_job() a_job.result.return_value = self._get_job_result(3) num_circs = 3 jobs = [] for _ in range(2): - job = mock.create_autospec(Job, instance=True) + job = self.generate_mock_job() job.result.return_value = self._get_job_result(2, label_from=num_circs) job.status.return_value = JobStatus.DONE jobs.append(job) @@ -163,7 +173,7 @@ def _callback(_exp_data): nonlocal called_back called_back = True - a_job = mock.create_autospec(Job, instance=True) + a_job = self.generate_mock_job() a_job.result.return_value = self._get_job_result(2) a_job.status.return_value = JobStatus.DONE @@ -217,7 +227,8 @@ def _callback(_exp_data, **kwargs): nonlocal called_back called_back = True - a_job = mock.create_autospec(Job, instance=True) + a_job = self.generate_mock_job() + a_job.backend.return_value = mock.create_autospec(Backend, instance=True) a_job.result.return_value = self._get_job_result(2) a_job.status.return_value = JobStatus.DONE @@ -235,7 +246,7 @@ def test_add_data_pending_post_processing(self): def _callback(_exp_data, **kwargs): kwargs["event"].wait(timeout=3) - a_job = mock.create_autospec(Job, instance=True) + a_job = self.generate_mock_job() a_job.result.return_value = self._get_job_result(2) a_job.status.return_value = JobStatus.DONE @@ -452,14 +463,14 @@ def test_delayed_backend(self): exp_data = ExperimentData(experiment_type="qiskit_test") self.assertIsNone(exp_data.backend) exp_data.save_metadata() - a_job = mock.create_autospec(Job, instance=True) + a_job = self.generate_mock_job() exp_data.add_jobs(a_job) self.assertIsNotNone(exp_data.backend) def test_different_backend(self): """Test setting a different backend.""" exp_data = ExperimentData(backend=self.backend, experiment_type="qiskit_test") - a_job = mock.create_autospec(Job, instance=True) + a_job = self.generate_mock_job() self.assertNotEqual(exp_data.backend, a_job.backend()) with self.assertLogs("qiskit_experiments", "WARNING"): exp_data.add_jobs(a_job) @@ -609,12 +620,12 @@ def test_auto_save(self): def test_status_job_pending(self): """Test experiment status when job is pending.""" - job1 = mock.create_autospec(Job, instance=True) + job1 = self.generate_mock_job() job1.result.return_value = self._get_job_result(3) job1.status.return_value = JobStatus.DONE event = threading.Event() - job2 = mock.create_autospec(Job, instance=True) + job2 = self.generate_mock_job() job2.result = lambda *args, **kwargs: event.wait(timeout=15) job2.status = lambda: JobStatus.CANCELLED if event.is_set() else JobStatus.RUNNING self.addCleanup(event.set) @@ -634,11 +645,11 @@ def test_status_job_pending(self): def test_status_job_error(self): """Test experiment status when job failed.""" - job1 = mock.create_autospec(Job, instance=True) + job1 = self.generate_mock_job() job1.result.return_value = self._get_job_result(3) job1.status.return_value = JobStatus.DONE - job2 = mock.create_autospec(Job, instance=True) + job2 = self.generate_mock_job() job2.status.return_value = JobStatus.ERROR exp_data = ExperimentData(experiment_type="qiskit_test") @@ -649,7 +660,7 @@ def test_status_job_error(self): def test_status_post_processing(self): """Test experiment status during post processing.""" - job = mock.create_autospec(Job, instance=True) + job = self.generate_mock_job() job.result.return_value = self._get_job_result(3) job.status.return_value = JobStatus.DONE @@ -664,7 +675,7 @@ def test_status_post_processing(self): def test_status_cancelled_analysis(self): """Test experiment status during post processing.""" - job = mock.create_autospec(Job, instance=True) + job = self.generate_mock_job() job.result.return_value = self._get_job_result(3) job.status.return_value = JobStatus.DONE @@ -686,7 +697,7 @@ def test_status_post_processing_error(self): def _post_processing(*args, **kwargs): raise ValueError("Kaboom!") - job = mock.create_autospec(Job, instance=True) + job = self.generate_mock_job() job.result.return_value = self._get_job_result(3) job.status.return_value = JobStatus.DONE @@ -701,7 +712,7 @@ def _post_processing(*args, **kwargs): def test_status_done(self): """Test experiment status when all jobs are done.""" - job = mock.create_autospec(Job, instance=True) + job = self.generate_mock_job() job.result.return_value = self._get_job_result(3) job.status.return_value = JobStatus.DONE exp_data = ExperimentData(experiment_type="qiskit_test") @@ -734,7 +745,7 @@ def _job_cancel(): exp_data = ExperimentData(experiment_type="qiskit_test") event = threading.Event() self.addCleanup(event.set) - job = mock.create_autospec(Job, instance=True) + job = self.generate_mock_job() job.job_id.return_value = "1234" job.cancel = _job_cancel job.result = _job_result @@ -760,7 +771,7 @@ def _job_result(): def _analysis(*args): # pylint: disable = unused-argument event.wait(timeout=15) - job = mock.create_autospec(Job, instance=True) + job = self.generate_mock_job() job.job_id.return_value = "1234" job.result = _job_result job.status = lambda: JobStatus.DONE if event.is_set() else JobStatus.RUNNING @@ -796,7 +807,7 @@ def _analysis(expdata, name=None, timeout=0): # pylint: disable = unused-argume event.wait(timeout=timeout) run_analysis.append(name) - job = mock.create_autospec(Job, instance=True) + job = self.generate_mock_job() job.job_id.return_value = "1234" job.result = _job_result job.status = lambda: JobStatus.DONE if event.is_set() else JobStatus.RUNNING @@ -848,7 +859,7 @@ def _status(): return JobStatus.CANCELLED return JobStatus.RUNNING - job = mock.create_autospec(Job, instance=True) + job = self.generate_mock_job() job.job_id.return_value = "1234" job.result = _job_result job.cancel = event.set @@ -874,7 +885,7 @@ def _job_result(): event.wait(timeout=15) raise ValueError("Job was cancelled.") - job = mock.create_autospec(Job, instance=True) + job = self.generate_mock_job() job.job_id.return_value = "1234" job.result = _job_result job.cancel = event.set @@ -906,11 +917,11 @@ def test_errors(self): def _post_processing(*args, **kwargs): # pylint: disable=unused-argument raise ValueError("Kaboom!") - job1 = mock.create_autospec(Job, instance=True) + job1 = self.generate_mock_job() job1.job_id.return_value = "1234" job1.status.return_value = JobStatus.DONE - job2 = mock.create_autospec(Job, instance=True) + job2 = self.generate_mock_job() job2.status.return_value = JobStatus.ERROR job2.job_id.return_value = "5678" @@ -1031,7 +1042,7 @@ def _sleeper(*args, **kwargs): # pylint: disable=unused-argument return self._get_job_result(1) sleep_count = 0 - job = mock.create_autospec(Job, instance=True) + job = self.generate_mock_job() job.result = _sleeper exp_data = ExperimentData(experiment_type="qiskit_test") exp_data.add_jobs(job) @@ -1069,12 +1080,12 @@ def _job2_result(): return job_results2 exp_data = ExperimentData(experiment_type="qiskit_test") - job = mock.create_autospec(Job, instance=True) + job = self.generate_mock_job() job.result = _job1_result exp_data.add_jobs(job) copied = exp_data.copy(copy_results=False) - job2 = mock.create_autospec(Job, instance=True) + job2 = self.generate_mock_job() job2.result = _job2_result copied.add_jobs(job2) event.set() From 2461683d1b543837f18db4cb5c15e53003d73226 Mon Sep 17 00:00:00 2001 From: Will Shanks Date: Thu, 25 Jan 2024 20:18:30 -0500 Subject: [PATCH 07/15] Remove version pins on documentation dependencies (#1375) Some of the pinned versions have gotten old and are confusing pip's resolver as it tries to find versions of the unpinned dependencies which are compatible with the pinned dependencies. The issues that led to the versions being pinned should be resolved. --- requirements-dev.txt | 33 +++++++++++++++++---------------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index df9b3f3a08..934285c6eb 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,25 +1,26 @@ +# Linters black~=22.0 +pylint~=3.0.2 +astroid~=3.0.1 # Must be kept aligned to what pylint wants + +# Test runner tools +coverage>=5.5 +ddt>=1.6.0 fixtures stestr testtools -pylint~=3.0.2 -astroid~=3.0.1 # Must be kept aligned to what pylint wants -jinja2==3.0.3 -sphinx>=6.2.1,<=7 + +# Extra dependencies for tests/documentation code +multimethod + +# Documentation tools +arxiv jupyter-sphinx>=0.4.0 -qiskit-sphinx-theme~=1.14.0rc1 -sphinx-design~=0.4.1 -pygments>=2.4 -reno>=4.0.0 nbsphinx -arxiv -ddt>=1.6.0 pylatexenc -multimethod +qiskit-sphinx-theme +reno>=4.0.0 +sphinx>=6.2.1 sphinx-copybutton -coverage>=5.5 -# Pin versions below because of build errors -ipykernel<=6.21.3 -jupyter-client<=8.0.3 -ipython<8.13.0 ; python_version<"3.9" # for python 3.8 compatibility +sphinx-design sphinx-remove-toctrees From 94541e66fe121e9b7a47cdb8515e062a5aaeec3b Mon Sep 17 00:00:00 2001 From: Will Shanks Date: Fri, 26 Jan 2024 10:25:26 -0500 Subject: [PATCH 08/15] Fix sphinx import in docs extension (#1376) The qiskit-experiments Sphinx extension was importing Sphinx from an internal location that was removed in 7.2. Here the import is moved to the main locaiont for the Sphinx app. --- docs/_ext/custom_styles/option_parser.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/_ext/custom_styles/option_parser.py b/docs/_ext/custom_styles/option_parser.py index da9b1e4c70..63b752fd6c 100644 --- a/docs/_ext/custom_styles/option_parser.py +++ b/docs/_ext/custom_styles/option_parser.py @@ -21,7 +21,8 @@ from typing import Type, Optional import numpy as np -from sphinx.ext.autodoc import Sphinx, Options as SphinxOptions +from sphinx.application import Sphinx +from sphinx.ext.autodoc import Options as SphinxOptions from sphinx.ext.napoleon import Config as NapoleonConfig from sphinx.ext.napoleon import GoogleDocstring From 8828115c47be22cab9b9bf6f408df997c5b4030c Mon Sep 17 00:00:00 2001 From: Helena Zhang Date: Fri, 26 Jan 2024 10:25:55 -0500 Subject: [PATCH 09/15] Add dependabot to upgrade GitHub Actions (#1373) I noticed some of our GitHub Actions, like checkout@v3, are outdated. Dependabot should be able to check these automatically and create pull requests. I didn't add `requirements.txt` to Dependabot since we don't have that many dependencies and we usually only bump the minimum version if needed. --- .github/dependabot.yml | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000000..c92b9a9f83 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,7 @@ +# Check for updates to GitHub Actions +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" From 754a0f5692e9047b96404bc18f9d8a78714f89ff Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 26 Jan 2024 15:15:28 -0800 Subject: [PATCH 10/15] Bump actions/checkout from 3 to 4 (#1378) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [actions/checkout](https://github.com/actions/checkout) from 3 to 4.
Release notes

Sourced from actions/checkout's releases.

v4.0.0

What's Changed

New Contributors

Full Changelog: https://github.com/actions/checkout/compare/v3...v4.0.0

v3.6.0

What's Changed

New Contributors

Full Changelog: https://github.com/actions/checkout/compare/v3.5.3...v3.6.0

v3.5.3

What's Changed

New Contributors

Full Changelog: https://github.com/actions/checkout/compare/v3...v3.5.3

v3.5.2

What's Changed

Full Changelog: https://github.com/actions/checkout/compare/v3.5.1...v3.5.2

v3.5.1

What's Changed

New Contributors

... (truncated)

Changelog

Sourced from actions/checkout's changelog.

Changelog

v4.1.0

v4.0.0

v3.6.0

v3.5.3

v3.5.2

v3.5.1

v3.5.0

v3.4.0

v3.3.0

v3.2.0

v3.1.0

v3.0.2

... (truncated)

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actions/checkout&package-manager=github_actions&previous-version=3&new-version=4)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/cron-staging.yml | 4 ++-- .github/workflows/docs_dev.yml | 2 +- .github/workflows/docs_release.yml | 2 +- .github/workflows/docs_stable.yml | 2 +- .github/workflows/main.yml | 6 +++--- .github/workflows/release.yml | 2 +- 6 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/cron-staging.yml b/.github/workflows/cron-staging.yml index da6fea2180..905471d547 100644 --- a/.github/workflows/cron-staging.yml +++ b/.github/workflows/cron-staging.yml @@ -21,7 +21,7 @@ jobs: echo -e "\033[31;1;4mConcurrency Group\033[0m" echo -e "$CONCURRENCY_GROUP\n" shell: bash - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: @@ -50,7 +50,7 @@ jobs: name: docs runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: fetch-depth: 0 - name: Set up Python 3.11 diff --git a/.github/workflows/docs_dev.yml b/.github/workflows/docs_dev.yml index d8b9fece87..8fd0222250 100644 --- a/.github/workflows/docs_dev.yml +++ b/.github/workflows/docs_dev.yml @@ -9,7 +9,7 @@ jobs: if: github.repository_owner == 'Qiskit-Extensions' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: fetch-depth: 0 - name: Set up Python diff --git a/.github/workflows/docs_release.yml b/.github/workflows/docs_release.yml index e201841b40..7c345941b6 100644 --- a/.github/workflows/docs_release.yml +++ b/.github/workflows/docs_release.yml @@ -10,7 +10,7 @@ jobs: if: github.repository_owner == 'Qiskit-Extensions' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: fetch-depth: 0 - name: Set up Python diff --git a/.github/workflows/docs_stable.yml b/.github/workflows/docs_stable.yml index 58a2767d90..e7ee1138ff 100644 --- a/.github/workflows/docs_stable.yml +++ b/.github/workflows/docs_stable.yml @@ -10,7 +10,7 @@ jobs: if: github.repository_owner == 'Qiskit-Extensions' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: fetch-depth: 0 - name: Set up Python diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 6887058e4c..0e85436c13 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -26,7 +26,7 @@ jobs: echo -e "\033[31;1;4mConcurrency Group\033[0m" echo -e "$CONCURRENCY_GROUP\n" shell: bash - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: @@ -66,7 +66,7 @@ jobs: name: lint runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python 3.8 uses: actions/setup-python@v4 with: @@ -85,7 +85,7 @@ jobs: name: docs runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: fetch-depth: 0 - name: Set up Python 3.8 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8c7a18ce1d..4ef8babb92 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,7 +11,7 @@ jobs: permissions: id-token: write steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: actions/setup-python@v4 name: Install Python with: From 862e20505cb0adce47f009c29e80af78a1eb6590 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 26 Jan 2024 23:16:07 +0000 Subject: [PATCH 11/15] Bump actions/cache from 3 to 4 (#1379) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [actions/cache](https://github.com/actions/cache) from 3 to 4.
Release notes

Sourced from actions/cache's releases.

v4.0.0

What's Changed

New Contributors

Full Changelog: https://github.com/actions/cache/compare/v3...v4.0.0

v3.3.3

What's Changed

New Contributors

Full Changelog: https://github.com/actions/cache/compare/v3...v3.3.3

v3.3.2

What's Changed

New Contributors

Full Changelog: https://github.com/actions/cache/compare/v3...v3.3.2

v3.3.1

What's Changed

Full Changelog: https://github.com/actions/cache/compare/v3...v3.3.1

v3.3.0

What's Changed

... (truncated)

Changelog

Sourced from actions/cache's changelog.

Releases

3.0.0

  • Updated minimum runner version support from node 12 -> node 16

3.0.1

  • Added support for caching from GHES 3.5.
  • Fixed download issue for files > 2GB during restore.

3.0.2

  • Added support for dynamic cache size cap on GHES.

3.0.3

  • Fixed avoiding empty cache save when no files are available for caching. (issue)

3.0.4

  • Fixed tar creation error while trying to create tar with path as ~/ home folder on ubuntu-latest. (issue)

3.0.5

  • Removed error handling by consuming actions/cache 3.0 toolkit, Now cache server error handling will be done by toolkit. (PR)

3.0.6

  • Fixed #809 - zstd -d: no such file or directory error
  • Fixed #833 - cache doesn't work with github workspace directory

3.0.7

  • Fixed #810 - download stuck issue. A new timeout is introduced in the download process to abort the download if it gets stuck and doesn't finish within an hour.

3.0.8

  • Fix zstd not working for windows on gnu tar in issues #888 and #891.
  • Allowing users to provide a custom timeout as input for aborting download of a cache segment using an environment variable SEGMENT_DOWNLOAD_TIMEOUT_MINS. Default is 60 minutes.

3.0.9

  • Enhanced the warning message for cache unavailablity in case of GHES.

3.0.10

  • Fix a bug with sorting inputs.
  • Update definition for restore-keys in README.md

... (truncated)

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actions/cache&package-manager=github_actions&previous-version=3&new-version=4)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/cron-staging.yml | 4 ++-- .github/workflows/main.yml | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/cron-staging.yml b/.github/workflows/cron-staging.yml index 905471d547..e50eabe98b 100644 --- a/.github/workflows/cron-staging.yml +++ b/.github/workflows/cron-staging.yml @@ -27,7 +27,7 @@ jobs: with: python-version: ${{ matrix.python-version }} - name: Pip cache - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: ~/.cache/pip key: ${{ runner.os }}-${{ matrix.python-version }}-pip-tests-${{ hashFiles('setup.py','requirements.txt','requirements-extras.txt','requirements-dev.txt','constraints.txt') }} @@ -58,7 +58,7 @@ jobs: with: python-version: 3.11 - name: Pip cache - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: ~/.cache/pip key: ${{ runner.os }}-pip-docs-${{ hashFiles('setup.py','requirements.txt','requirements-extras.txt','requirements-dev.txt','constraints.txt') }} diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 0e85436c13..2042ac0ab0 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -32,7 +32,7 @@ jobs: with: python-version: ${{ matrix.python-version }} - name: Pip cache - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: ~/.cache/pip key: ${{ runner.os }}-${{ matrix.python-version }}-pip-tests-${{ hashFiles('setup.py','requirements.txt','requirements-extras.txt','requirements-dev.txt','constraints.txt') }} @@ -41,7 +41,7 @@ jobs: ${{ runner.os }}-${{ matrix.python-version }}-pip- ${{ runner.os }}-${{ matrix.python-version }} - name: Stestr cache - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: .stestr key: stestr-${{ runner.os }}-${{ matrix.python-version }} @@ -72,7 +72,7 @@ jobs: with: python-version: 3.8 - name: Pip cache - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: ~/.cache/pip key: ${{ runner.os }}-pip-lint-${{ hashFiles('setup.py','requirements.txt','requirements-extras.txt','requirements-dev.txt','constraints.txt') }} @@ -93,7 +93,7 @@ jobs: with: python-version: 3.8 - name: Pip cache - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: ~/.cache/pip key: ${{ runner.os }}-pip-docs-${{ hashFiles('setup.py','requirements.txt','requirements-extras.txt','requirements-dev.txt','constraints.txt') }} From 5661cb07e1f0af83438d87aa1e6a0f3cd6cd3e15 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 26 Jan 2024 23:16:42 +0000 Subject: [PATCH 12/15] Bump actions/upload-artifact from 3 to 4 (#1380) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 3 to 4.
Release notes

Sourced from actions/upload-artifact's releases.

v4.0.0

What's Changed

The release of upload-artifact@v4 and download-artifact@v4 are major changes to the backend architecture of Artifacts. They have numerous performance and behavioral improvements.

ℹ️ However, this is a major update that includes breaking changes. Artifacts created with versions v3 and below are not compatible with the v4 actions. Uploads and downloads must use the same major actions versions. There are also key differences from previous versions that may require updates to your workflows.

For more information, please see:

  1. The changelog post.
  2. The README.
  3. The migration documentation.
  4. As well as the underlying npm package, @​actions/artifact documentation.

New Contributors

Full Changelog: https://github.com/actions/upload-artifact/compare/v3...v4.0.0

v3.1.3

What's Changed

Full Changelog: https://github.com/actions/upload-artifact/compare/v3...v3.1.3

v3.1.2

  • Update all @actions/* NPM packages to their latest versions- #374
  • Update all dev dependencies to their most recent versions - #375

v3.1.1

  • Update actions/core package to latest version to remove set-output deprecation warning #351

v3.1.0

What's Changed

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actions/upload-artifact&package-manager=github_actions&previous-version=3&new-version=4)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/cron-staging.yml | 2 +- .github/workflows/main.yml | 2 +- .github/workflows/release.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/cron-staging.yml b/.github/workflows/cron-staging.yml index e50eabe98b..855a29b566 100644 --- a/.github/workflows/cron-staging.yml +++ b/.github/workflows/cron-staging.yml @@ -73,7 +73,7 @@ jobs: mkdir artifacts tar -Jcvf html_docs.tar.xz docs/_build/html mv html_docs.tar.xz artifacts/. - - uses: actions/upload-artifact@v3 + - uses: actions/upload-artifact@v4 with: name: html_docs path: artifacts diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 2042ac0ab0..71647ab2ba 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -108,7 +108,7 @@ jobs: mkdir artifacts tar -Jcvf html_docs.tar.xz docs/_build/html mv html_docs.tar.xz artifacts/. - - uses: actions/upload-artifact@v3 + - uses: actions/upload-artifact@v4 with: name: html_docs path: artifacts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4ef8babb92..d2bd4ab544 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,7 +23,7 @@ jobs: python setup.py sdist python setup.py bdist_wheel shell: bash - - uses: actions/upload-artifact@v3 + - uses: actions/upload-artifact@v4 with: path: ./dist/qiskit* - name: Publish to PyPi From 32474382ea28e36a7ffe5253d87e1aaa7e0c1a40 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 26 Jan 2024 20:23:39 -0500 Subject: [PATCH 13/15] Bump actions/setup-python from 4 to 5 (#1377) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [actions/setup-python](https://github.com/actions/setup-python) from 4 to 5.
Release notes

Sourced from actions/setup-python's releases.

v5.0.0

What's Changed

In scope of this release, we update node version runtime from node16 to node20 (actions/setup-python#772). Besides, we update dependencies to the latest versions.

Full Changelog: https://github.com/actions/setup-python/compare/v4.8.0...v5.0.0

v4.8.0

What's Changed

In scope of this release we added support for GraalPy (actions/setup-python#694). You can use this snippet to set up GraalPy:

steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v4
  with:
    python-version: 'graalpy-22.3'
- run: python my_script.py

Besides, the release contains such changes as:

New Contributors

Full Changelog: https://github.com/actions/setup-python/compare/v4...v4.8.0

v4.7.1

What's Changed

Full Changelog: https://github.com/actions/setup-python/compare/v4...v4.7.1

v4.7.0

In scope of this release, the support for reading python version from pyproject.toml was added (actions/setup-python#669).

      - name: Setup Python
        uses: actions/setup-python@v4
</tr></table>

... (truncated)

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actions/setup-python&package-manager=github_actions&previous-version=4&new-version=5)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/cron-staging.yml | 4 ++-- .github/workflows/docs_dev.yml | 2 +- .github/workflows/docs_release.yml | 2 +- .github/workflows/docs_stable.yml | 2 +- .github/workflows/main.yml | 6 +++--- .github/workflows/release.yml | 2 +- 6 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/cron-staging.yml b/.github/workflows/cron-staging.yml index 855a29b566..76d091ba68 100644 --- a/.github/workflows/cron-staging.yml +++ b/.github/workflows/cron-staging.yml @@ -23,7 +23,7 @@ jobs: shell: bash - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Pip cache @@ -54,7 +54,7 @@ jobs: with: fetch-depth: 0 - name: Set up Python 3.11 - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: 3.11 - name: Pip cache diff --git a/.github/workflows/docs_dev.yml b/.github/workflows/docs_dev.yml index 8fd0222250..4d189f14bf 100644 --- a/.github/workflows/docs_dev.yml +++ b/.github/workflows/docs_dev.yml @@ -13,7 +13,7 @@ jobs: with: fetch-depth: 0 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: '3.8' - name: Install dependencies diff --git a/.github/workflows/docs_release.yml b/.github/workflows/docs_release.yml index 7c345941b6..ac51d15a64 100644 --- a/.github/workflows/docs_release.yml +++ b/.github/workflows/docs_release.yml @@ -14,7 +14,7 @@ jobs: with: fetch-depth: 0 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: '3.8' - name: Install dependencies diff --git a/.github/workflows/docs_stable.yml b/.github/workflows/docs_stable.yml index e7ee1138ff..9754504013 100644 --- a/.github/workflows/docs_stable.yml +++ b/.github/workflows/docs_stable.yml @@ -14,7 +14,7 @@ jobs: with: fetch-depth: 0 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: '3.8' - name: Install dependencies diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 71647ab2ba..85cddcbbb3 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -28,7 +28,7 @@ jobs: shell: bash - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Pip cache @@ -68,7 +68,7 @@ jobs: steps: - uses: actions/checkout@v4 - name: Set up Python 3.8 - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: 3.8 - name: Pip cache @@ -89,7 +89,7 @@ jobs: with: fetch-depth: 0 - name: Set up Python 3.8 - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: 3.8 - name: Pip cache diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d2bd4ab544..b0ed2b7f4b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,7 +12,7 @@ jobs: id-token: write steps: - uses: actions/checkout@v4 - - uses: actions/setup-python@v4 + - uses: actions/setup-python@v5 name: Install Python with: python-version: '3.8' From 46e7eec919de59f104676c6c290a7da3e3ddd325 Mon Sep 17 00:00:00 2001 From: Naoki Kanazawa Date: Wed, 31 Jan 2024 13:01:13 +0900 Subject: [PATCH 14/15] Rework of Stark module and workflow (#1236) ### Summary This PR moves all Stark experiments to new module `qiskit_experiments.library.driven_freq_tuning` to define a module-wise utility file. This util file contains the `StarkCoefficients` dataclass to combine all seven coefficients from third-order polynomial fit characterizing the Stark shift. This object is shared among all experiments and analyses in new module. In addition to this, `StarkP1Spectroscopy` allows users to scan xval in units of either amplitude or frequency (previously only amplitude was allowed). These two domains are mutually convertible with the `StarkCoefficients` object. The domain conversion functions are also included in the util file. ### Details and comments Experiment option names are updated to be more general, namely `amp` -> `xval` and new option `xval_type` is added. `xval_type` is either `amplitude` or `frequency`. Experimentalist can directly specify the target Stark shift by ```python exp = StarkP1Spectroscopy((0,), backend=backend) freqs = np.linspace(-70e6, 70e6, 31) exp.set_experiment_options( xvals=freqs, xval_type="frequency", ) ``` Note that this requires pre-calibration of Stark shift coefficients with `StarkRamseyXYAmpScan` experiment to convert specified frequencies into tone amplitudes, and one must save the calibration results in the experiment service. If the service is not available, one can also directly provide these coefficients instead of providing a service through the backend. ```python exp.set_experiment_options( xvals=test_freqs, xval_type="frequency", stark_coefficients=StarkCoefficients(...), ) ``` When the coefficients are already calibrated, one can estimate the maximum Stark shift available within the power budget. ```python min_freq, max_freq = util.find_min_max_frequency(-0.9, 0.9, coeffs) ``` --------- Co-authored-by: Will Shanks Co-authored-by: Will Shanks --- docs/apidocs/index.rst | 1 + docs/apidocs/mod_driven_freq_tuning.rst | 6 + .../characterization/stark_experiment.rst | 104 +++ .../stark_experiment_example.png | Bin 0 -> 1203075 bytes qiskit_experiments/__init__.py | 1 + qiskit_experiments/library/__init__.py | 13 +- .../library/characterization/__init__.py | 11 +- .../characterization/analysis/__init__.py | 4 +- .../analysis/ramsey_xy_analysis.py | 398 ----------- .../characterization/analysis/t1_analysis.py | 220 +----- .../library/characterization/ramsey_xy.py | 628 +----------------- .../library/characterization/t1.py | 228 +------ .../library/driven_freq_tuning/__init__.py | 63 ++ .../driven_freq_tuning/coefficients.py | 279 ++++++++ .../library/driven_freq_tuning/p1_spect.py | 269 ++++++++ .../driven_freq_tuning/p1_spect_analysis.py | 163 +++++ .../library/driven_freq_tuning/ramsey.py | 359 ++++++++++ .../driven_freq_tuning/ramsey_amp_scan.py | 311 +++++++++ .../ramsey_amp_scan_analysis.py | 440 ++++++++++++ test/library/driven_freq_tuning/__init__.py | 12 + .../library/driven_freq_tuning/test_coeffs.py | 173 +++++ .../test_stark_p1_spect.py | 224 ++++--- .../test_stark_ramsey_xy.py | 68 +- 23 files changed, 2368 insertions(+), 1607 deletions(-) create mode 100644 docs/apidocs/mod_driven_freq_tuning.rst create mode 100644 docs/manuals/characterization/stark_experiment_example.png create mode 100644 qiskit_experiments/library/driven_freq_tuning/__init__.py create mode 100644 qiskit_experiments/library/driven_freq_tuning/coefficients.py create mode 100644 qiskit_experiments/library/driven_freq_tuning/p1_spect.py create mode 100644 qiskit_experiments/library/driven_freq_tuning/p1_spect_analysis.py create mode 100644 qiskit_experiments/library/driven_freq_tuning/ramsey.py create mode 100644 qiskit_experiments/library/driven_freq_tuning/ramsey_amp_scan.py create mode 100644 qiskit_experiments/library/driven_freq_tuning/ramsey_amp_scan_analysis.py create mode 100644 test/library/driven_freq_tuning/__init__.py create mode 100644 test/library/driven_freq_tuning/test_coeffs.py rename test/library/{characterization => driven_freq_tuning}/test_stark_p1_spect.py (55%) rename test/library/{characterization => driven_freq_tuning}/test_stark_ramsey_xy.py (84%) diff --git a/docs/apidocs/index.rst b/docs/apidocs/index.rst index 01efc9c174..fac3299b4d 100644 --- a/docs/apidocs/index.rst +++ b/docs/apidocs/index.rst @@ -30,6 +30,7 @@ Experiment Modules mod_calibration mod_characterization + mod_driven_freq_tuning mod_randomized_benchmarking mod_tomography mod_quantum_volume diff --git a/docs/apidocs/mod_driven_freq_tuning.rst b/docs/apidocs/mod_driven_freq_tuning.rst new file mode 100644 index 0000000000..bdc7fa1462 --- /dev/null +++ b/docs/apidocs/mod_driven_freq_tuning.rst @@ -0,0 +1,6 @@ +.. _qiskit-experiments-driven-freq-tuning: + +.. automodule:: qiskit_experiments.library.driven_freq_tuning + :no-members: + :no-inherited-members: + :no-special-members: diff --git a/docs/manuals/characterization/stark_experiment.rst b/docs/manuals/characterization/stark_experiment.rst index 691171cd67..23b6c56d4e 100644 --- a/docs/manuals/characterization/stark_experiment.rst +++ b/docs/manuals/characterization/stark_experiment.rst @@ -211,6 +211,110 @@ In Qiskit Experiments, the experiment option ``stark_amp`` usually refers to the height of this GaussianSquare flat-top. +Workflow +-------- + +In this example, you'll learn how to measure a spectrum of qubit relaxation versus +frequency with fixed frequency transmons. +As you already know, we give an offset to the qubit frequency with a Stark tone, +and the workflow starts from characterizing the amount of the Stark shift against +the Stark amplitude :math:`\bar{\Omega}` that you can experimentally control. + +.. jupyter-input:: + + from qiskit_experiments.library.driven_freq_tuning import StarkRamseyXYAmpScan + + exp = StarkRamseyXYAmpScan((0,), backend=backend) + exp_data = exp.run().block_for_results() + coefficients = exp_data.analysis_results("stark_coefficients").value + +You first need to run the :class:`.StarkRamseyXYAmpScan` experiment that scans :math:`\bar{\Omega}` +and estimates the amount of the resultant frequency shift. +This experiment fits the frequency shift to a polynomial model which is a function of :math:`\bar{\Omega}`. +You can obtain the :class:`.StarkCoefficients` object that contains +all polynomial coefficients to map and reverse-map the :math:`\bar{\Omega}` to a corresponding frequency value. + +This object may be necessary for the following spectroscopy experiment. +Since Stark coefficients are stable for a relatively long time, +you may want to save the coefficient values and load them later when you run the experiment. +If you have an access to the Experiment service, you can just save the experiment result. + +.. jupyter-input:: + + exp_data.save() + +.. jupyter-output:: + + You can view the experiment online at https://quantum.ibm.com/experiments/23095777-be28-4036-9c98-89d3a915b820 + + +Otherwise, you can dump the coefficient object into a file with JSON format. + +.. jupyter-input:: + + import json + from qiskit_experiments.framework import ExperimentEncoder + + with open("coefficients.json", "w") as fp: + json.dump(ret_coeffs, fp, cls=ExperimentEncoder) + +The saved object can be retrieved either from the service or file, as follows. + +.. jupyter-input:: + + # When you have access to Experiment service + from qiskit_experiments.library.driven_freq_tuning import retrieve_coefficients_from_backend + + coefficients = retrieve_coefficients_from_backend(backend, 0) + + # Alternatively you can load from file + from qiskit_experiments.framework import ExperimentDecoder + + with open("coefficients.json", "r") as fp: + coefficients = json.load(fp, cls=ExperimentDecoder) + +Now you can measure the qubit relaxation spectrum. +The :class:`.StarkP1Spectroscopy` experiment also scans :math:`\bar{\Omega}`, +but instead of measuring the frequency shift, it measures the excited state population P1 +after certain delay, :code:`t1_delay` in the experiment options, following the state population. +You can scan the :math:`\bar{\Omega}` values either in the "frequency" or "amplitude" domain, +but the :code:`stark_coefficients` option must be set to perform the frequency sweep. + +.. jupyter-input:: + + from qiskit_experiments.library.driven_freq_tuning import StarkP1Spectroscopy + + exp = StarkP1Spectroscopy((0,), backend=backend) + + exp.set_experiment_options( + t1_delay=20e-6, + min_xval=-20e6, + max_xval=20e6, + xval_type="frequency", + spacing="linear", + stark_coefficients=coefficients, + ) + + exp_data = exp.run().block_for_results() + +You may find notches in the P1 spectrum, which may indicate the existence of TLS's +in the vicinity of your qubit drive frequency. + +.. jupyter-input:: + + exp_data.figure(0) + +.. image:: ./stark_experiment_example.png + +Note that this experiment doesn't yield any analysis result because the landscape of a P1 spectrum +can not be predicted due to the random occurrences of the TLS and frequency collisions. +If you have your own protocol to extract meaningful quantities from the data, +you can write a custom analysis subclass and give it to the experiment instance before execution. +See :class:`.StarkP1SpectAnalysis` for more details. + +This protocol can be parallelized among many qubits unless crosstalk matters. + + References ---------- diff --git a/docs/manuals/characterization/stark_experiment_example.png b/docs/manuals/characterization/stark_experiment_example.png new file mode 100644 index 0000000000000000000000000000000000000000..5d99b87fe587ac3708bf42d7ff14a7ba125366a0 GIT binary patch literal 1203075 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

-r(w6hglC5Ho0#iCq@;j4+ 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 literal 0 HcmV?d00001 diff --git a/qiskit_experiments/__init__.py b/qiskit_experiments/__init__.py index e9c613b259..32532928b8 100644 --- a/qiskit_experiments/__init__.py +++ b/qiskit_experiments/__init__.py @@ -49,6 +49,7 @@ - :mod:`qiskit_experiments.library.calibration` - :mod:`qiskit_experiments.library.characterization` +- :mod:`qiskit_experiments.library.driven_freq_tuning` - :mod:`qiskit_experiments.library.randomized_benchmarking` - :mod:`qiskit_experiments.library.tomography` """ diff --git a/qiskit_experiments/library/__init__.py b/qiskit_experiments/library/__init__.py index 6737402863..29770ed7b3 100644 --- a/qiskit_experiments/library/__init__.py +++ b/qiskit_experiments/library/__init__.py @@ -76,8 +76,9 @@ ~characterization.FineXDrag ~characterization.FineSXDrag ~characterization.MultiStateDiscrimination - ~characterization.StarkRamseyXY - ~characterization.StarkRamseyXYAmpScan + ~driven_freq_tuning.StarkRamseyXY + ~driven_freq_tuning.StarkRamseyXYAmpScan + ~driven_freq_tuning.StarkP1Spectroscopy .. _characterization two qubits: @@ -160,7 +161,6 @@ class instance to manage parameters and pulse schedules. ) from .characterization import ( T1, - StarkP1Spectroscopy, T2Hahn, T2Ramsey, Tphi, @@ -187,8 +187,6 @@ class instance to manage parameters and pulse schedules. CorrelatedReadoutError, ZZRamsey, MultiStateDiscrimination, - StarkRamseyXY, - StarkRamseyXYAmpScan, ) from .randomized_benchmarking import StandardRB, InterleavedRB from .tomography import ( @@ -199,6 +197,11 @@ class instance to manage parameters and pulse schedules. MitigatedProcessTomography, ) from .quantum_volume import QuantumVolume +from .driven_freq_tuning import ( + StarkRamseyXY, + StarkRamseyXYAmpScan, + StarkP1Spectroscopy, +) # Experiment Sub-modules from . import calibration diff --git a/qiskit_experiments/library/characterization/__init__.py b/qiskit_experiments/library/characterization/__init__.py index 8aaaceee4c..daa29bb4a4 100644 --- a/qiskit_experiments/library/characterization/__init__.py +++ b/qiskit_experiments/library/characterization/__init__.py @@ -24,7 +24,6 @@ :template: autosummary/experiment.rst T1 - StarkP1Spectroscopy T2Ramsey T2Hahn Tphi @@ -50,8 +49,6 @@ ResonatorSpectroscopy MultiStateDiscrimination ZZRamsey - StarkRamseyXY - StarkRamseyXYAmpScan Analysis @@ -63,7 +60,6 @@ T1Analysis T1KerneledAnalysis - StarkP1SpectAnalysis T2RamseyAnalysis T2HahnAnalysis TphiAnalysis @@ -71,7 +67,6 @@ DragCalAnalysis FineAmplitudeAnalysis RamseyXYAnalysis - StarkRamseyXYAmpScanAnalysis ReadoutAngleAnalysis ResonatorSpectroscopyAnalysis LocalReadoutErrorAnalysis @@ -85,8 +80,6 @@ DragCalAnalysis, FineAmplitudeAnalysis, RamseyXYAnalysis, - StarkRamseyXYAmpScanAnalysis, - StarkP1SpectAnalysis, T2RamseyAnalysis, T1Analysis, T1KerneledAnalysis, @@ -101,7 +94,7 @@ MultiStateDiscriminationAnalysis, ) -from .t1 import T1, StarkP1Spectroscopy +from .t1 import T1 from .qubit_spectroscopy import QubitSpectroscopy from .ef_spectroscopy import EFSpectroscopy from .t2ramsey import T2Ramsey @@ -111,7 +104,7 @@ from .rabi import Rabi, EFRabi from .half_angle import HalfAngle from .fine_amplitude import FineAmplitude, FineXAmplitude, FineSXAmplitude, FineZXAmplitude -from .ramsey_xy import RamseyXY, StarkRamseyXY, StarkRamseyXYAmpScan +from .ramsey_xy import RamseyXY from .fine_frequency import FineFrequency from .drag import RoughDrag from .readout_angle import ReadoutAngle diff --git a/qiskit_experiments/library/characterization/analysis/__init__.py b/qiskit_experiments/library/characterization/analysis/__init__.py index 8520060772..f249dcd9be 100644 --- a/qiskit_experiments/library/characterization/analysis/__init__.py +++ b/qiskit_experiments/library/characterization/analysis/__init__.py @@ -14,10 +14,10 @@ from .drag_analysis import DragCalAnalysis from .fine_amplitude_analysis import FineAmplitudeAnalysis -from .ramsey_xy_analysis import RamseyXYAnalysis, StarkRamseyXYAmpScanAnalysis +from .ramsey_xy_analysis import RamseyXYAnalysis from .t2ramsey_analysis import T2RamseyAnalysis from .t2hahn_analysis import T2HahnAnalysis -from .t1_analysis import T1Analysis, T1KerneledAnalysis, StarkP1SpectAnalysis +from .t1_analysis import T1Analysis, T1KerneledAnalysis from .tphi_analysis import TphiAnalysis from .cr_hamiltonian_analysis import CrossResonanceHamiltonianAnalysis from .readout_angle_analysis import ReadoutAngleAnalysis diff --git a/qiskit_experiments/library/characterization/analysis/ramsey_xy_analysis.py b/qiskit_experiments/library/characterization/analysis/ramsey_xy_analysis.py index 1a0ac92837..27a588a550 100644 --- a/qiskit_experiments/library/characterization/analysis/ramsey_xy_analysis.py +++ b/qiskit_experiments/library/characterization/analysis/ramsey_xy_analysis.py @@ -16,11 +16,8 @@ import lmfit import numpy as np -from uncertainties import unumpy as unp import qiskit_experiments.curve_analysis as curve -import qiskit_experiments.visualization as vis -from qiskit_experiments.framework import ExperimentData class RamseyXYAnalysis(curve.CurveAnalysis): @@ -209,398 +206,3 @@ def _evaluate_quality(self, fit_data: curve.CurveFitResult) -> Union[str, None]: return "good" return "bad" - - -class StarkRamseyXYAmpScanAnalysis(curve.CurveAnalysis): - r"""Ramsey XY analysis for the Stark shifted phase sweep. - - # section: overview - - This analysis is a variant of :class:`RamseyXYAnalysis`. In both cases, the X and Y - data are treated as the real and imaginary parts of a complex oscillating signal. - In :class:`RamseyXYAnalysis`, the data are fit assuming a phase varying linearly with - the x-data corresponding to a constant frequency and assuming an exponentially - decaying amplitude. By contrast, in this model, the phase is assumed to be - a third order polynomial :math:`\theta(x)` of the x-data. - Additionally, the amplitude is not assumed to follow a specific form. - Techniques to compute a good initial guess for the polynomial coefficients inside - a trigonometric function like this are not trivial. Instead, this analysis extracts the - raw phase and runs fits on the extracted data to a polynomial :math:`\theta(x)` directly. - - The measured P1 values for a Ramsey X and Y experiment can be written in the form of - a trignometric function taking the phase polynomial :math:`\theta(x)`: - - .. math:: - - P_X = \text{amp}(x) \cdot \cos \theta(x) + \text{offset},\\ - P_Y = \text{amp}(x) \cdot \sin \theta(x) + \text{offset}. - - Hence the phase polynomial can be extracted as follows - - .. math:: - - \theta(x) = \tan^{-1} \frac{P_Y}{P_X}. - - Because the arctangent is implemented by the ``atan2`` function - defined in :math:`[-\pi, \pi]`, the computed :math:`\theta(x)` is unwrapped to - ensure continuous phase evolution. - - We call attention to the fact that :math:`\text{amp}(x)` is also Stark tone amplitude - dependent because of the qubit frequency dependence of the dephasing rate. - In general :math:`\text{amp}(x)` is unpredictable due to dephasing from - two-level systems distributed randomly in frequency - or potentially due to qubit heating. This prevents us from precisely fitting - the raw :math:`P_X`, :math:`P_Y` data. Fitting only the phase data makes the - analysis robust to amplitude dependent dephasing. - - In this analysis, the phase polynomial is defined as - - .. math:: - - \theta(x) = 2 \pi t_S f_S(x) - - where - - .. math:: - - f_S(x) = c_1 x + c_2 x^2 + c_3 x^3 + f_{\rm err}, - - denotes the Stark shift. For the lowest order perturbative expansion of a single driven qubit, - the Stark shift is a quadratic function of :math:`x`, but linear and cubic terms - and a constant offset are also considered to account for - other effects, e.g. strong drive, collisions, TLS, and so forth, - and frequency mis-calibration, respectively. - - # section: fit_model - - .. math:: - - \theta^\nu(x) = c_1^\nu x + c_2^\nu x^2 + c_3^\nu x^3 + f_{\rm err}, - - where :math:`\nu \in \{+, -\}`. - The Stark shift is asymmetric with respect to :math:`x=0`, because of the - anti-crossings of higher energy levels. In a typical transmon qubit, - these levels appear only in :math:`f_S < 0` because of the negative anharmonicity. - To precisely fit the results, this analysis uses different model parameters - for positive (:math:`x > 0`) and negative (:math:`x < 0`) shift domains. - - # section: fit_parameters - - defpar c_1^+: - desc: The linear term coefficient of the positive Stark shift - (fit parameter: ``stark_pos_coef_o1``). - init_guess: 0. - bounds: None - - defpar c_2^+: - desc: The quadratic term coefficient of the positive Stark shift. - This parameter must be positive because Stark amplitude is chosen to - induce blue shift when its sign is positive. - Note that the quadratic term is the primary term - (fit parameter: ``stark_pos_coef_o2``). - init_guess: 1e6. - bounds: [0, inf] - - defpar c_3^+: - desc: The cubic term coefficient of the positive Stark shift - (fit parameter: ``stark_pos_coef_o3``). - init_guess: 0. - bounds: None - - defpar c_1^-: - desc: The linear term coefficient of the negative Stark shift. - (fit parameter: ``stark_neg_coef_o1``). - init_guess: 0. - bounds: None - - defpar c_2^-: - desc: The quadratic term coefficient of the negative Stark shift. - This parameter must be negative because Stark amplitude is chosen to - induce red shift when its sign is negative. - Note that the quadratic term is the primary term - (fit parameter: ``stark_neg_coef_o2``). - init_guess: -1e6. - bounds: [-inf, 0] - - defpar c_3^-: - desc: The cubic term coefficient of the negative Stark shift - (fit parameter: ``stark_neg_coef_o3``). - init_guess: 0. - bounds: None - - defpar f_{\rm err}: - desc: Constant phase accumulation which is independent of the Stark tone amplitude. - (fit parameter: ``stark_ferr``). - init_guess: 0 - bounds: None - - # section: see_also - - :class:`qiskit_experiments.library.characterization.analysis.ramsey_xy_analysis.RamseyXYAnalysis` - - """ - - def __init__(self): - - models = [ - lmfit.models.ExpressionModel( - expr="c1_pos * x + c2_pos * x**2 + c3_pos * x**3 + f_err", - name="FREQpos", - ), - lmfit.models.ExpressionModel( - expr="c1_neg * x + c2_neg * x**2 + c3_neg * x**3 + f_err", - name="FREQneg", - ), - ] - super().__init__(models=models) - - @classmethod - def _default_options(cls): - """Default analysis options. - - Analysis Options: - pulse_len (float): Duration of effective Stark pulse in units of sec. - """ - ramsey_plotter = vis.CurvePlotter(vis.MplDrawer()) - ramsey_plotter.set_figure_options( - xlabel="Stark tone amplitude", - ylabel=["Stark shift", "P1"], - yval_unit=["Hz", None], - series_params={ - "Fpos": { - "color": "#123FE8", - "symbol": "^", - "label": "", - "canvas": 0, - }, - "Fneg": { - "color": "#123FE8", - "symbol": "v", - "label": "", - "canvas": 0, - }, - "Xpos": { - "color": "#123FE8", - "symbol": "o", - "label": "Ramsey X", - "canvas": 1, - }, - "Ypos": { - "color": "#6312E8", - "symbol": "^", - "label": "Ramsey Y", - "canvas": 1, - }, - "Xneg": { - "color": "#E83812", - "symbol": "o", - "label": "Ramsey X", - "canvas": 1, - }, - "Yneg": { - "color": "#E89012", - "symbol": "^", - "label": "Ramsey Y", - "canvas": 1, - }, - }, - sharey=False, - ) - ramsey_plotter.set_options(subplots=(2, 1), style=vis.PlotStyle({"figsize": (10, 8)})) - - options = super()._default_options() - options.update_options( - data_subfit_map={ - "Xpos": {"series": "X", "direction": "pos"}, - "Ypos": {"series": "Y", "direction": "pos"}, - "Xneg": {"series": "X", "direction": "neg"}, - "Yneg": {"series": "Y", "direction": "neg"}, - }, - result_parameters=[ - curve.ParameterRepr("c1_pos", "stark_pos_coef_o1", "Hz"), - curve.ParameterRepr("c2_pos", "stark_pos_coef_o2", "Hz"), - curve.ParameterRepr("c3_pos", "stark_pos_coef_o3", "Hz"), - curve.ParameterRepr("c1_neg", "stark_neg_coef_o1", "Hz"), - curve.ParameterRepr("c2_neg", "stark_neg_coef_o2", "Hz"), - curve.ParameterRepr("c3_neg", "stark_neg_coef_o3", "Hz"), - curve.ParameterRepr("f_err", "stark_ferr", "Hz"), - ], - plotter=ramsey_plotter, - fit_category="freq", - pulse_len=None, - ) - - return options - - def _freq_phase_coef(self) -> float: - """Return a coefficient to convert frequency into phase value.""" - try: - return 2 * np.pi * self.options.pulse_len - except TypeError as ex: - raise TypeError( - "A float-value duration in units of sec of the Stark pulse must be provided. " - f"The pulse_len option value {self.options.pulse_len} is not valid." - ) from ex - - def _format_data( - self, - curve_data: curve.ScatterTable, - category: str = "freq", - ) -> curve.ScatterTable: - - curve_data = super()._format_data(curve_data, category="ramsey_xy") - ramsey_xy = curve_data[curve_data.category == "ramsey_xy"] - - # Create phase data by arctan(Y/X) - columns = list(curve_data.columns) - phase_data = np.empty((0, len(columns))) - y_mean = ramsey_xy.yval.mean() - - grouped = ramsey_xy.groupby("name") - for m_id, direction in enumerate(("pos", "neg")): - x_quadrature = grouped.get_group(f"X{direction}") - y_quadrature = grouped.get_group(f"Y{direction}") - if not np.array_equal(x_quadrature.xval, y_quadrature.xval): - raise ValueError( - "Amplitude values of X and Y quadrature are different. " - "Same values must be used." - ) - x_uarray = unp.uarray(x_quadrature.yval, x_quadrature.yerr) - y_uarray = unp.uarray(y_quadrature.yval, y_quadrature.yerr) - - amplitudes = x_quadrature.xval.to_numpy() - - # pylint: disable=no-member - phase = unp.arctan2(y_uarray - y_mean, x_uarray - y_mean) - phase_n = unp.nominal_values(phase) - phase_s = unp.std_devs(phase) - - # Unwrap phase - # We assume a smooth slope and correct 2pi phase jump to minimize the change of the slope. - unwrapped_phase = np.unwrap(phase_n) - if amplitudes[0] < 0: - # Preserve phase value closest to 0 amplitude - unwrapped_phase = unwrapped_phase + (phase_n[-1] - unwrapped_phase[-1]) - - # Store new data - tmp = np.empty((len(amplitudes), len(columns)), dtype=object) - tmp[:, columns.index("xval")] = amplitudes - tmp[:, columns.index("yval")] = unwrapped_phase / self._freq_phase_coef() - tmp[:, columns.index("yerr")] = phase_s / self._freq_phase_coef() - tmp[:, columns.index("name")] = f"FREQ{direction}" - tmp[:, columns.index("class_id")] = m_id - tmp[:, columns.index("shots")] = x_quadrature.shots + y_quadrature.shots - tmp[:, columns.index("category")] = category - phase_data = np.r_[phase_data, tmp] - - return curve_data.append_list_values(other=phase_data) - - def _generate_fit_guesses( - self, - user_opt: curve.FitOptions, - curve_data: curve.ScatterTable, - ) -> Union[curve.FitOptions, List[curve.FitOptions]]: - """Create algorithmic initial fit guess from analysis options and curve data. - - Args: - user_opt: Fit options filled with user provided guess and bounds. - curve_data: Formatted data collection to fit. - - Returns: - List of fit options that are passed to the fitter function. - """ - user_opt.bounds.set_if_empty(c2_pos=(0, np.inf), c2_neg=(-np.inf, 0)) - user_opt.p0.set_if_empty( - c1_pos=0, c2_pos=1e6, c3_pos=0, c1_neg=0, c2_neg=-1e6, c3_neg=0, f_err=0 - ) - return user_opt - - def _create_figures( - self, - curve_data: curve.ScatterTable, - ) -> List["matplotlib.figure.Figure"]: - - # plot unwrapped phase on first axis - for d in ("pos", "neg"): - sub_data = curve_data[(curve_data.name == f"FREQ{d}") & (curve_data.category == "freq")] - self.plotter.set_series_data( - series_name=f"F{d}", - x_formatted=sub_data.xval.to_numpy(), - y_formatted=sub_data.yval.to_numpy(), - y_formatted_err=sub_data.yerr.to_numpy(), - ) - - # plot raw RamseyXY plot on second axis - for name in ("Xpos", "Ypos", "Xneg", "Yneg"): - sub_data = curve_data[(curve_data.name == name) & (curve_data.category == "ramsey_xy")] - self.plotter.set_series_data( - series_name=name, - x_formatted=sub_data.xval.to_numpy(), - y_formatted=sub_data.yval.to_numpy(), - y_formatted_err=sub_data.yerr.to_numpy(), - ) - - # find base and amplitude guess - ramsey_xy = curve_data[curve_data.category == "ramsey_xy"] - offset_guess = 0.5 * (ramsey_xy.yval.min() + ramsey_xy.yval.max()) - amp_guess = 0.5 * np.ptp(ramsey_xy.yval) - - # plot frequency and Ramsey fit lines - line_data = curve_data[curve_data.category == "fitted"] - for direction in ("pos", "neg"): - sub_data = line_data[line_data.name == f"FREQ{direction}"] - if len(sub_data) == 0: - continue - xval = sub_data.xval.to_numpy() - yn = sub_data.yval.to_numpy() - ys = sub_data.yerr.to_numpy() - yval = unp.uarray(yn, ys) * self._freq_phase_coef() - - # Ramsey fit lines are predicted from the phase fit line. - # Note that this line doesn't need to match with the expeirment data - # because Ramsey P1 data may fluctuate due to phase damping. - - # pylint: disable=no-member - ramsey_cos = amp_guess * unp.cos(yval) + offset_guess - ramsey_sin = amp_guess * unp.sin(yval) + offset_guess - - self.plotter.set_series_data( - series_name=f"F{direction}", - x_interp=xval, - y_interp=yn, - ) - self.plotter.set_series_data( - series_name=f"X{direction}", - x_interp=xval, - y_interp=unp.nominal_values(ramsey_cos), - ) - self.plotter.set_series_data( - series_name=f"Y{direction}", - x_interp=xval, - y_interp=unp.nominal_values(ramsey_sin), - ) - - if np.isfinite(ys).all(): - self.plotter.set_series_data( - series_name=f"F{direction}", - y_interp_err=ys, - ) - self.plotter.set_series_data( - series_name=f"X{direction}", - y_interp_err=unp.std_devs(ramsey_cos), - ) - self.plotter.set_series_data( - series_name=f"Y{direction}", - y_interp_err=unp.std_devs(ramsey_sin), - ) - return [self.plotter.figure()] - - def _initialize( - self, - experiment_data: ExperimentData, - ): - super()._initialize(experiment_data) - - # Set scaling factor to convert phase to frequency - if "stark_length" in experiment_data.metadata: - self.set_options(pulse_len=experiment_data.metadata["stark_length"]) diff --git a/qiskit_experiments/library/characterization/analysis/t1_analysis.py b/qiskit_experiments/library/characterization/analysis/t1_analysis.py index 4bbbabb542..9ef0ed3bc3 100644 --- a/qiskit_experiments/library/characterization/analysis/t1_analysis.py +++ b/qiskit_experiments/library/characterization/analysis/t1_analysis.py @@ -12,19 +12,12 @@ """ T1 Analysis class. """ -from typing import Union, Tuple, List, Dict +from typing import Union import numpy as np -from qiskit_ibm_experiment import IBMExperimentService -from qiskit_ibm_experiment.exceptions import IBMApiError -from uncertainties import unumpy as unp import qiskit_experiments.curve_analysis as curve -import qiskit_experiments.data_processing as dp -import qiskit_experiments.visualization as vis -from qiskit_experiments.data_processing.exceptions import DataProcessorError -from qiskit_experiments.database_service.device_component import Qubit -from qiskit_experiments.framework import BaseAnalysis, ExperimentData, AnalysisResultData, Options +from qiskit_experiments.framework import Options class T1Analysis(curve.DecayAnalysis): @@ -139,212 +132,3 @@ def _format_data( if avg_slope > 0: curve_data.yval = 1 - curve_data.yval return super()._format_data(curve_data) - - -class StarkP1SpectAnalysis(BaseAnalysis): - """Analysis class for StarkP1Spectroscopy. - - # section: overview - - The P1 landscape is hardly predictable because of the random appearance of - lossy TLS notches, and hence this analysis doesn't provide any - generic mathematical model to fit the measurement data. - A developer may subclass this to conduct own analysis. - - This analysis just visualizes the measured P1 values against Stark tone amplitudes. - The tone amplitudes can be converted into the amount of Stark shift - when the calibrated coefficients are provided in the analysis option, - or the calibration experiment results are available in the result database. - - # section: see_also - :class:`qiskit_experiments.library.characterization.ramsey_xy.StarkRamseyXYAmpScan` - - """ - - stark_coefficients_names = [ - "stark_pos_coef_o1", - "stark_pos_coef_o2", - "stark_pos_coef_o3", - "stark_neg_coef_o1", - "stark_neg_coef_o2", - "stark_neg_coef_o3", - "stark_ferr", - ] - - @property - def plotter(self) -> vis.CurvePlotter: - """Curve plotter instance.""" - return self.options.plotter - - @classmethod - def _default_options(cls) -> Options: - """Default analysis options. - - Analysis Options: - plotter (Plotter): Plotter to visualize P1 landscape. - data_processor (DataProcessor): Data processor to compute P1 value. - stark_coefficients (Union[Dict, str]): Dictionary of Stark shift coefficients to - convert tone amplitudes into amount of Stark shift. This dictionary must include - all keys defined in :attr:`.StarkP1SpectAnalysis.stark_coefficients_names`, - which are calibrated with :class:`.StarkRamseyXYAmpScan`. - Alternatively, it searches for these coefficients in the result database - when "latest" is set. This requires having the experiment service set in - the experiment data to analyze. - x_key (str): Key of the circuit metadata to represent x value. - """ - options = super()._default_options() - - p1spect_plotter = vis.CurvePlotter(vis.MplDrawer()) - p1spect_plotter.set_figure_options( - xlabel="Stark amplitude", - ylabel="P(1)", - xscale="quadratic", - ) - - options.update_options( - plotter=p1spect_plotter, - data_processor=dp.DataProcessor("counts", [dp.Probability("1")]), - stark_coefficients="latest", - x_key="xval", - ) - return options - - # pylint: disable=unused-argument - def _run_spect_analysis( - self, - xdata: np.ndarray, - ydata: np.ndarray, - ydata_err: np.ndarray, - ) -> List[AnalysisResultData]: - """Run further analysis on the spectroscopy data. - - .. note:: - A subclass can overwrite this method to conduct analysis. - - Args: - xdata: X values. This is either amplitudes or frequencies. - ydata: Y values. This is P1 values measured at different Stark tones. - ydata_err: Sampling error of the Y values. - - Returns: - A list of analysis results. - """ - return [] - - @classmethod - def retrieve_coefficients_from_service( - cls, - service: IBMExperimentService, - qubit: int, - backend: str, - ) -> Dict: - """Retrieve stark coefficient dictionary from the experiment service. - - Args: - service: A valid experiment service instance. - qubit: Qubit index. - backend: Name of the backend. - - Returns: - A dictionary of Stark coefficients to convert amplitude to frequency. - None value is returned when the dictionary is incomplete. - """ - out = {} - try: - for name in cls.stark_coefficients_names: - results = service.analysis_results( - device_components=[str(Qubit(qubit))], - result_type=name, - backend_name=backend, - sort_by=["creation_datetime:desc"], - ) - if len(results) == 0: - return None - result_data = getattr(results[0], "result_data") - out[name] = result_data["value"] - except (IBMApiError, ValueError, KeyError, AttributeError): - return None - return out - - def _convert_axis( - self, - xdata: np.ndarray, - coefficients: Dict[str, float], - ) -> np.ndarray: - """A helper method to convert x-axis. - - Args: - xdata: An array of Stark tone amplitude. - coefficients: Stark coefficients to convert amplitudes into frequencies. - - Returns: - An array of amount of Stark shift. - """ - names = self.stark_coefficients_names # alias - positive = np.poly1d([coefficients[names[idx]] for idx in [2, 1, 0, 6]]) - negative = np.poly1d([coefficients[names[idx]] for idx in [5, 4, 3, 6]]) - - new_xdata = np.where(xdata > 0, positive(xdata), negative(xdata)) - self.plotter.set_figure_options( - xlabel="Stark shift", - xval_unit="Hz", - xscale="linear", - ) - return new_xdata - - def _run_analysis( - self, - experiment_data: ExperimentData, - ) -> Tuple[List[AnalysisResultData], List["matplotlib.figure.Figure"]]: - - x_key = self.options.x_key - - # Get calibrated Stark tone coefficients - if self.options.stark_coefficients == "latest" and experiment_data.service is not None: - # Get value from service - stark_coeffs = self.retrieve_coefficients_from_service( - service=experiment_data.service, - qubit=experiment_data.metadata["physical_qubits"][0], - backend=experiment_data.backend_name, - ) - elif isinstance(self.options.stark_coefficients, dict): - # Get value from experiment options - missing = set(self.stark_coefficients_names) - self.options.stark_coefficients.keys() - if any(missing): - raise KeyError( - "Following coefficient data is missing in the " - f"'stark_coefficients' dictionary: {missing}." - ) - stark_coeffs = self.options.stark_coefficients - else: - # No calibration is available - stark_coeffs = None - - # Compute P1 value and sampling error - data = experiment_data.data() - try: - xdata = np.asarray([datum["metadata"][x_key] for datum in data], dtype=float) - except KeyError as ex: - raise DataProcessorError( - f"X value key {x_key} is not defined in circuit metadata." - ) from ex - ydata_ufloat = self.options.data_processor(data) - ydata = unp.nominal_values(ydata_ufloat) - ydata_err = unp.std_devs(ydata_ufloat) - - # Convert x-axis of amplitudes into Stark shift by consuming calibrated parameters. - if stark_coeffs: - xdata = self._convert_axis(xdata, stark_coeffs) - - # Draw figures and create analysis results. - self.plotter.set_series_data( - series_name="stark_p1", - x_formatted=xdata, - y_formatted=ydata, - y_formatted_err=ydata_err, - x_interp=xdata, - y_interp=ydata, - ) - analysis_results = self._run_spect_analysis(xdata, ydata, ydata_err) - - return analysis_results, [self.plotter.figure()] diff --git a/qiskit_experiments/library/characterization/ramsey_xy.py b/qiskit_experiments/library/characterization/ramsey_xy.py index 1d0238b652..ccb1987481 100644 --- a/qiskit_experiments/library/characterization/ramsey_xy.py +++ b/qiskit_experiments/library/characterization/ramsey_xy.py @@ -1,6 +1,6 @@ # This code is part of Qiskit. # -# (C) Copyright IBM 2021, 2023. +# (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 @@ -11,27 +11,16 @@ # that they have been altered from the originals. """Ramsey XY frequency characterization experiment.""" -import warnings -from typing import List, Tuple, Dict, Optional, Sequence +from typing import List, Optional, Sequence import numpy as np -from qiskit import pulse -from qiskit.circuit import QuantumCircuit, Gate, ParameterExpression, Parameter +from qiskit.circuit import QuantumCircuit, Parameter from qiskit.providers.backend import Backend from qiskit.qobj.utils import MeasLevel -from qiskit.utils import optionals as _optional from qiskit_experiments.framework import BaseExperiment, Options, BackendTiming from qiskit_experiments.framework.restless_mixin import RestlessMixin -from qiskit_experiments.library.characterization.analysis import ( - RamseyXYAnalysis, - StarkRamseyXYAmpScanAnalysis, -) - -if _optional.HAS_SYMENGINE: - import symengine as sym -else: - import sympy as sym +from qiskit_experiments.library.characterization.analysis import RamseyXYAnalysis class RamseyXY(BaseExperiment, RestlessMixin): @@ -208,612 +197,3 @@ def _metadata(self): if hasattr(self.run_options, run_opt): metadata[run_opt] = getattr(self.run_options, run_opt) return metadata - - -class StarkRamseyXY(BaseExperiment): - """A sign-sensitive experiment to measure the frequency of a qubit under a pulsed Stark tone. - - # section: overview - - This experiment is a variant of :class:`.RamseyXY` with a pulsed Stark tone - and consists of the following two circuits: - - .. parsed-literal:: - - (Ramsey X) The pulse before measurement rotates by pi-half around the X axis - - ┌────┐┌────────┐┌───┐┌───────────────┐┌────────┐┌────┐┌─┐ - q: ┤ √X ├┤ StarkV ├┤ X ├┤ StarkU(delay) ├┤ Rz(-π) ├┤ √X ├┤M├ - └────┘└────────┘└───┘└───────────────┘└────────┘└────┘└╥┘ - c: 1/═══════════════════════════════════════════════════════╩═ - 0 - - (Ramsey Y) The pulse before measurement rotates by pi-half around the Y axis - - ┌────┐┌────────┐┌───┐┌───────────────┐┌───────────┐┌────┐┌─┐ - q: ┤ √X ├┤ StarkV ├┤ X ├┤ StarkU(delay) ├┤ Rz(-3π/2) ├┤ √X ├┤M├ - └────┘└────────┘└───┘└───────────────┘└───────────┘└────┘└╥┘ - c: 1/══════════════════════════════════════════════════════════╩═ - 0 - - In principle, the sequence is a variant of :class:`.RamseyXY` circuit. - However, the delay in between √X gates is replaced with an off-resonant drive. - This off-resonant drive shifts the qubit frequency due to the - Stark effect and causes it to accumulate phase during the - Ramsey sequence. This frequency shift is a function of the - offset of the Stark tone frequency from the qubit frequency - and of the magnitude of the tone. - - Note that the Stark tone pulse (StarkU) takes the form of a flat-topped Gaussian envelope. - The magnitude of the pulse varies in time during its rising and falling edges. - It is difficult to characterize the net phase accumulation of the qubit during the - edges of the pulse when the frequency shift is varying with the pulse amplitude. - In order to simplify the analysis, an additional pulse (StarkV) - involving only the edges of StarkU is added to the sequence. - The sign of the phase accumulation is inverted for StarkV relative to that of StarkU - by inserting an X gate in between the two pulses. - - This technique allows the experiment to accumulate only the net phase - during the flat-top part of the StarkU pulse with constant magnitude. - - # section: analysis_ref - :py:class:`RamseyXYAnalysis` - - # section: see_also - :class:`qiskit_experiments.library.characterization.ramsey_xy.RamseyXY` - - # section: manual - :doc:`/manuals/characterization/stark_experiment` - - """ - - def __init__( - self, - physical_qubits: Sequence[int], - backend: Optional[Backend] = None, - **experiment_options, - ): - """Create new experiment. - - Args: - physical_qubits: Index of physical qubit. - backend: Optional, the backend to run the experiment on. - experiment_options: Experiment options. See the class documentation or - ``self._default_experiment_options`` for descriptions. - """ - self._timing = None - - super().__init__( - physical_qubits=physical_qubits, - analysis=RamseyXYAnalysis(), - backend=backend, - ) - self.set_experiment_options(**experiment_options) - - @classmethod - def _default_experiment_options(cls) -> Options: - """Default experiment options. - - Experiment Options: - stark_amp (float): A single float parameter to represent the magnitude of Stark tone - and the sign of expected Stark shift. - See :ref:`stark_tone_implementation` for details. - stark_channel (PulseChannel): Pulse channel to apply Stark tones. - If not provided, the same channel with the qubit drive is used. - See :ref:`stark_channel_consideration` for details. - stark_freq_offset (float): Offset of Stark tone frequency from the qubit frequency. - This offset should be sufficiently large so that the Stark pulse - does not Rabi drive the qubit. - See :ref:`stark_frequency_consideration` for details. - stark_sigma (float): Gaussian sigma of the rising and falling edges - of the Stark tone, in seconds. - stark_risefall (float): Ratio of sigma to the duration of - the rising and falling edges of the Stark tone. - min_freq (float): Minimum frequency that this experiment is guaranteed to resolve. - Note that fitter algorithm :class:`.RamseyXYAnalysis` of this experiment - is still capable of fitting experiment data with lower frequency. - max_freq (float): Maximum frequency that this experiment can resolve. - delays (list[float]): The list of delays if set that will be scanned in the - experiment. If not set, then evenly spaced delays with interval - computed from ``min_freq`` and ``max_freq`` are used. - See :meth:`StarkRamseyXY.delays` for details. - """ - options = super()._default_experiment_options() - options.update_options( - stark_amp=0.0, - stark_channel=None, - stark_freq_offset=80e6, - stark_sigma=15e-9, - stark_risefall=2, - min_freq=5e6, - max_freq=100e6, - delays=None, - ) - options.set_validator("stark_freq_offset", (0, np.inf)) - options.set_validator("stark_channel", pulse.channels.PulseChannel) - return options - - def _set_backend(self, backend: Backend): - super()._set_backend(backend) - self._timing = BackendTiming(backend) - - def set_experiment_options(self, **fields): - _warning_circuit_length = 300 - - # Do validation for circuit number - min_freq = fields.get("min_freq", None) - max_freq = fields.get("max_freq", None) - delays = fields.get("delays", None) - if min_freq is not None and max_freq is not None: - if delays: - warnings.warn( - "Experiment option 'min_freq' and 'max_freq' are ignored " - "when 'delays' are explicitly specified.", - UserWarning, - ) - else: - n_expr_circs = 2 * int(2 * max_freq / min_freq) # delays * (x, y) - max_circs_per_job = None - if self._backend_data: - max_circs_per_job = self._backend_data.max_circuits() - if n_expr_circs > (max_circs_per_job or _warning_circuit_length): - warnings.warn( - f"Provided configuration generates {n_expr_circs} circuits. " - "You can set smaller 'max_freq' or larger 'min_freq' to reduce this number. " - "This experiment is still executable but your execution may consume " - "unnecessary long quantum device time, and result may suffer " - "device parameter drift in consequence of the long execution time.", - UserWarning, - ) - # Do validation for spectrum overlap to avoid real excitation - stark_freq_offset = fields.get("stark_freq_offset", None) - stark_sigma = fields.get("stark_sigma", None) - if stark_freq_offset is not None and stark_sigma is not None: - if stark_freq_offset < 1 / stark_sigma: - warnings.warn( - "Provided configuration may induce coherent state exchange between qubit levels " - "because of the potential spectrum overlap. You can avoid this by " - "increasing the 'stark_sigma' or 'stark_freq_offset'. " - "Note that this experiment is still executable.", - UserWarning, - ) - pass - - super().set_experiment_options(**fields) - - def parameters(self) -> np.ndarray: - """Delay values to use in circuits. - - .. note:: - - The delays are computed with the ``min_freq`` and ``max_freq`` experiment options. - The maximum point is computed from the ``min_freq`` to guarantee the result - contains at least one Ramsey oscillation cycle at this frequency. - The interval is computed from the ``max_freq`` to sample with resolution - such that the Nyquist frequency is twice ``max_freq``. - - Returns: - The list of delays to use for the different circuits based on the - experiment options. - - Raises: - ValueError: When ``min_freq`` is larger than ``max_freq``. - """ - opt = self.experiment_options # alias - - if opt.delays is None: - if opt.min_freq > opt.max_freq: - raise ValueError("Experiment option 'min_freq' must be smaller than 'max_freq'.") - # Delay is longer enough to capture 1 cycle of the minimum frequency. - # Fitter can still accurately fit samples shorter than 1 cycle. - max_period = 1 / opt.min_freq - # Inverse of interval should be greater than Nyquist frequency. - sampling_freq = 2 * opt.max_freq - interval = 1 / sampling_freq - return np.arange(0, max_period, interval) - return opt.delays - - def parameterized_circuits(self) -> Tuple[QuantumCircuit, ...]: - """Create circuits with parameters for Ramsey XY experiment with Stark tone. - - Returns: - Quantum template circuits for Ramsey X and Ramsey Y experiment. - """ - opt = self.experiment_options # alias - param = Parameter("delay") - - # Pulse gates - stark_v = Gate("StarkV", 1, []) - stark_u = Gate("StarkU", 1, [param]) - - # Note that Stark tone yields negative (positive) frequency shift - # when the Stark tone frequency is higher (lower) than qubit f01 frequency. - # This choice gives positive frequency shift with positive Stark amplitude. - qubit_f01 = self._backend_data.drive_freqs[self.physical_qubits[0]] - stark_freq = qubit_f01 - np.sign(opt.stark_amp) * opt.stark_freq_offset - stark_amp = np.abs(opt.stark_amp) - stark_channel = opt.stark_channel or pulse.DriveChannel(self.physical_qubits[0]) - ramps_dt = self._timing.round_pulse(time=2 * opt.stark_risefall * opt.stark_sigma) - sigma_dt = ramps_dt / 2 / opt.stark_risefall - - with pulse.build() as stark_v_schedule: - pulse.set_frequency(stark_freq, stark_channel) - pulse.play( - pulse.Gaussian( - duration=ramps_dt, - amp=stark_amp, - sigma=sigma_dt, - name="StarkV", - ), - stark_channel, - ) - - with pulse.build() as stark_u_schedule: - pulse.set_frequency(stark_freq, stark_channel) - pulse.play( - pulse.GaussianSquare( - duration=ramps_dt + param, - amp=stark_amp, - sigma=sigma_dt, - risefall_sigma_ratio=opt.stark_risefall, - name="StarkU", - ), - stark_channel, - ) - - ram_x = QuantumCircuit(1, 1) - ram_x.sx(0) - ram_x.append(stark_v, [0]) - ram_x.x(0) - ram_x.append(stark_u, [0]) - ram_x.rz(-np.pi, 0) - ram_x.sx(0) - ram_x.measure(0, 0) - ram_x.metadata = {"series": "X"} - ram_x.add_calibration( - gate=stark_v, - qubits=self.physical_qubits, - schedule=stark_v_schedule, - ) - ram_x.add_calibration( - gate=stark_u, - qubits=self.physical_qubits, - schedule=stark_u_schedule, - ) - - ram_y = QuantumCircuit(1, 1) - ram_y.sx(0) - ram_y.append(stark_v, [0]) - ram_y.x(0) - ram_y.append(stark_u, [0]) - ram_y.rz(-np.pi * 3 / 2, 0) - ram_y.sx(0) - ram_y.measure(0, 0) - ram_y.metadata = {"series": "Y"} - ram_y.add_calibration( - gate=stark_v, - qubits=self.physical_qubits, - schedule=stark_v_schedule, - ) - ram_y.add_calibration( - gate=stark_u, - qubits=self.physical_qubits, - schedule=stark_u_schedule, - ) - - return ram_x, ram_y - - def circuits(self) -> List[QuantumCircuit]: - """Create circuits. - - Returns: - A list of circuits with a variable delay. - """ - timing = BackendTiming(self.backend, min_length=0) - - ramx_circ, ramy_circ = self.parameterized_circuits() - param = next(iter(ramx_circ.parameters)) - - circs = [] - for delay in self.parameters(): - valid_delay_dt = timing.round_pulse(time=delay) - net_delay_sec = timing.pulse_time(time=delay) - - ramx_circ_assigned = ramx_circ.assign_parameters({param: valid_delay_dt}, inplace=False) - ramx_circ_assigned.metadata["xval"] = net_delay_sec - - ramy_circ_assigned = ramy_circ.assign_parameters({param: valid_delay_dt}, inplace=False) - ramy_circ_assigned.metadata["xval"] = net_delay_sec - - circs.extend([ramx_circ_assigned, ramy_circ_assigned]) - - return circs - - def _metadata(self) -> Dict[str, any]: - """Return experiment metadata for ExperimentData.""" - metadata = super()._metadata() - metadata["stark_amp"] = self.experiment_options.stark_amp - metadata["stark_freq_offset"] = self.experiment_options.stark_freq_offset - - return metadata - - -class StarkRamseyXYAmpScan(BaseExperiment): - r"""A fast characterization of Stark frequency shift by varying Stark tone amplitude. - - # section: overview - - This experiment scans Stark tone amplitude at a fixed tone duration. - The experimental circuits are identical to the :class:`.StarkRamseyXY` experiment - except that the Stark pulse amplitude is the scanned parameter rather than the pulse width. - - .. parsed-literal:: - - (Ramsey X) The pulse before measurement rotates by pi-half around the X axis - - ┌────┐┌───────────────────┐┌───┐┌───────────────────┐┌────────┐┌────┐┌─┐ - q: ┤ √X ├┤ StarkV(stark_amp) ├┤ X ├┤ StarkU(stark_amp) ├┤ Rz(-π) ├┤ √X ├┤M├ - └────┘└───────────────────┘└───┘└───────────────────┘└────────┘└────┘└╥┘ - c: 1/══════════════════════════════════════════════════════════════════════╩═ - 0 - - (Ramsey Y) The pulse before measurement rotates by pi-half around the Y axis - - ┌────┐┌───────────────────┐┌───┐┌───────────────────┐┌───────────┐┌────┐┌─┐ - q: ┤ √X ├┤ StarkV(stark_amp) ├┤ X ├┤ StarkU(stark_amp) ├┤ Rz(-3π/2) ├┤ √X ├┤M├ - └────┘└───────────────────┘└───┘└───────────────────┘└───────────┘└────┘└╥┘ - c: 1/═════════════════════════════════════════════════════════════════════════╩═ - 0 - - The AC Stark effect can be used to shift the frequency of a qubit with a microwave drive. - To calibrate a specific frequency shift, the :class:`.StarkRamseyXY` experiment can be run - to scan the Stark pulse duration at every amplitude, but such a two dimensional scan of - the tone duration and amplitude may require many circuit executions. - To avoid this overhead, the :class:`.StarkRamseyXYAmpScan` experiment fixes the - tone duration and scans only amplitude. - - Recall that an observed Ramsey oscillation in each quadrature may be represented by - - .. math:: - - {\cal E}_X(\Omega, t_S) = A e^{-t_S/\tau} \cos \left( 2\pi f_S(\Omega) t_S \right), \\ - {\cal E}_Y(\Omega, t_S) = A e^{-t_S/\tau} \sin \left( 2\pi f_S(\Omega) t_S \right), - - where :math:`f_S(\Omega)` denotes the amount of Stark shift - at a constant tone amplitude :math:`\Omega`, and :math:`t_S` is the duration of the - applied tone. For a fixed tone duration, - one can still observe the Ramsey oscillation by scanning the tone amplitude. - However, since :math:`f_S` is usually a higher order polynomial of :math:`\Omega`, - one must manage to fit the y-data for trigonometric functions with - phase which non-linearly changes with the x-data. - The :class:`.StarkRamseyXYAmpScan` experiment thus drastically reduces the number of - circuits to run in return for greater complexity in the fitting model. - - # section: analysis_ref - :py:class:`StarkRamseyXYAmpScanAnalysis` - - # section: see_also - :class:`qiskit_experiments.library.characterization.ramsey_xy.StarkRamseyXY` - :class:`qiskit_experiments.library.characterization.ramsey_xy.RamseyXY` - - # section: manual - :doc:`/manuals/characterization/stark_experiment` - - """ - - def __init__( - self, - physical_qubits: Sequence[int], - backend: Optional[Backend] = None, - **experiment_options, - ): - """Create new experiment. - - Args: - physical_qubits: Sequence with the index of the physical qubit. - backend: Optional, the backend to run the experiment on. - experiment_options: Experiment options. See the class documentation or - ``self._default_experiment_options`` for descriptions. - """ - self._timing = None - - super().__init__( - physical_qubits=physical_qubits, - analysis=StarkRamseyXYAmpScanAnalysis(), - backend=backend, - ) - self.set_experiment_options(**experiment_options) - - @classmethod - def _default_experiment_options(cls) -> Options: - """Default experiment options. - - Experiment Options: - stark_channel (PulseChannel): Pulse channel on which to apply Stark tones. - If not provided, the same channel with the qubit drive is used. - See :ref:`stark_channel_consideration` for details. - stark_freq_offset (float): Offset of Stark tone frequency from the qubit frequency. - This offset should be sufficiently large so that the Stark pulse - does not Rabi drive the qubit. - See :ref:`stark_frequency_consideration` for details. - stark_sigma (float): Gaussian sigma of the rising and falling edges - of the Stark tone, in seconds. - stark_risefall (float): Ratio of sigma to the duration of - the rising and falling edges of the Stark tone. - stark_length (float): Time to accumulate Stark shifted phase in seconds. - min_stark_amp (float): Minimum Stark tone amplitude. - max_stark_amp (float): Maximum Stark tone amplitude. - num_stark_amps (int): Number of Stark tone amplitudes to scan. - stark_amps (list[float]): The list of amplitude that will be scanned in the experiment. - If not set, then ``num_stark_amps`` evenly spaced amplitudes - between ``min_stark_amp`` and ``max_stark_amp`` are used. If ``stark_amps`` - is set, these parameters are ignored. - """ - options = super()._default_experiment_options() - options.update_options( - stark_channel=None, - stark_freq_offset=80e6, - stark_sigma=15e-9, - stark_risefall=2, - stark_length=50e-9, - min_stark_amp=-1.0, - max_stark_amp=1.0, - num_stark_amps=101, - stark_amps=None, - ) - options.set_validator("stark_freq_offset", (0, np.inf)) - options.set_validator("stark_channel", pulse.channels.PulseChannel) - return options - - def _set_backend(self, backend: Backend): - super()._set_backend(backend) - self._timing = BackendTiming(backend) - - def parameters(self) -> np.ndarray: - """Stark tone amplitudes to use in circuits. - - Returns: - The list of amplitudes to use for the different circuits based on the - experiment options. - """ - opt = self.experiment_options # alias - - if opt.stark_amps is None: - params = np.linspace(opt.min_stark_amp, opt.max_stark_amp, opt.num_stark_amps) - else: - params = np.asarray(opt.stark_amps, dtype=float) - - return params - - def parameterized_circuits(self) -> Tuple[QuantumCircuit, ...]: - """Create circuits with parameters for Ramsey XY experiment with Stark tone. - - Returns: - Quantum template circuits for Ramsey X and Ramsey Y experiment. - """ - opt = self.experiment_options # alias - param = Parameter("stark_amp") - sym_param = param._symbol_expr - - # Pulse gates - stark_v = Gate("StarkV", 1, [param]) - stark_u = Gate("StarkU", 1, [param]) - - # Note that Stark tone yields negative (positive) frequency shift - # when the Stark tone frequency is higher (lower) than qubit f01 frequency. - # This choice gives positive frequency shift with positive Stark amplitude. - qubit_f01 = self._backend_data.drive_freqs[self.physical_qubits[0]] - neg_sign_of_amp = ParameterExpression( - symbol_map={param: sym_param}, - expr=-sym.sign(sym_param), - ) - abs_of_amp = ParameterExpression( - symbol_map={param: sym_param}, - expr=sym.Abs(sym_param), - ) - stark_freq = qubit_f01 + neg_sign_of_amp * opt.stark_freq_offset - stark_channel = opt.stark_channel or pulse.DriveChannel(self.physical_qubits[0]) - ramps_dt = self._timing.round_pulse(time=2 * opt.stark_risefall * opt.stark_sigma) - sigma_dt = ramps_dt / 2 / opt.stark_risefall - width_dt = self._timing.round_pulse(time=opt.stark_length) - - with pulse.build() as stark_v_schedule: - pulse.set_frequency(stark_freq, stark_channel) - pulse.play( - pulse.Gaussian( - duration=ramps_dt, - amp=abs_of_amp, - sigma=sigma_dt, - ), - stark_channel, - ) - - with pulse.build() as stark_u_schedule: - pulse.set_frequency(stark_freq, stark_channel) - pulse.play( - pulse.GaussianSquare( - duration=ramps_dt + width_dt, - amp=abs_of_amp, - sigma=sigma_dt, - risefall_sigma_ratio=opt.stark_risefall, - ), - stark_channel, - ) - - ram_x = QuantumCircuit(1, 1) - ram_x.sx(0) - ram_x.append(stark_v, [0]) - ram_x.x(0) - ram_x.append(stark_u, [0]) - ram_x.rz(-np.pi, 0) - ram_x.sx(0) - ram_x.measure(0, 0) - ram_x.metadata = {"series": "X"} - ram_x.add_calibration( - gate=stark_v, - qubits=self.physical_qubits, - schedule=stark_v_schedule, - ) - ram_x.add_calibration( - gate=stark_u, - qubits=self.physical_qubits, - schedule=stark_u_schedule, - ) - - ram_y = QuantumCircuit(1, 1) - ram_y.sx(0) - ram_y.append(stark_v, [0]) - ram_y.x(0) - ram_y.append(stark_u, [0]) - ram_y.rz(-np.pi * 3 / 2, 0) - ram_y.sx(0) - ram_y.measure(0, 0) - ram_y.metadata = {"series": "Y"} - ram_y.add_calibration( - gate=stark_v, - qubits=self.physical_qubits, - schedule=stark_v_schedule, - ) - ram_y.add_calibration( - gate=stark_u, - qubits=self.physical_qubits, - schedule=stark_u_schedule, - ) - - return ram_x, ram_y - - def circuits(self) -> List[QuantumCircuit]: - """Create circuits. - - Returns: - A list of circuits with a variable Stark tone amplitudes. - """ - ramx_circ, ramy_circ = self.parameterized_circuits() - param = next(iter(ramx_circ.parameters)) - - circs = [] - for amp in self.parameters(): - # Add metadata "direction" to ease the filtering of the data - # by curve analysis. Indeed, the fit parameters are amplitude sign dependent. - - ramx_circ_assigned = ramx_circ.assign_parameters({param: amp}, inplace=False) - ramx_circ_assigned.metadata["xval"] = amp - ramx_circ_assigned.metadata["direction"] = "pos" if amp > 0 else "neg" - - ramy_circ_assigned = ramy_circ.assign_parameters({param: amp}, inplace=False) - ramy_circ_assigned.metadata["xval"] = amp - ramy_circ_assigned.metadata["direction"] = "pos" if amp > 0 else "neg" - - circs.extend([ramx_circ_assigned, ramy_circ_assigned]) - - return circs - - def _metadata(self) -> Dict[str, any]: - """Return experiment metadata for ExperimentData.""" - metadata = super()._metadata() - metadata["stark_length"] = self._timing.pulse_time( - time=self.experiment_options.stark_length - ) - metadata["stark_freq_offset"] = self.experiment_options.stark_freq_offset - - return metadata diff --git a/qiskit_experiments/library/characterization/t1.py b/qiskit_experiments/library/characterization/t1.py index 751f9a569b..0554599a25 100644 --- a/qiskit_experiments/library/characterization/t1.py +++ b/qiskit_experiments/library/characterization/t1.py @@ -13,24 +13,14 @@ T1 Experiment class. """ -from typing import List, Tuple, Dict, Optional, Union, Sequence +from typing import List, Optional, Union, Sequence import numpy as np -from qiskit import pulse -from qiskit.circuit import QuantumCircuit, Gate, Parameter, ParameterExpression +from qiskit.circuit import QuantumCircuit from qiskit.providers.backend import Backend -from qiskit.utils import optionals as _optional from qiskit_experiments.framework import BackendTiming, BaseExperiment, Options -from qiskit_experiments.library.characterization.analysis.t1_analysis import ( - T1Analysis, - StarkP1SpectAnalysis, -) - -if _optional.HAS_SYMENGINE: - import symengine as sym -else: - import sympy as sym +from qiskit_experiments.library.characterization.analysis.t1_analysis import T1Analysis class T1(BaseExperiment): @@ -119,215 +109,3 @@ def _metadata(self): if hasattr(self.run_options, run_opt): metadata[run_opt] = getattr(self.run_options, run_opt) return metadata - - -class StarkP1Spectroscopy(BaseExperiment): - """P1 spectroscopy experiment with Stark tone. - - # section: overview - - This experiment measures a probability of the excitation state of the qubit - with a certain delay after excitation. - A Stark tone is applied during this delay to move the - qubit frequency to conduct a spectroscopy of qubit relaxation quantity. - - .. parsed-literal:: - - ┌───┐┌──────────────────┐┌─┐ - q: ┤ X ├┤ Stark(stark_amp) ├┤M├ - └───┘└──────────────────┘└╥┘ - c: 1/══════════════════════════╩═ - 0 - - Since the qubit relaxation rate may depend on the qubit frequency due to the - coupling to nearby energy levels, this experiment is useful to find out - lossy operation frequency that might be harmful to the gate fidelity [1]. - - # section: analysis_ref - :py:class:`.StarkP1SpectAnalysis` - - # section: reference - .. ref_arxiv:: 1 2105.15201 - - # section: see_also - :class:`qiskit_experiments.library.characterization.ramsey_xy.StarkRamseyXY` - :class:`qiskit_experiments.library.characterization.ramsey_xy.StarkRamseyXYAmpScan` - - # section: manual - :doc:`/manuals/characterization/stark_experiment` - - """ - - def __init__( - self, - physical_qubits: Sequence[int], - backend: Optional[Backend] = None, - **experiment_options, - ): - """ - Initialize the T1 experiment class. - - Args: - physical_qubits: Sequence with the index of the physical qubit. - backend: Optional, the backend to run the experiment on. - experiment_options: Experiment options. See the class documentation or - ``self._default_experiment_options`` for descriptions. - """ - self._timing = None - - super().__init__( - physical_qubits=physical_qubits, - analysis=StarkP1SpectAnalysis(), - backend=backend, - ) - self.set_experiment_options(**experiment_options) - - @classmethod - def _default_experiment_options(cls) -> Options: - """Default experiment options. - - Experiment Options: - t1_delay (float): The T1 delay time after excitation pulse. The delay must be - sufficiently greater than the edge duration determined by the stark_sigma. - stark_channel (PulseChannel): Pulse channel to apply Stark tones. - If not provided, the same channel with the qubit drive is used. - stark_freq_offset (float): Offset of Stark tone frequency from the qubit frequency. - This must be greater than zero not to apply Rabi drive. - stark_sigma (float): Gaussian sigma of the rising and falling edges - of the Stark tone, in seconds. - stark_risefall (float): Ratio of sigma to the duration of - the rising and falling edges of the Stark tone. - min_stark_amp (float): Minimum Stark tone amplitude. - max_stark_amp (float): Maximum Stark tone amplitude. - num_stark_amps (int): Number of Stark tone amplitudes to scan. - spacing (str): A policy for the spacing to create an amplitude list from - ``min_stark_amp`` to ``max_stark_amp``. Either ``linear`` or ``quadratic`` - must be specified. - stark_amps (list[float]): The list of amplitude that will be scanned in the experiment. - If not set, then ``num_stark_amps`` amplitudes spaced according to - the ``spacing`` policy between ``min_stark_amp`` and ``max_stark_amp`` are used. - If ``stark_amps`` is set, these parameters are ignored. - """ - options = super()._default_experiment_options() - options.update_options( - t1_delay=20e-6, - stark_channel=None, - stark_freq_offset=80e6, - stark_sigma=15e-9, - stark_risefall=2, - min_stark_amp=-1, - max_stark_amp=1, - num_stark_amps=201, - spacing="quadratic", - stark_amps=None, - ) - options.set_validator("spacing", ["linear", "quadratic"]) - options.set_validator("stark_freq_offset", (0, np.inf)) - options.set_validator("stark_channel", pulse.channels.PulseChannel) - return options - - def _set_backend(self, backend: Backend): - super()._set_backend(backend) - self._timing = BackendTiming(backend) - - def parameters(self) -> np.ndarray: - """Stark tone amplitudes to use in circuits. - - Returns: - The list of amplitudes to use for the different circuits based on the - experiment options. - """ - opt = self.experiment_options # alias - - if opt.stark_amps is None: - if opt.spacing == "linear": - params = np.linspace(opt.min_stark_amp, opt.max_stark_amp, opt.num_stark_amps) - elif opt.spacing == "quadratic": - min_sqrt = np.sign(opt.min_stark_amp) * np.sqrt(np.abs(opt.min_stark_amp)) - max_sqrt = np.sign(opt.max_stark_amp) * np.sqrt(np.abs(opt.max_stark_amp)) - lin_params = np.linspace(min_sqrt, max_sqrt, opt.num_stark_amps) - params = np.sign(lin_params) * lin_params**2 - else: - raise ValueError(f"Spacing option {opt.spacing} is not valid.") - else: - params = np.asarray(opt.stark_amps, dtype=float) - - return params - - def parameterized_circuits(self) -> Tuple[QuantumCircuit, ...]: - """Create circuits with parameters for P1 experiment with Stark shift. - - Returns: - Quantum template circuit for P1 experiment. - """ - opt = self.experiment_options # alias - param = Parameter("stark_amp") - sym_param = param._symbol_expr - - # Pulse gates - stark = Gate("Stark", 1, [param]) - - # Note that Stark tone yields negative (positive) frequency shift - # when the Stark tone frequency is higher (lower) than qubit f01 frequency. - # This choice gives positive frequency shift with positive Stark amplitude. - qubit_f01 = self._backend_data.drive_freqs[self.physical_qubits[0]] - neg_sign_of_amp = ParameterExpression( - symbol_map={param: sym_param}, - expr=-sym.sign(sym_param), - ) - abs_of_amp = ParameterExpression( - symbol_map={param: sym_param}, - expr=sym.Abs(sym_param), - ) - stark_freq = qubit_f01 + neg_sign_of_amp * opt.stark_freq_offset - stark_channel = opt.stark_channel or pulse.DriveChannel(self.physical_qubits[0]) - sigma_dt = opt.stark_sigma / self._backend_data.dt - delay_dt = self._timing.round_pulse(time=opt.t1_delay) - - with pulse.build() as stark_schedule: - pulse.set_frequency(stark_freq, stark_channel) - pulse.play( - pulse.GaussianSquare( - duration=delay_dt, - amp=abs_of_amp, - sigma=sigma_dt, - risefall_sigma_ratio=opt.stark_risefall, - ), - stark_channel, - ) - - temp_t1 = QuantumCircuit(1, 1) - temp_t1.x(0) - temp_t1.append(stark, [0]) - temp_t1.measure(0, 0) - temp_t1.add_calibration( - gate=stark, - qubits=self.physical_qubits, - schedule=stark_schedule, - ) - - return (temp_t1,) - - def circuits(self) -> List[QuantumCircuit]: - """Create circuits. - - Returns: - A list of P1 circuits with a variable Stark tone amplitudes. - """ - (t1_circ,) = self.parameterized_circuits() - param = next(iter(t1_circ.parameters)) - - circs = [] - for amp in self.parameters(): - t1_assigned = t1_circ.assign_parameters({param: amp}, inplace=False) - t1_assigned.metadata = {"xval": amp} - circs.append(t1_assigned) - - return circs - - def _metadata(self) -> Dict[str, any]: - """Return experiment metadata for ExperimentData.""" - metadata = super()._metadata() - metadata["stark_freq_offset"] = self.experiment_options.stark_freq_offset - - return metadata diff --git a/qiskit_experiments/library/driven_freq_tuning/__init__.py b/qiskit_experiments/library/driven_freq_tuning/__init__.py new file mode 100644 index 0000000000..0bef002700 --- /dev/null +++ b/qiskit_experiments/library/driven_freq_tuning/__init__.py @@ -0,0 +1,63 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2023. +# +# 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. + +""" +=============================================================================================== +Driven Frequency Tuning (:mod:`qiskit_experiments.library.driven_freq_tuning`) +=============================================================================================== + +.. currentmodule:: qiskit_experiments.library.driven_freq_tuning + +Experiments +=========== +.. autosummary:: + :toctree: ../stubs/ + :template: autosummary/experiment.rst + + StarkRamseyXY + StarkRamseyXYAmpScan + StarkP1Spectroscopy + + +Analysis +======== + +.. autosummary:: + :toctree: ../stubs/ + :template: autosummary/analysis.rst + + StarkRamseyXYAmpScanAnalysis + StarkP1SpectAnalysis + + +Stark Coefficient +================= + +.. autosummary:: + :toctree: ../stubs/ + + StarkCoefficients + retrieve_coefficients_from_backend + retrieve_coefficients_from_service +""" + +from .ramsey_amp_scan_analysis import StarkRamseyXYAmpScanAnalysis +from .p1_spect_analysis import StarkP1SpectAnalysis +from .ramsey import StarkRamseyXY +from .ramsey_amp_scan import StarkRamseyXYAmpScan +from .p1_spect import StarkP1Spectroscopy + +from .coefficients import ( + StarkCoefficients, + retrieve_coefficients_from_backend, + retrieve_coefficients_from_service, +) diff --git a/qiskit_experiments/library/driven_freq_tuning/coefficients.py b/qiskit_experiments/library/driven_freq_tuning/coefficients.py new file mode 100644 index 0000000000..89dd840ed2 --- /dev/null +++ b/qiskit_experiments/library/driven_freq_tuning/coefficients.py @@ -0,0 +1,279 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2024. +# +# 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. +"""Coefficients characterizing Stark shift.""" + +from __future__ import annotations +from typing import Any + +import numpy as np + +from qiskit.providers.backend import Backend +from qiskit_ibm_experiment.service import IBMExperimentService +from qiskit_ibm_experiment.exceptions import IBMApiError + +from qiskit_experiments.framework.json import ExperimentDecoder +from qiskit_experiments.framework.backend_data import BackendData +from qiskit_experiments.framework.experiment_data import ExperimentData + + +class StarkCoefficients: + """A collection of coefficients characterizing Stark shift.""" + + def __init__( + self, + pos_coef_o1: float, + pos_coef_o2: float, + pos_coef_o3: float, + neg_coef_o1: float, + neg_coef_o2: float, + neg_coef_o3: float, + offset: float, + ): + """Create new coefficients object. + + Args: + pos_coef_o1: The first order shift coefficient on positive amplitude. + pos_coef_o2: The second order shift coefficient on positive amplitude. + pos_coef_o3: The third order shift coefficient on positive amplitude. + neg_coef_o1: The first order shift coefficient on negative amplitude. + neg_coef_o2: The second order shift coefficient on negative amplitude. + neg_coef_o3: The third order shift coefficient on negative amplitude. + offset: Offset frequency. + """ + self.pos_coef_o1 = pos_coef_o1 + self.pos_coef_o2 = pos_coef_o2 + self.pos_coef_o3 = pos_coef_o3 + self.neg_coef_o1 = neg_coef_o1 + self.neg_coef_o2 = neg_coef_o2 + self.neg_coef_o3 = neg_coef_o3 + self.offset = offset + + def positive_coeffs(self) -> list[float]: + """Positive coefficients.""" + return [self.pos_coef_o3, self.pos_coef_o2, self.pos_coef_o1] + + def negative_coeffs(self) -> list[float]: + """Negative coefficients.""" + return [self.neg_coef_o3, self.neg_coef_o2, self.neg_coef_o1] + + def convert_freq_to_amp( + self, + freqs: np.ndarray, + ) -> np.ndarray: + """A helper function to convert Stark frequency to amplitude. + + Args: + freqs: Target frequency shifts to compute required Stark amplitude. + + Returns: + Estimated Stark amplitudes to induce input frequency shifts. + + Raises: + ValueError: When amplitude value cannot be solved. + """ + amplitudes = np.zeros_like(freqs) + for idx, freq in enumerate(freqs): + shift = freq - self.offset + if np.isclose(shift, 0.0): + amplitudes[idx] = 0.0 + continue + if shift > 0: + fit = [*self.positive_coeffs(), -shift] + else: + fit = [*self.negative_coeffs(), -shift] + amp_candidates = np.roots(fit) + # Because the fit function is third order, we get three solutions here. + criteria = np.all( + [ + # Frequency shift and tone have the same sign by definition + np.sign(amp_candidates.real) == np.sign(shift), + # Tone amplitude is a real value + np.isclose(amp_candidates.imag, 0.0), + # The absolute value of tone amplitude must be less than 1.0 + 10 mp + np.abs(amp_candidates.real) < 1.0 + 10 * np.finfo(float).eps, + ], + axis=0, + ) + valid_amps = amp_candidates[criteria] + if len(valid_amps) == 0: + raise ValueError(f"Stark shift at frequency value of {freq} Hz is not available.") + if len(valid_amps) > 1: + # We assume a monotonic trend but sometimes a large third-order term causes + # inflection point and inverts the trend in larger amplitudes. + # In this case we would have more than one solution, but we can + # take the smallest amplitude before reaching to the inflection point. + before_inflection = np.argmin(np.abs(valid_amps.real)) + valid_amp = float(valid_amps[before_inflection].real) + else: + valid_amp = float(valid_amps[0].real) + amplitudes[idx] = min(valid_amp, 1.0) + return amplitudes + + def convert_amp_to_freq( + self, + amps: np.ndarray, + ) -> np.ndarray: + """A helper function to convert Stark amplitude to frequency shift. + + Args: + amps: Amplitude values to convert into frequency shift. + + Returns: + Calculated frequency shift at given Stark amplitude. + """ + pos_fit = np.poly1d([*self.positive_coeffs(), self.offset]) + neg_fit = np.poly1d([*self.negative_coeffs(), self.offset]) + + return np.where(amps > 0, pos_fit(amps), neg_fit(amps)) + + def find_min_max_frequency( + self, + min_amp: float, + max_amp: float, + ) -> tuple[float, float]: + """A helper function to estimate maximum frequency shift within given amplitude budget. + + Args: + min_amp: Minimum Stark amplitude. + max_amp: Maximum Stark amplitude. + + Returns: + Minimum and maximum frequency shift available within the amplitude range. + """ + trim_amps = [] + for amp in [min_amp, max_amp]: + if amp > 0: + fit = self.positive_coeffs() + else: + fit = self.negative_coeffs() + # Solve for inflection points by computing the point where derivative becomes zero. + solutions = np.roots([deriv * coeff for deriv, coeff in zip((3, 2, 1), fit)]) + inflection_points = solutions[ + (solutions.imag == 0) & (np.sign(solutions) == np.sign(amp)) + ] + if len(inflection_points) > 0: + # When multiple inflection points are found, use the most outer one. + # There could be a small inflection point around amp=0, + # when the first order term is significant. + amp = min([amp, max(inflection_points, key=abs)], key=abs) + trim_amps.append(amp) + return tuple(self.convert_amp_to_freq(np.asarray(trim_amps))) + + def __str__(self): + # Short representation for dataframe + return "StarkCoefficients(...)" + + def __eq__(self, other): + return all( + [ + self.pos_coef_o1 == other.pos_coef_o1, + self.pos_coef_o2 == other.pos_coef_o2, + self.pos_coef_o3 == other.pos_coef_o3, + self.neg_coef_o1 == other.neg_coef_o1, + self.neg_coef_o2 == other.neg_coef_o2, + self.neg_coef_o3 == other.neg_coef_o3, + ] + ) + + def __json_encode__(self) -> dict[str, Any]: + return { + "class": "StarkCoefficients", + "data": { + "pos_coef_o1": self.pos_coef_o1, + "pos_coef_o2": self.pos_coef_o2, + "pos_coef_o3": self.pos_coef_o3, + "neg_coef_o1": self.neg_coef_o1, + "neg_coef_o2": self.neg_coef_o2, + "neg_coef_o3": self.neg_coef_o3, + "offset": self.offset, + }, + } + + @classmethod + def __json_decode__(cls, value: dict[str, Any]) -> "StarkCoefficients": + if not value.get("class", None) == "StarkCoefficients": + raise ValueError("JSON decoded value for StarkCoefficients is not valid class type.") + return StarkCoefficients(**value.get("data", {})) + + +def retrieve_coefficients_from_service( + service: IBMExperimentService, + backend_name: str, + qubit: int, +) -> StarkCoefficients: + """Retrieve StarkCoefficients object from experiment service. + + Args: + service: IBM Experiment service instance interfacing with result database. + backend_name: Name of target backend. + qubit: Index of qubit. + + Returns: + StarkCoefficients object. + + Raises: + RuntimeError: When stark_coefficients entry doesn't exist in the service. + """ + try: + retrieved = service.analysis_results( + device_components=[f"Q{qubit}"], + result_type="stark_coefficients", + backend_name=backend_name, + sort_by=["creation_datetime:desc"], + json_decoder=ExperimentDecoder, + # Returns the latest value only. IBM service returns 10 entries by default. + # This could contain old data from previous version, which might not be deserialized. + limit=1, + ) + except (IBMApiError, ValueError) as ex: + raise RuntimeError( + f"Failed to retrieve the result of stark_coefficients: {ex.message}" + ) from ex + if len(retrieved) == 0: + raise RuntimeError( + "Analysis result of stark_coefficients is not found in the " + "experiment service. Run and save the result of StarkRamseyXYAmpScan." + ) + + result_data_dict = retrieved[0].result_data + if "_value" in result_data_dict: + # In IBM Experiment service, the result_data["value"] returns + # a display value for the experiment service webpage. + # Original data is stored in "_value". + # TODO: this must be handled by experiment service. + return result_data_dict["_value"] + return result_data_dict["value"] + + +def retrieve_coefficients_from_backend( + backend: Backend, + qubit: int, +) -> StarkCoefficients: + """Retrieve StarkCoefficients object from the Qiskit backend. + + Args: + backend: Qiskit backend object. + qubit: Index of qubit. + + Returns: + StarkCoefficients object. + + Raises: + RuntimeError: When experiment service cannot be loaded from backend. + """ + name = BackendData(backend).name + service = ExperimentData.get_service_from_backend(backend) + + if service is None: + raise RuntimeError(f"Valid experiment service is not found for the backend {name}.") + + return retrieve_coefficients_from_service(service, name, qubit) diff --git a/qiskit_experiments/library/driven_freq_tuning/p1_spect.py b/qiskit_experiments/library/driven_freq_tuning/p1_spect.py new file mode 100644 index 0000000000..5ee1bbc18e --- /dev/null +++ b/qiskit_experiments/library/driven_freq_tuning/p1_spect.py @@ -0,0 +1,269 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2023. +# +# 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. +"""P1 experiment at various qubit frequencies.""" + +from __future__ import annotations + +from collections.abc import Sequence + +import numpy as np +from qiskit import pulse +from qiskit.circuit import QuantumCircuit, Gate, Parameter, ParameterExpression +from qiskit.providers.backend import Backend +from qiskit.utils import optionals as _optional + +from qiskit_experiments.framework import BackendTiming, BaseExperiment, Options +from .p1_spect_analysis import StarkP1SpectAnalysis + +from .coefficients import ( + StarkCoefficients, + retrieve_coefficients_from_backend, +) + +if _optional.HAS_SYMENGINE: + import symengine as sym +else: + import sympy as sym + + +class StarkP1Spectroscopy(BaseExperiment): + """P1 spectroscopy experiment with Stark tone. + + # section: overview + + This experiment measures a probability of the excitation state of the qubit + with a certain delay after excitation. + A Stark tone is applied during this delay to move the + qubit frequency to conduct a spectroscopy of qubit relaxation quantity. + + .. parsed-literal:: + + ┌───┐┌──────────────────┐┌─┐ + q: ┤ X ├┤ Stark(stark_amp) ├┤M├ + └───┘└──────────────────┘└╥┘ + c: 1/══════════════════════════╩═ + 0 + + Since the qubit relaxation rate may depend on the qubit frequency due to the + coupling to nearby energy levels, this experiment is useful to find out + lossy operation frequency that might be harmful to the gate fidelity [1]. + + # section: analysis_ref + :class:`qiskit_experiments.library.driven_freq_tuning.StarkP1SpectAnalysis` + + # section: reference + .. ref_arxiv:: 1 2105.15201 + + # section: see_also + :class:`qiskit_experiments.library.driven_freq_tuning.ramsey.StarkRamseyXY` + :class:`qiskit_experiments.library.driven_freq_tuning.ramsey_amp_scan.StarkRamseyXYAmpScan` + + # section: manual + :doc:`/manuals/characterization/stark_experiment` + + """ + + def __init__( + self, + physical_qubits: Sequence[int], + backend: Backend | None = None, + **experiment_options, + ): + """ + Initialize new experiment class. + + Args: + physical_qubits: Sequence with the index of the physical qubit. + backend: Optional, the backend to run the experiment on. + experiment_options: Experiment options. See the class documentation or + ``self._default_experiment_options`` for descriptions. + """ + self._timing = None + + super().__init__( + physical_qubits=physical_qubits, + analysis=StarkP1SpectAnalysis(), + backend=backend, + ) + self.set_experiment_options(**experiment_options) + + @classmethod + def _default_experiment_options(cls) -> Options: + """Default experiment options. + + Experiment Options: + t1_delay (float): The T1 delay time after excitation pulse. The delay must be + sufficiently greater than the edge duration determined by the stark_sigma. + stark_channel (PulseChannel): Pulse channel to apply Stark tones. + If not provided, the same channel with the qubit drive is used. + stark_freq_offset (float): Offset of Stark tone frequency from the qubit frequency. + This must be greater than zero not to apply Rabi drive. + stark_sigma (float): Gaussian sigma of the rising and falling edges + of the Stark tone, in seconds. + stark_risefall (float): Ratio of sigma to the duration of + the rising and falling edges of the Stark tone. + min_xval (float): Minimum x value. + max_xval (float): Maximum x value. + num_xvals (int): Number of x-values to scan. + xval_type (str): Type of x-value. Either ``amplitude`` or ``frequency``. + Setting to frequency requires pre-calibration of Stark shift coefficients. + spacing (str): A policy for the spacing to create an amplitude list from + ``min_stark_amp`` to ``max_stark_amp``. Either ``linear`` or ``quadratic`` + must be specified. + xvals (list[float]): The list of x-values that will be scanned in the experiment. + If not set, then ``num_xvals`` parameters spaced according to + the ``spacing`` policy between ``min_xval`` and ``max_xval`` are used. + If ``xvals`` is set, these parameters are ignored. + stark_coefficients (StarkCoefficients): Calibrated Stark shift coefficients. + This value is necessary when xval_type is "frequency". + When this value is None, a search for the "stark_coefficients" in the + result database is run. This requires having the experiment service + available in the backend set for the experiment. + """ + options = super()._default_experiment_options() + options.update_options( + t1_delay=20e-6, + stark_channel=None, + stark_freq_offset=80e6, + stark_sigma=15e-9, + stark_risefall=2, + min_xval=-1.0, + max_xval=1.0, + num_xvals=201, + xval_type="amplitude", + spacing="quadratic", + xvals=None, + stark_coefficients=None, + ) + options.set_validator("spacing", ["linear", "quadratic"]) + options.set_validator("xval_type", ["amplitude", "frequency"]) + options.set_validator("stark_freq_offset", (0, np.inf)) + options.set_validator("stark_channel", pulse.channels.PulseChannel) + options.set_validator("stark_coefficients", StarkCoefficients) + return options + + def _set_backend(self, backend: Backend): + super()._set_backend(backend) + self._timing = BackendTiming(backend) + + def parameters(self) -> np.ndarray: + """Stark tone amplitudes to use in circuits. + + Returns: + The list of amplitudes to use for the different circuits based on the + experiment options. + + Raises: + ValueError: When invalid xval spacing is specified. + """ + opt = self.experiment_options # alias + + if opt.xvals is None: + if opt.spacing == "linear": + params = np.linspace(opt.min_xval, opt.max_xval, opt.num_xvals) + elif opt.spacing == "quadratic": + min_sqrt = np.sign(opt.min_xval) * np.sqrt(np.abs(opt.min_xval)) + max_sqrt = np.sign(opt.max_xval) * np.sqrt(np.abs(opt.max_xval)) + lin_params = np.linspace(min_sqrt, max_sqrt, opt.num_xvals) + params = np.sign(lin_params) * lin_params**2 + else: + raise ValueError(f"Spacing option {opt.spacing} is not valid.") + else: + params = np.asarray(opt.xvals, dtype=float) + + if opt.xval_type == "frequency": + coeffs = opt.stark_coefficients + if coeffs is None: + coeffs = retrieve_coefficients_from_backend( + backend=self.backend, + qubit=self.physical_qubits[0], + ) + return coeffs.convert_freq_to_amp(freqs=params) + return params + + def parameterized_circuits(self) -> tuple[QuantumCircuit, ...]: + """Create circuits with parameters for P1 experiment with Stark shift. + + Returns: + Quantum template circuit for P1 experiment. + """ + opt = self.experiment_options # alias + param = Parameter("stark_amp") + sym_param = param._symbol_expr + + # Pulse gates + stark = Gate("Stark", 1, [param]) + + # Note that Stark tone yields negative (positive) frequency shift + # when the Stark tone frequency is higher (lower) than qubit f01 frequency. + # This choice gives positive frequency shift with positive Stark amplitude. + qubit_f01 = self._backend_data.drive_freqs[self.physical_qubits[0]] + neg_sign_of_amp = ParameterExpression( + symbol_map={param: sym_param}, + expr=-sym.sign(sym_param), + ) + abs_of_amp = ParameterExpression( + symbol_map={param: sym_param}, + expr=sym.Abs(sym_param), + ) + stark_freq = qubit_f01 + neg_sign_of_amp * opt.stark_freq_offset + stark_channel = opt.stark_channel or pulse.DriveChannel(self.physical_qubits[0]) + sigma_dt = opt.stark_sigma / self._backend_data.dt + delay_dt = self._timing.round_pulse(time=opt.t1_delay) + + with pulse.build() as stark_schedule: + pulse.set_frequency(stark_freq, stark_channel) + pulse.play( + pulse.GaussianSquare( + duration=delay_dt, + amp=abs_of_amp, + sigma=sigma_dt, + risefall_sigma_ratio=opt.stark_risefall, + ), + stark_channel, + ) + + temp_t1 = QuantumCircuit(1, 1) + temp_t1.x(0) + temp_t1.append(stark, [0]) + temp_t1.measure(0, 0) + temp_t1.add_calibration( + gate=stark, + qubits=self.physical_qubits, + schedule=stark_schedule, + ) + + return (temp_t1,) + + def circuits(self) -> list[QuantumCircuit]: + """Create circuits. + + Returns: + A list of P1 circuits with a variable Stark tone amplitudes. + """ + (t1_circ,) = self.parameterized_circuits() + param = next(iter(t1_circ.parameters)) + + circs = [] + for amp in self.parameters(): + t1_assigned = t1_circ.assign_parameters({param: amp}, inplace=False) + t1_assigned.metadata = {"xval": amp} + circs.append(t1_assigned) + + return circs + + def _metadata(self) -> dict[str, any]: + """Return experiment metadata for ExperimentData.""" + metadata = super()._metadata() + metadata["stark_freq_offset"] = self.experiment_options.stark_freq_offset + + return metadata diff --git a/qiskit_experiments/library/driven_freq_tuning/p1_spect_analysis.py b/qiskit_experiments/library/driven_freq_tuning/p1_spect_analysis.py new file mode 100644 index 0000000000..857f440bb8 --- /dev/null +++ b/qiskit_experiments/library/driven_freq_tuning/p1_spect_analysis.py @@ -0,0 +1,163 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2023. +# +# 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. +"""P1 spectroscopy analyses.""" + +from __future__ import annotations + +import numpy as np +from uncertainties import unumpy as unp + +import qiskit_experiments.data_processing as dp +import qiskit_experiments.visualization as vis +from qiskit_experiments.data_processing.exceptions import DataProcessorError +from qiskit_experiments.framework import BaseAnalysis, ExperimentData, AnalysisResultData, Options +from .coefficients import ( + StarkCoefficients, + retrieve_coefficients_from_service, +) + + +class StarkP1SpectAnalysis(BaseAnalysis): + """Analysis class for StarkP1Spectroscopy. + + # section: overview + + The P1 landscape is hardly predictable because of the random appearance of + lossy TLS notches, and hence this analysis doesn't provide any + generic mathematical model to fit the measurement data. + A developer may subclass this to conduct own analysis. + The :meth:`StarkP1SpectAnalysis._run_spect_analysis` is a hook method where + you can define a custom analysis protocol. + + By default, this analysis just visualizes the measured P1 values against Stark tone amplitudes. + The tone amplitudes can be converted into the amount of Stark shift + when the calibrated coefficients are provided in the analysis option, + or the calibration experiment results are available in the result database. + + # section: see_also + :class:`qiskit_experiments.library.driven_freq_tuning.StarkRamseyXYAmpScan` + + """ + + @property + def plotter(self) -> vis.CurvePlotter: + """Curve plotter instance.""" + return self.options.plotter + + @classmethod + def _default_options(cls) -> Options: + """Default analysis options. + + Analysis Options: + plotter (Plotter): Plotter to visualize P1 landscape. + data_processor (DataProcessor): Data processor to compute P1 value. + stark_coefficients (Union[Dict, str]): Dictionary of Stark shift coefficients to + convert tone amplitudes into amount of Stark shift. This dictionary must include + all keys defined in :attr:`.StarkP1SpectAnalysis.stark_coefficients_names`, + which are calibrated with :class:`.StarkRamseyXYAmpScan`. + Alternatively, it searches for these coefficients in the result database + when "latest" is set. This requires having the experiment service set in + the experiment data to analyze. + x_key (str): Key of the circuit metadata to represent x value. + """ + options = super()._default_options() + + p1spect_plotter = vis.CurvePlotter(vis.MplDrawer()) + p1spect_plotter.set_figure_options( + xlabel="Stark amplitude", + ylabel="P(1)", + xscale="quadratic", + ) + + options.update_options( + plotter=p1spect_plotter, + data_processor=dp.DataProcessor("counts", [dp.Probability("1")]), + stark_coefficients=None, + x_key="xval", + ) + options.set_validator("stark_coefficients", StarkCoefficients) + + return options + + # pylint: disable=unused-argument + def _run_spect_analysis( + self, + xdata: np.ndarray, + ydata: np.ndarray, + ydata_err: np.ndarray, + ) -> list[AnalysisResultData]: + """Run further analysis on the spectroscopy data. + + .. note:: + A subclass can overwrite this method to conduct analysis. + + Args: + xdata: X values. This is either amplitudes or frequencies. + ydata: Y values. This is P1 values measured at different Stark tones. + ydata_err: Sampling error of the Y values. + + Returns: + A list of analysis results. + """ + return [] + + def _run_analysis( + self, + experiment_data: ExperimentData, + ) -> tuple[list[AnalysisResultData], list["matplotlib.figure.Figure"]]: + + x_key = self.options.x_key + + # Get calibrated Stark tone coefficients + if self.options.stark_coefficients is None and experiment_data.service is not None: + # Get value from service + stark_coeffs = retrieve_coefficients_from_service( + service=experiment_data.service, + backend_name=experiment_data.backend_name, + qubit=experiment_data.metadata["physical_qubits"][0], + ) + else: + stark_coeffs = self.options.stark_coefficients + + # Compute P1 value and sampling error + data = experiment_data.data() + try: + xdata = np.asarray([datum["metadata"][x_key] for datum in data], dtype=float) + except KeyError as ex: + raise DataProcessorError( + f"X value key {x_key} is not defined in circuit metadata." + ) from ex + ydata_ufloat = self.options.data_processor(data) + ydata = unp.nominal_values(ydata_ufloat) + ydata_err = unp.std_devs(ydata_ufloat) + + # Convert x-axis of amplitudes into Stark shift by consuming calibrated parameters. + if isinstance(stark_coeffs, StarkCoefficients): + xdata = stark_coeffs.convert_amp_to_freq(amps=xdata) + self.plotter.set_figure_options( + xlabel="Stark shift", + xval_unit="Hz", + xscale="linear", + ) + + # Draw figures and create analysis results. + self.plotter.set_series_data( + series_name="stark_p1", + x_formatted=xdata, + y_formatted=ydata, + y_formatted_err=ydata_err, + x_interp=xdata, + y_interp=ydata, + ) + analysis_results = self._run_spect_analysis(xdata, ydata, ydata_err) + + return analysis_results, [self.plotter.figure()] diff --git a/qiskit_experiments/library/driven_freq_tuning/ramsey.py b/qiskit_experiments/library/driven_freq_tuning/ramsey.py new file mode 100644 index 0000000000..e214a5c4b4 --- /dev/null +++ b/qiskit_experiments/library/driven_freq_tuning/ramsey.py @@ -0,0 +1,359 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2023. +# +# 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. +"""Stark Ramsey experiment.""" + +from __future__ import annotations + +import warnings +from collections.abc import Sequence + +import numpy as np +from qiskit import pulse +from qiskit.circuit import QuantumCircuit, Gate, Parameter +from qiskit.providers.backend import Backend +from qiskit.utils import optionals as _optional + +from qiskit_experiments.framework import BaseExperiment, Options, BackendTiming +from qiskit_experiments.library.characterization.analysis import RamseyXYAnalysis + +if _optional.HAS_SYMENGINE: + pass +else: + pass + + +class StarkRamseyXY(BaseExperiment): + """A sign-sensitive experiment to measure the frequency of a qubit under a pulsed Stark tone. + + # section: overview + + This experiment is a variant of :class:`.RamseyXY` with a pulsed Stark tone + and consists of the following two circuits: + + .. parsed-literal:: + + (Ramsey X) The pulse before measurement rotates by pi-half around the X axis + + ┌────┐┌────────┐┌───┐┌───────────────┐┌────────┐┌────┐┌─┐ + q: ┤ √X ├┤ StarkV ├┤ X ├┤ StarkU(delay) ├┤ Rz(-π) ├┤ √X ├┤M├ + └────┘└────────┘└───┘└───────────────┘└────────┘└────┘└╥┘ + c: 1/═══════════════════════════════════════════════════════╩═ + 0 + + (Ramsey Y) The pulse before measurement rotates by pi-half around the Y axis + + ┌────┐┌────────┐┌───┐┌───────────────┐┌───────────┐┌────┐┌─┐ + q: ┤ √X ├┤ StarkV ├┤ X ├┤ StarkU(delay) ├┤ Rz(-3π/2) ├┤ √X ├┤M├ + └────┘└────────┘└───┘└───────────────┘└───────────┘└────┘└╥┘ + c: 1/══════════════════════════════════════════════════════════╩═ + 0 + + In principle, the sequence is a variant of :class:`.RamseyXY` circuit. + However, the delay in between √X gates is replaced with an off-resonant drive. + This off-resonant drive shifts the qubit frequency due to the + Stark effect and causes it to accumulate phase during the + Ramsey sequence. This frequency shift is a function of the + offset of the Stark tone frequency from the qubit frequency + and of the magnitude of the tone. + + Note that the Stark tone pulse (StarkU) takes the form of a flat-topped Gaussian envelope. + The magnitude of the pulse varies in time during its rising and falling edges. + It is difficult to characterize the net phase accumulation of the qubit during the + edges of the pulse when the frequency shift is varying with the pulse amplitude. + In order to simplify the analysis, an additional pulse (StarkV) + involving only the edges of StarkU is added to the sequence. + The sign of the phase accumulation is inverted for StarkV relative to that of StarkU + by inserting an X gate in between the two pulses. + + This technique allows the experiment to accumulate only the net phase + during the flat-top part of the StarkU pulse with constant magnitude. + + # section: analysis_ref + :class:`qiskit_experiments.library.characterization.RamseyXYAnalysis` + + # section: see_also + :class:`qiskit_experiments.library.characterization.ramsey_xy.RamseyXY` + + # section: manual + :doc:`/manuals/characterization/stark_experiment` + + """ + + def __init__( + self, + physical_qubits: Sequence[int], + backend: Backend | None = None, + **experiment_options, + ): + """Create new experiment. + + Args: + physical_qubits: Index of physical qubit. + backend: Optional, the backend to run the experiment on. + experiment_options: Experiment options. See the class documentation or + ``self._default_experiment_options`` for descriptions. + """ + self._timing = None + + super().__init__( + physical_qubits=physical_qubits, + analysis=RamseyXYAnalysis(), + backend=backend, + ) + self.set_experiment_options(**experiment_options) + + @classmethod + def _default_experiment_options(cls) -> Options: + """Default experiment options. + + Experiment Options: + stark_amp (float): A single float parameter to represent the magnitude of Stark tone + and the sign of expected Stark shift. + See :ref:`stark_tone_implementation` for details. + stark_channel (PulseChannel): Pulse channel to apply Stark tones. + If not provided, the same channel with the qubit drive is used. + See :ref:`stark_channel_consideration` for details. + stark_freq_offset (float): Offset of Stark tone frequency from the qubit frequency. + This offset should be sufficiently large so that the Stark pulse + does not Rabi drive the qubit. + See :ref:`stark_frequency_consideration` for details. + stark_sigma (float): Gaussian sigma of the rising and falling edges + of the Stark tone, in seconds. + stark_risefall (float): Ratio of sigma to the duration of + the rising and falling edges of the Stark tone. + min_freq (float): Minimum frequency that this experiment is guaranteed to resolve. + Note that fitter algorithm :class:`.RamseyXYAnalysis` of this experiment + is still capable of fitting experiment data with lower frequency. + max_freq (float): Maximum frequency that this experiment can resolve. + delays (list[float]): The list of delays if set that will be scanned in the + experiment. If not set, then evenly spaced delays with interval + computed from ``min_freq`` and ``max_freq`` are used. + See :meth:`StarkRamseyXY.delays` for details. + """ + options = super()._default_experiment_options() + options.update_options( + stark_amp=0.0, + stark_channel=None, + stark_freq_offset=80e6, + stark_sigma=15e-9, + stark_risefall=2, + min_freq=5e6, + max_freq=100e6, + delays=None, + ) + options.set_validator("stark_freq_offset", (0, np.inf)) + options.set_validator("stark_channel", pulse.channels.PulseChannel) + return options + + def _set_backend(self, backend: Backend): + super()._set_backend(backend) + self._timing = BackendTiming(backend) + + def set_experiment_options(self, **fields): + _warning_circuit_length = 300 + + # Do validation for circuit number + min_freq = fields.get("min_freq", None) + max_freq = fields.get("max_freq", None) + delays = fields.get("delays", None) + if min_freq is not None and max_freq is not None: + if delays: + warnings.warn( + "Experiment option 'min_freq' and 'max_freq' are ignored " + "when 'delays' are explicitly specified.", + UserWarning, + ) + else: + n_expr_circs = 2 * int(2 * max_freq / min_freq) # delays * (x, y) + max_circs_per_job = None + if self._backend_data: + max_circs_per_job = self._backend_data.max_circuits() + if n_expr_circs > (max_circs_per_job or _warning_circuit_length): + warnings.warn( + f"Provided configuration generates {n_expr_circs} circuits. " + "You can set smaller 'max_freq' or larger 'min_freq' to reduce this number. " + "This experiment is still executable but your execution may consume " + "unnecessary long quantum device time, and result may suffer " + "device parameter drift in consequence of the long execution time.", + UserWarning, + ) + # Do validation for spectrum overlap to avoid real excitation + stark_freq_offset = fields.get("stark_freq_offset", None) + stark_sigma = fields.get("stark_sigma", None) + if stark_freq_offset is not None and stark_sigma is not None: + if stark_freq_offset < 1 / stark_sigma: + warnings.warn( + "Provided configuration may induce coherent state exchange between qubit levels " + "because of the potential spectrum overlap. You can avoid this by " + "increasing the 'stark_sigma' or 'stark_freq_offset'. " + "Note that this experiment is still executable.", + UserWarning, + ) + pass + + super().set_experiment_options(**fields) + + def parameters(self) -> np.ndarray: + """Delay values to use in circuits. + + .. note:: + + The delays are computed with the ``min_freq`` and ``max_freq`` experiment options. + The maximum point is computed from the ``min_freq`` to guarantee the result + contains at least one Ramsey oscillation cycle at this frequency. + The interval is computed from the ``max_freq`` to sample with resolution + such that the Nyquist frequency is twice ``max_freq``. + + Returns: + The list of delays to use for the different circuits based on the + experiment options. + + Raises: + ValueError: When ``min_freq`` is larger than ``max_freq``. + """ + opt = self.experiment_options # alias + + if opt.delays is None: + if opt.min_freq > opt.max_freq: + raise ValueError("Experiment option 'min_freq' must be smaller than 'max_freq'.") + # Delay is longer enough to capture 1 cycle of the minimum frequency. + # Fitter can still accurately fit samples shorter than 1 cycle. + max_period = 1 / opt.min_freq + # Inverse of interval should be greater than Nyquist frequency. + sampling_freq = 2 * opt.max_freq + interval = 1 / sampling_freq + return np.arange(0, max_period, interval) + return opt.delays + + def parameterized_circuits(self) -> tuple[QuantumCircuit, ...]: + """Create circuits with parameters for Ramsey XY experiment with Stark tone. + + Returns: + Quantum template circuits for Ramsey X and Ramsey Y experiment. + """ + opt = self.experiment_options # alias + param = Parameter("delay") + + # Pulse gates + stark_v = Gate("StarkV", 1, []) + stark_u = Gate("StarkU", 1, [param]) + + # Note that Stark tone yields negative (positive) frequency shift + # when the Stark tone frequency is higher (lower) than qubit f01 frequency. + # This choice gives positive frequency shift with positive Stark amplitude. + qubit_f01 = self._backend_data.drive_freqs[self.physical_qubits[0]] + stark_freq = qubit_f01 - np.sign(opt.stark_amp) * opt.stark_freq_offset + stark_amp = np.abs(opt.stark_amp) + stark_channel = opt.stark_channel or pulse.DriveChannel(self.physical_qubits[0]) + ramps_dt = self._timing.round_pulse(time=2 * opt.stark_risefall * opt.stark_sigma) + sigma_dt = ramps_dt / 2 / opt.stark_risefall + + with pulse.build() as stark_v_schedule: + pulse.set_frequency(stark_freq, stark_channel) + pulse.play( + pulse.Gaussian( + duration=ramps_dt, + amp=stark_amp, + sigma=sigma_dt, + name="StarkV", + ), + stark_channel, + ) + + with pulse.build() as stark_u_schedule: + pulse.set_frequency(stark_freq, stark_channel) + pulse.play( + pulse.GaussianSquare( + duration=ramps_dt + param, + amp=stark_amp, + sigma=sigma_dt, + risefall_sigma_ratio=opt.stark_risefall, + name="StarkU", + ), + stark_channel, + ) + + ram_x = QuantumCircuit(1, 1) + ram_x.sx(0) + ram_x.append(stark_v, [0]) + ram_x.x(0) + ram_x.append(stark_u, [0]) + ram_x.rz(-np.pi, 0) + ram_x.sx(0) + ram_x.measure(0, 0) + ram_x.metadata = {"series": "X"} + ram_x.add_calibration( + gate=stark_v, + qubits=self.physical_qubits, + schedule=stark_v_schedule, + ) + ram_x.add_calibration( + gate=stark_u, + qubits=self.physical_qubits, + schedule=stark_u_schedule, + ) + + ram_y = QuantumCircuit(1, 1) + ram_y.sx(0) + ram_y.append(stark_v, [0]) + ram_y.x(0) + ram_y.append(stark_u, [0]) + ram_y.rz(-np.pi * 3 / 2, 0) + ram_y.sx(0) + ram_y.measure(0, 0) + ram_y.metadata = {"series": "Y"} + ram_y.add_calibration( + gate=stark_v, + qubits=self.physical_qubits, + schedule=stark_v_schedule, + ) + ram_y.add_calibration( + gate=stark_u, + qubits=self.physical_qubits, + schedule=stark_u_schedule, + ) + + return ram_x, ram_y + + def circuits(self) -> list[QuantumCircuit]: + """Create circuits. + + Returns: + A list of circuits with a variable delay. + """ + timing = BackendTiming(self.backend, min_length=0) + + ramx_circ, ramy_circ = self.parameterized_circuits() + param = next(iter(ramx_circ.parameters)) + + circs = [] + for delay in self.parameters(): + valid_delay_dt = timing.round_pulse(time=delay) + net_delay_sec = timing.pulse_time(time=delay) + + ramx_circ_assigned = ramx_circ.assign_parameters({param: valid_delay_dt}, inplace=False) + ramx_circ_assigned.metadata["xval"] = net_delay_sec + + ramy_circ_assigned = ramy_circ.assign_parameters({param: valid_delay_dt}, inplace=False) + ramy_circ_assigned.metadata["xval"] = net_delay_sec + + circs.extend([ramx_circ_assigned, ramy_circ_assigned]) + + return circs + + def _metadata(self) -> dict[str, any]: + """Return experiment metadata for ExperimentData.""" + metadata = super()._metadata() + metadata["stark_amp"] = self.experiment_options.stark_amp + metadata["stark_freq_offset"] = self.experiment_options.stark_freq_offset + + return metadata diff --git a/qiskit_experiments/library/driven_freq_tuning/ramsey_amp_scan.py b/qiskit_experiments/library/driven_freq_tuning/ramsey_amp_scan.py new file mode 100644 index 0000000000..528c3fd8bd --- /dev/null +++ b/qiskit_experiments/library/driven_freq_tuning/ramsey_amp_scan.py @@ -0,0 +1,311 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2023. +# +# 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. +"""Stark Ramsey experiment directly scanning Stark amplitude.""" + +from __future__ import annotations + +from collections.abc import Sequence + +import numpy as np +from qiskit import pulse +from qiskit.circuit import QuantumCircuit, Gate, ParameterExpression, Parameter +from qiskit.providers.backend import Backend +from qiskit.utils import optionals as _optional + +from qiskit_experiments.framework import BaseExperiment, Options, BackendTiming +from .ramsey_amp_scan_analysis import StarkRamseyXYAmpScanAnalysis + +if _optional.HAS_SYMENGINE: + import symengine as sym +else: + import sympy as sym + + +class StarkRamseyXYAmpScan(BaseExperiment): + r"""A fast characterization of Stark frequency shift by varying Stark tone amplitude. + + # section: overview + + This experiment scans Stark tone amplitude at a fixed tone duration. + The experimental circuits are identical to the :class:`.StarkRamseyXY` experiment + except that the Stark pulse amplitude is the scanned parameter rather than the pulse width. + + .. parsed-literal:: + + (Ramsey X) The pulse before measurement rotates by pi-half around the X axis + + ┌────┐┌───────────────────┐┌───┐┌───────────────────┐┌────────┐┌────┐┌─┐ + q: ┤ √X ├┤ StarkV(stark_amp) ├┤ X ├┤ StarkU(stark_amp) ├┤ Rz(-π) ├┤ √X ├┤M├ + └────┘└───────────────────┘└───┘└───────────────────┘└────────┘└────┘└╥┘ + c: 1/══════════════════════════════════════════════════════════════════════╩═ + 0 + + (Ramsey Y) The pulse before measurement rotates by pi-half around the Y axis + + ┌────┐┌───────────────────┐┌───┐┌───────────────────┐┌───────────┐┌────┐┌─┐ + q: ┤ √X ├┤ StarkV(stark_amp) ├┤ X ├┤ StarkU(stark_amp) ├┤ Rz(-3π/2) ├┤ √X ├┤M├ + └────┘└───────────────────┘└───┘└───────────────────┘└───────────┘└────┘└╥┘ + c: 1/═════════════════════════════════════════════════════════════════════════╩═ + 0 + + The AC Stark effect can be used to shift the frequency of a qubit with a microwave drive. + To calibrate a specific frequency shift, the :class:`.StarkRamseyXY` experiment can be run + to scan the Stark pulse duration at every amplitude, but such a two dimensional scan of + the tone duration and amplitude may require many circuit executions. + To avoid this overhead, the :class:`.StarkRamseyXYAmpScan` experiment fixes the + tone duration and scans only amplitude. + + Recall that an observed Ramsey oscillation in each quadrature may be represented by + + .. math:: + + {\cal E}_X(\Omega, t_S) = A e^{-t_S/\tau} \cos \left( 2\pi f_S(\Omega) t_S \right), \\ + {\cal E}_Y(\Omega, t_S) = A e^{-t_S/\tau} \sin \left( 2\pi f_S(\Omega) t_S \right), + + where :math:`f_S(\Omega)` denotes the amount of Stark shift + at a constant tone amplitude :math:`\Omega`, and :math:`t_S` is the duration of the + applied tone. For a fixed tone duration, + one can still observe the Ramsey oscillation by scanning the tone amplitude. + However, since :math:`f_S` is usually a higher order polynomial of :math:`\Omega`, + one must manage to fit the y-data for trigonometric functions with + phase which non-linearly changes with the x-data. + The :class:`.StarkRamseyXYAmpScan` experiment thus drastically reduces the number of + circuits to run in return for greater complexity in the fitting model. + + # section: analysis_ref + :class:`qiskit_experiments.library.driven_freq_tuning.StarkRamseyXYAmpScanAnalysis` + + # section: see_also + :class:`qiskit_experiments.library.driven_freq_tuning.ramsey.StarkRamseyXY` + :class:`qiskit_experiments.library.characterization.ramsey_xy.RamseyXY` + + # section: manual + :doc:`/manuals/characterization/stark_experiment` + + """ + + def __init__( + self, + physical_qubits: Sequence[int], + backend: Backend | None = None, + **experiment_options, + ): + """Create new experiment. + + Args: + physical_qubits: Sequence with the index of the physical qubit. + backend: Optional, the backend to run the experiment on. + experiment_options: Experiment options. See the class documentation or + ``self._default_experiment_options`` for descriptions. + """ + self._timing = None + + super().__init__( + physical_qubits=physical_qubits, + analysis=StarkRamseyXYAmpScanAnalysis(), + backend=backend, + ) + self.set_experiment_options(**experiment_options) + + @classmethod + def _default_experiment_options(cls) -> Options: + """Default experiment options. + + Experiment Options: + stark_channel (PulseChannel): Pulse channel on which to apply Stark tones. + If not provided, the same channel with the qubit drive is used. + See :ref:`stark_channel_consideration` for details. + stark_freq_offset (float): Offset of Stark tone frequency from the qubit frequency. + This offset should be sufficiently large so that the Stark pulse + does not Rabi drive the qubit. + See :ref:`stark_frequency_consideration` for details. + stark_sigma (float): Gaussian sigma of the rising and falling edges + of the Stark tone, in seconds. + stark_risefall (float): Ratio of sigma to the duration of + the rising and falling edges of the Stark tone. + stark_length (float): Time to accumulate Stark shifted phase in seconds. + min_stark_amp (float): Minimum Stark tone amplitude. + max_stark_amp (float): Maximum Stark tone amplitude. + num_stark_amps (int): Number of Stark tone amplitudes to scan. + stark_amps (list[float]): The list of amplitude that will be scanned in the experiment. + If not set, then ``num_stark_amps`` evenly spaced amplitudes + between ``min_stark_amp`` and ``max_stark_amp`` are used. If ``stark_amps`` + is set, these parameters are ignored. + """ + options = super()._default_experiment_options() + options.update_options( + stark_channel=None, + stark_freq_offset=80e6, + stark_sigma=15e-9, + stark_risefall=2, + stark_length=50e-9, + min_stark_amp=-1.0, + max_stark_amp=1.0, + num_stark_amps=101, + stark_amps=None, + ) + options.set_validator("stark_freq_offset", (0, np.inf)) + options.set_validator("stark_channel", pulse.channels.PulseChannel) + return options + + def _set_backend(self, backend: Backend): + super()._set_backend(backend) + self._timing = BackendTiming(backend) + + def parameters(self) -> np.ndarray: + """Stark tone amplitudes to use in circuits. + + Returns: + The list of amplitudes to use for the different circuits based on the + experiment options. + """ + opt = self.experiment_options # alias + + if opt.stark_amps is None: + params = np.linspace(opt.min_stark_amp, opt.max_stark_amp, opt.num_stark_amps) + else: + params = np.asarray(opt.stark_amps, dtype=float) + + return params + + def parameterized_circuits(self) -> tuple[QuantumCircuit, ...]: + """Create circuits with parameters for Ramsey XY experiment with Stark tone. + + Returns: + Quantum template circuits for Ramsey X and Ramsey Y experiment. + """ + opt = self.experiment_options # alias + param = Parameter("stark_amp") + sym_param = param._symbol_expr + + # Pulse gates + stark_v = Gate("StarkV", 1, [param]) + stark_u = Gate("StarkU", 1, [param]) + + # Note that Stark tone yields negative (positive) frequency shift + # when the Stark tone frequency is higher (lower) than qubit f01 frequency. + # This choice gives positive frequency shift with positive Stark amplitude. + qubit_f01 = self._backend_data.drive_freqs[self.physical_qubits[0]] + neg_sign_of_amp = ParameterExpression( + symbol_map={param: sym_param}, + expr=-sym.sign(sym_param), + ) + abs_of_amp = ParameterExpression( + symbol_map={param: sym_param}, + expr=sym.Abs(sym_param), + ) + stark_freq = qubit_f01 + neg_sign_of_amp * opt.stark_freq_offset + stark_channel = opt.stark_channel or pulse.DriveChannel(self.physical_qubits[0]) + ramps_dt = self._timing.round_pulse(time=2 * opt.stark_risefall * opt.stark_sigma) + sigma_dt = ramps_dt / 2 / opt.stark_risefall + width_dt = self._timing.round_pulse(time=opt.stark_length) + + with pulse.build() as stark_v_schedule: + pulse.set_frequency(stark_freq, stark_channel) + pulse.play( + pulse.Gaussian( + duration=ramps_dt, + amp=abs_of_amp, + sigma=sigma_dt, + ), + stark_channel, + ) + + with pulse.build() as stark_u_schedule: + pulse.set_frequency(stark_freq, stark_channel) + pulse.play( + pulse.GaussianSquare( + duration=ramps_dt + width_dt, + amp=abs_of_amp, + sigma=sigma_dt, + risefall_sigma_ratio=opt.stark_risefall, + ), + stark_channel, + ) + + ram_x = QuantumCircuit(1, 1) + ram_x.sx(0) + ram_x.append(stark_v, [0]) + ram_x.x(0) + ram_x.append(stark_u, [0]) + ram_x.rz(-np.pi, 0) + ram_x.sx(0) + ram_x.measure(0, 0) + ram_x.metadata = {"series": "X"} + ram_x.add_calibration( + gate=stark_v, + qubits=self.physical_qubits, + schedule=stark_v_schedule, + ) + ram_x.add_calibration( + gate=stark_u, + qubits=self.physical_qubits, + schedule=stark_u_schedule, + ) + + ram_y = QuantumCircuit(1, 1) + ram_y.sx(0) + ram_y.append(stark_v, [0]) + ram_y.x(0) + ram_y.append(stark_u, [0]) + ram_y.rz(-np.pi * 3 / 2, 0) + ram_y.sx(0) + ram_y.measure(0, 0) + ram_y.metadata = {"series": "Y"} + ram_y.add_calibration( + gate=stark_v, + qubits=self.physical_qubits, + schedule=stark_v_schedule, + ) + ram_y.add_calibration( + gate=stark_u, + qubits=self.physical_qubits, + schedule=stark_u_schedule, + ) + + return ram_x, ram_y + + def circuits(self) -> list[QuantumCircuit]: + """Create circuits. + + Returns: + A list of circuits with a variable Stark tone amplitudes. + """ + ramx_circ, ramy_circ = self.parameterized_circuits() + param = next(iter(ramx_circ.parameters)) + + circs = [] + for amp in self.parameters(): + # Add metadata "direction" to ease the filtering of the data + # by curve analysis. Indeed, the fit parameters are amplitude sign dependent. + + ramx_circ_assigned = ramx_circ.assign_parameters({param: amp}, inplace=False) + ramx_circ_assigned.metadata["xval"] = amp + ramx_circ_assigned.metadata["direction"] = "pos" if amp > 0 else "neg" + + ramy_circ_assigned = ramy_circ.assign_parameters({param: amp}, inplace=False) + ramy_circ_assigned.metadata["xval"] = amp + ramy_circ_assigned.metadata["direction"] = "pos" if amp > 0 else "neg" + + circs.extend([ramx_circ_assigned, ramy_circ_assigned]) + + return circs + + def _metadata(self) -> dict[str, any]: + """Return experiment metadata for ExperimentData.""" + metadata = super()._metadata() + metadata["stark_length"] = self._timing.pulse_time( + time=self.experiment_options.stark_length + ) + metadata["stark_freq_offset"] = self.experiment_options.stark_freq_offset + + return metadata diff --git a/qiskit_experiments/library/driven_freq_tuning/ramsey_amp_scan_analysis.py b/qiskit_experiments/library/driven_freq_tuning/ramsey_amp_scan_analysis.py new file mode 100644 index 0000000000..9ced48b07a --- /dev/null +++ b/qiskit_experiments/library/driven_freq_tuning/ramsey_amp_scan_analysis.py @@ -0,0 +1,440 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2023. +# +# 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. +"""Ramsey amplitude scan analysis.""" + +from __future__ import annotations + +from typing import List, Union + +import lmfit +import numpy as np +from uncertainties import unumpy as unp + +import qiskit_experiments.curve_analysis as curve +import qiskit_experiments.visualization as vis +from qiskit_experiments.framework import ExperimentData, AnalysisResultData +from .coefficients import StarkCoefficients + + +class StarkRamseyXYAmpScanAnalysis(curve.CurveAnalysis): + r"""Ramsey XY analysis for the Stark shifted phase sweep. + + # section: overview + + This analysis is a variant of :class:`RamseyXYAnalysis`. In both cases, the X and Y + data are treated as the real and imaginary parts of a complex oscillating signal. + In :class:`RamseyXYAnalysis`, the data are fit assuming a phase varying linearly with + the x-data corresponding to a constant frequency and assuming an exponentially + decaying amplitude. By contrast, in this model, the phase is assumed to be + a third order polynomial :math:`\theta(x)` of the x-data. + Additionally, the amplitude is not assumed to follow a specific form. + Techniques to compute a good initial guess for the polynomial coefficients inside + a trigonometric function like this are not trivial. Instead, this analysis extracts the + raw phase and runs fits on the extracted data to a polynomial :math:`\theta(x)` directly. + + The measured P1 values for a Ramsey X and Y experiment can be written in the form of + a trignometric function taking the phase polynomial :math:`\theta(x)`: + + .. math:: + + P_X = \text{amp}(x) \cdot \cos \theta(x) + \text{offset},\\ + P_Y = \text{amp}(x) \cdot \sin \theta(x) + \text{offset}. + + Hence the phase polynomial can be extracted as follows + + .. math:: + + \theta(x) = \tan^{-1} \frac{P_Y}{P_X}. + + Because the arctangent is implemented by the ``atan2`` function + defined in :math:`[-\pi, \pi]`, the computed :math:`\theta(x)` is unwrapped to + ensure continuous phase evolution. + + We call attention to the fact that :math:`\text{amp}(x)` is also Stark tone amplitude + dependent because of the qubit frequency dependence of the dephasing rate. + In general :math:`\text{amp}(x)` is unpredictable due to dephasing from + two-level systems distributed randomly in frequency + or potentially due to qubit heating. This prevents us from precisely fitting + the raw :math:`P_X`, :math:`P_Y` data. Fitting only the phase data makes the + analysis robust to amplitude dependent dephasing. + + In this analysis, the phase polynomial is defined as + + .. math:: + + \theta(x) = 2 \pi t_S f_S(x) + + where + + .. math:: + + f_S(x) = c_1 x + c_2 x^2 + c_3 x^3 + f_{\rm err}, + + denotes the Stark shift. For the lowest order perturbative expansion of a single driven qubit, + the Stark shift is a quadratic function of :math:`x`, but linear and cubic terms + and a constant offset are also considered to account for + other effects, e.g. strong drive, collisions, TLS, and so forth, + and frequency mis-calibration, respectively. + + # section: fit_model + + .. math:: + + \theta^\nu(x) = c_1^\nu x + c_2^\nu x^2 + c_3^\nu x^3 + f_{\rm err}, + + where :math:`\nu \in \{+, -\}`. + The Stark shift is asymmetric with respect to :math:`x=0`, because of the + anti-crossings of higher energy levels. In a typical transmon qubit, + these levels appear only in :math:`f_S < 0` because of the negative anharmonicity. + To precisely fit the results, this analysis uses different model parameters + for positive (:math:`x > 0`) and negative (:math:`x < 0`) shift domains. + + # section: fit_parameters + + defpar c_1^+: + desc: The linear term coefficient of the positive Stark shift + (fit parameter: ``stark_pos_coef_o1``). + init_guess: 0. + bounds: None + + defpar c_2^+: + desc: The quadratic term coefficient of the positive Stark shift. + This parameter must be positive because Stark amplitude is chosen to + induce blue shift when its sign is positive. + Note that the quadratic term is the primary term + (fit parameter: ``stark_pos_coef_o2``). + init_guess: 1e6. + bounds: [0, inf] + + defpar c_3^+: + desc: The cubic term coefficient of the positive Stark shift + (fit parameter: ``stark_pos_coef_o3``). + init_guess: 0. + bounds: None + + defpar c_1^-: + desc: The linear term coefficient of the negative Stark shift. + (fit parameter: ``stark_neg_coef_o1``). + init_guess: 0. + bounds: None + + defpar c_2^-: + desc: The quadratic term coefficient of the negative Stark shift. + This parameter must be negative because Stark amplitude is chosen to + induce red shift when its sign is negative. + Note that the quadratic term is the primary term + (fit parameter: ``stark_neg_coef_o2``). + init_guess: -1e6. + bounds: [-inf, 0] + + defpar c_3^-: + desc: The cubic term coefficient of the negative Stark shift + (fit parameter: ``stark_neg_coef_o3``). + init_guess: 0. + bounds: None + + defpar f_{\rm err}: + desc: Constant phase accumulation which is independent of the Stark tone amplitude. + (fit parameter: ``stark_ferr``). + init_guess: 0 + bounds: None + + # section: see_also + + :class:`qiskit_experiments.library.characterization.analysis.ramsey_xy_analysis.RamseyXYAnalysis` + + """ + + def __init__(self): + + models = [ + lmfit.models.ExpressionModel( + expr="c1_pos * x + c2_pos * x**2 + c3_pos * x**3 + f_err", + name="FREQpos", + ), + lmfit.models.ExpressionModel( + expr="c1_neg * x + c2_neg * x**2 + c3_neg * x**3 + f_err", + name="FREQneg", + ), + ] + super().__init__(models=models) + + @classmethod + def _default_options(cls): + """Default analysis options. + + Analysis Options: + pulse_len (float): Duration of effective Stark pulse in units of sec. + """ + ramsey_plotter = vis.CurvePlotter(vis.MplDrawer()) + ramsey_plotter.set_figure_options( + xlabel="Stark tone amplitude", + ylabel=["Stark shift", "P1"], + yval_unit=["Hz", None], + series_params={ + "Fpos": { + "color": "#123FE8", + "symbol": "^", + "label": "", + "canvas": 0, + }, + "Fneg": { + "color": "#123FE8", + "symbol": "v", + "label": "", + "canvas": 0, + }, + "Xpos": { + "color": "#123FE8", + "symbol": "o", + "label": "Ramsey X", + "canvas": 1, + }, + "Ypos": { + "color": "#6312E8", + "symbol": "^", + "label": "Ramsey Y", + "canvas": 1, + }, + "Xneg": { + "color": "#E83812", + "symbol": "o", + "label": "Ramsey X", + "canvas": 1, + }, + "Yneg": { + "color": "#E89012", + "symbol": "^", + "label": "Ramsey Y", + "canvas": 1, + }, + }, + sharey=False, + ) + ramsey_plotter.set_options(subplots=(2, 1), style=vis.PlotStyle({"figsize": (10, 8)})) + + options = super()._default_options() + options.update_options( + data_subfit_map={ + "Xpos": {"series": "X", "direction": "pos"}, + "Ypos": {"series": "Y", "direction": "pos"}, + "Xneg": {"series": "X", "direction": "neg"}, + "Yneg": {"series": "Y", "direction": "neg"}, + }, + plotter=ramsey_plotter, + fit_category="freq", + pulse_len=None, + ) + + return options + + def _freq_phase_coef(self) -> float: + """Return a coefficient to convert frequency into phase value.""" + try: + return 2 * np.pi * self.options.pulse_len + except TypeError as ex: + raise TypeError( + "A float-value duration in units of sec of the Stark pulse must be provided. " + f"The pulse_len option value {self.options.pulse_len} is not valid." + ) from ex + + def _format_data( + self, + curve_data: curve.ScatterTable, + category: str = "freq", + ) -> curve.ScatterTable: + + curve_data = super()._format_data(curve_data, category="ramsey_xy") + ramsey_xy = curve_data[curve_data.category == "ramsey_xy"] + + # Create phase data by arctan(Y/X) + columns = list(curve_data.columns) + phase_data = np.empty((0, len(columns))) + y_mean = ramsey_xy.yval.mean() + + grouped = ramsey_xy.groupby("name") + for m_id, direction in enumerate(("pos", "neg")): + x_quadrature = grouped.get_group(f"X{direction}") + y_quadrature = grouped.get_group(f"Y{direction}") + if not np.array_equal(x_quadrature.xval, y_quadrature.xval): + raise ValueError( + "Amplitude values of X and Y quadrature are different. " + "Same values must be used." + ) + x_uarray = unp.uarray(x_quadrature.yval, x_quadrature.yerr) + y_uarray = unp.uarray(y_quadrature.yval, y_quadrature.yerr) + + amplitudes = x_quadrature.xval.to_numpy() + + # pylint: disable=no-member + phase = unp.arctan2(y_uarray - y_mean, x_uarray - y_mean) + phase_n = unp.nominal_values(phase) + phase_s = unp.std_devs(phase) + + # Unwrap phase + # We assume a smooth slope and correct 2pi phase jump to minimize the change of the slope. + unwrapped_phase = np.unwrap(phase_n) + if amplitudes[0] < 0: + # Preserve phase value closest to 0 amplitude + unwrapped_phase = unwrapped_phase + (phase_n[-1] - unwrapped_phase[-1]) + + # Store new data + tmp = np.empty((len(amplitudes), len(columns)), dtype=object) + tmp[:, columns.index("xval")] = amplitudes + tmp[:, columns.index("yval")] = unwrapped_phase / self._freq_phase_coef() + tmp[:, columns.index("yerr")] = phase_s / self._freq_phase_coef() + tmp[:, columns.index("name")] = f"FREQ{direction}" + tmp[:, columns.index("class_id")] = m_id + tmp[:, columns.index("shots")] = x_quadrature.shots + y_quadrature.shots + tmp[:, columns.index("category")] = category + phase_data = np.r_[phase_data, tmp] + + return curve_data.append_list_values(other=phase_data) + + def _generate_fit_guesses( + self, + user_opt: curve.FitOptions, + curve_data: curve.ScatterTable, + ) -> Union[curve.FitOptions, List[curve.FitOptions]]: + """Create algorithmic initial fit guess from analysis options and curve data. + + Args: + user_opt: Fit options filled with user provided guess and bounds. + curve_data: Formatted data collection to fit. + + Returns: + List of fit options that are passed to the fitter function. + """ + user_opt.bounds.set_if_empty(c2_pos=(0, np.inf), c2_neg=(-np.inf, 0)) + user_opt.p0.set_if_empty( + c1_pos=0, c2_pos=1e6, c3_pos=0, c1_neg=0, c2_neg=-1e6, c3_neg=0, f_err=0 + ) + return user_opt + + def _create_analysis_results( + self, + fit_data: curve.CurveFitResult, + quality: str, + **metadata, + ) -> List[AnalysisResultData]: + outcomes = super()._create_analysis_results(fit_data, quality, **metadata) + + # Combine fit coefficients + coeffs = StarkCoefficients( + pos_coef_o1=fit_data.ufloat_params["c1_pos"].nominal_value, + pos_coef_o2=fit_data.ufloat_params["c2_pos"].nominal_value, + pos_coef_o3=fit_data.ufloat_params["c3_pos"].nominal_value, + neg_coef_o1=fit_data.ufloat_params["c1_neg"].nominal_value, + neg_coef_o2=fit_data.ufloat_params["c2_neg"].nominal_value, + neg_coef_o3=fit_data.ufloat_params["c3_neg"].nominal_value, + offset=fit_data.ufloat_params["f_err"].nominal_value, + ) + outcomes.append( + AnalysisResultData( + name="stark_coefficients", + value=coeffs, + chisq=fit_data.reduced_chisq, + quality=quality, + extra=metadata, + ) + ) + return outcomes + + def _create_figures( + self, + curve_data: curve.ScatterTable, + ) -> List["matplotlib.figure.Figure"]: + + # plot unwrapped phase on first axis + for d in ("pos", "neg"): + sub_data = curve_data[(curve_data.name == f"FREQ{d}") & (curve_data.category == "freq")] + self.plotter.set_series_data( + series_name=f"F{d}", + x_formatted=sub_data.xval.to_numpy(), + y_formatted=sub_data.yval.to_numpy(), + y_formatted_err=sub_data.yerr.to_numpy(), + ) + + # plot raw RamseyXY plot on second axis + for name in ("Xpos", "Ypos", "Xneg", "Yneg"): + sub_data = curve_data[(curve_data.name == name) & (curve_data.category == "ramsey_xy")] + self.plotter.set_series_data( + series_name=name, + x_formatted=sub_data.xval.to_numpy(), + y_formatted=sub_data.yval.to_numpy(), + y_formatted_err=sub_data.yerr.to_numpy(), + ) + + # find base and amplitude guess + ramsey_xy = curve_data[curve_data.category == "ramsey_xy"] + offset_guess = 0.5 * (ramsey_xy.yval.min() + ramsey_xy.yval.max()) + amp_guess = 0.5 * np.ptp(ramsey_xy.yval) + + # plot frequency and Ramsey fit lines + line_data = curve_data[curve_data.category == "fitted"] + for direction in ("pos", "neg"): + sub_data = line_data[line_data.name == f"FREQ{direction}"] + if len(sub_data) == 0: + continue + xval = sub_data.xval.to_numpy() + yn = sub_data.yval.to_numpy() + ys = sub_data.yerr.to_numpy() + yval = unp.uarray(yn, ys) * self._freq_phase_coef() + + # Ramsey fit lines are predicted from the phase fit line. + # Note that this line doesn't need to match with the expeirment data + # because Ramsey P1 data may fluctuate due to phase damping. + + # pylint: disable=no-member + ramsey_cos = amp_guess * unp.cos(yval) + offset_guess + ramsey_sin = amp_guess * unp.sin(yval) + offset_guess + + self.plotter.set_series_data( + series_name=f"F{direction}", + x_interp=xval, + y_interp=yn, + ) + self.plotter.set_series_data( + series_name=f"X{direction}", + x_interp=xval, + y_interp=unp.nominal_values(ramsey_cos), + ) + self.plotter.set_series_data( + series_name=f"Y{direction}", + x_interp=xval, + y_interp=unp.nominal_values(ramsey_sin), + ) + + if np.isfinite(ys).all(): + self.plotter.set_series_data( + series_name=f"F{direction}", + y_interp_err=ys, + ) + self.plotter.set_series_data( + series_name=f"X{direction}", + y_interp_err=unp.std_devs(ramsey_cos), + ) + self.plotter.set_series_data( + series_name=f"Y{direction}", + y_interp_err=unp.std_devs(ramsey_sin), + ) + return [self.plotter.figure()] + + def _initialize( + self, + experiment_data: ExperimentData, + ): + super()._initialize(experiment_data) + + # Set scaling factor to convert phase to frequency + if "stark_length" in experiment_data.metadata: + self.set_options(pulse_len=experiment_data.metadata["stark_length"]) diff --git a/test/library/driven_freq_tuning/__init__.py b/test/library/driven_freq_tuning/__init__.py new file mode 100644 index 0000000000..4575d01965 --- /dev/null +++ b/test/library/driven_freq_tuning/__init__.py @@ -0,0 +1,12 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2023. +# +# 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. +"""Tests for driven frequency tuning.""" diff --git a/test/library/driven_freq_tuning/test_coeffs.py b/test/library/driven_freq_tuning/test_coeffs.py new file mode 100644 index 0000000000..ce1cd9ee85 --- /dev/null +++ b/test/library/driven_freq_tuning/test_coeffs.py @@ -0,0 +1,173 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2024. +# +# 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 for Stark coefficients utility.""" + +from test.base import QiskitExperimentsTestCase + +from ddt import ddt, named_data, data, unpack +import numpy as np + +from qiskit_experiments.library.driven_freq_tuning import coefficients as util +from qiskit_experiments.test import FakeService + + +@ddt +class TestStarkUtil(QiskitExperimentsTestCase): + """Test cases for Stark coefficient utilities.""" + + def test_coefficients(self): + """Test getting group of coefficients.""" + coeffs = util.StarkCoefficients( + pos_coef_o1=1e6, + pos_coef_o2=2e6, + pos_coef_o3=3e6, + neg_coef_o1=-1e6, + neg_coef_o2=-2e6, + neg_coef_o3=-3e6, + offset=0, + ) + self.assertListEqual(coeffs.positive_coeffs(), [3e6, 2e6, 1e6]) + self.assertListEqual(coeffs.negative_coeffs(), [-3e6, -2e6, -1e6]) + + def test_roundtrip_coefficients(self): + """Test serializing and deserializing the coefficient object.""" + coeffs = util.StarkCoefficients( + pos_coef_o1=1e6, + pos_coef_o2=2e6, + pos_coef_o3=3e6, + neg_coef_o1=-1e6, + neg_coef_o2=-2e6, + neg_coef_o3=-3e6, + offset=0, + ) + self.assertRoundTripSerializable(coeffs) + + @named_data( + ["ordinary", 5e6, 200e6, -50e6, 5e6, -180e6, -40e6, 100e3], + ["asymmetric_inflection_1st_ord", 10e6, 200e6, -20e6, -50e6, -180e6, -20e6, -10e6], + ["inflection_3st_ord", 10e6, 200e6, -80e6, 80e6, -180e6, -200e6, 100e3], + ) + @unpack + def test_roundtrip_convert_freq_amp( + self, + pos_o1: float, + pos_o2: float, + pos_o3: float, + neg_o1: float, + neg_o2: float, + neg_o3: float, + offset: float, + ): + """Test round-trip conversion between frequency shift and Stark amplitude.""" + coeffs = util.StarkCoefficients( + pos_coef_o1=pos_o1, + pos_coef_o2=pos_o2, + pos_coef_o3=pos_o3, + neg_coef_o1=neg_o1, + neg_coef_o2=neg_o2, + neg_coef_o3=neg_o3, + offset=offset, + ) + target_freqs = np.linspace(-70e6, 70e6, 11) + test_amps = coeffs.convert_freq_to_amp(target_freqs) + test_freqs = coeffs.convert_amp_to_freq(test_amps) + + np.testing.assert_array_almost_equal(test_freqs, target_freqs, decimal=2) + + @data( + [-0.5, 0.5], + [-0.9, 0.9], + [0.25, 1.0], + ) + @unpack + def test_calculate_min_max_shift(self, min_amp, max_amp): + """Test estimating maximum frequency shift within given Stark amplitude budget.""" + + # These coefficients induce inflection points around ±0.75, for testing + coeffs = util.StarkCoefficients( + pos_coef_o1=10e6, + pos_coef_o2=100e6, + pos_coef_o3=-90e6, + neg_coef_o1=80e6, + neg_coef_o2=-180e6, + neg_coef_o3=-200e6, + offset=100e3, + ) + # This numerical solution is correct up to amp resolution of 0.001 + nop = int((max_amp - min_amp) / 0.001) + amps = np.linspace(min_amp, max_amp, nop) + freqs = coeffs.convert_amp_to_freq(amps) + + # This finds strict solution, unless it has a bug + min_freq, max_freq = coeffs.find_min_max_frequency( + min_amp=min_amp, + max_amp=max_amp, + ) + + # Allow 1kHz tolerance because ref is approximate value + self.assertAlmostEqual(min_freq, np.min(freqs), delta=1e3) + self.assertAlmostEqual(max_freq, np.max(freqs), delta=1e3) + + def test_get_coeffs_from_service(self): + """Test retrieve the saved Stark coefficients from the experiment service.""" + mock_experiment_id = "6453f3d1-04ef-4e3b-82c6-1a92e3e066eb" + mock_result_id = "d067ae34-96db-4e8e-adc8-030305d3d404" + mock_backend = "mock_backend" + + ref_coeffs = util.StarkCoefficients( + pos_coef_o1=1e6, + pos_coef_o2=2e6, + pos_coef_o3=3e6, + neg_coef_o1=-1e6, + neg_coef_o2=-2e6, + neg_coef_o3=-3e6, + offset=0, + ) + + service = FakeService() + service.create_experiment( + experiment_type="StarkRamseyXYAmpScan", + backend_name=mock_backend, + experiment_id=mock_experiment_id, + ) + service.create_analysis_result( + experiment_id=mock_experiment_id, + result_data={"value": ref_coeffs}, + result_type="stark_coefficients", + device_components=["Q0"], + tags=[], + quality="Good", + verified=False, + result_id=mock_result_id, + ) + + retrieved = util.retrieve_coefficients_from_service( + service=service, + backend_name=mock_backend, + qubit=0, + ) + + self.assertEqual(retrieved, ref_coeffs) + + def test_get_coeffs_no_data(self): + """Test raises when Stark coefficients don't exist in the result database.""" + mock_backend = "mock_backend" + + service = FakeService() + + with self.assertRaises(RuntimeError): + util.retrieve_coefficients_from_service( + service=service, + backend_name=mock_backend, + qubit=0, + ) diff --git a/test/library/characterization/test_stark_p1_spect.py b/test/library/driven_freq_tuning/test_stark_p1_spect.py similarity index 55% rename from test/library/characterization/test_stark_p1_spect.py rename to test/library/driven_freq_tuning/test_stark_p1_spect.py index e3a4f9e2cc..00ddbd5c6f 100644 --- a/test/library/characterization/test_stark_p1_spect.py +++ b/test/library/driven_freq_tuning/test_stark_p1_spect.py @@ -14,6 +14,7 @@ from test.base import QiskitExperimentsTestCase +from ddt import ddt, named_data, unpack import numpy as np from qiskit import pulse from qiskit.circuit import QuantumCircuit, Gate @@ -22,7 +23,8 @@ from qiskit_experiments.framework import ExperimentData, AnalysisResultData from qiskit_experiments.library import StarkP1Spectroscopy -from qiskit_experiments.library.characterization.analysis import StarkP1SpectAnalysis +from qiskit_experiments.library.driven_freq_tuning.p1_spect_analysis import StarkP1SpectAnalysis +from qiskit_experiments.library.driven_freq_tuning.coefficients import StarkCoefficients from qiskit_experiments.test import FakeService @@ -43,48 +45,17 @@ def _run_spect_analysis( ] +@ddt class TestStarkP1Spectroscopy(QiskitExperimentsTestCase): """Test case for the Stark P1 Spectroscopy experiment.""" - def setUp(self): - super().setUp() - - self.service = FakeService() - - self.service.create_experiment( - experiment_type="StarkRamseyXYAmpScan", - backend_name="fake_hanoi", - experiment_id="123456789", - ) - - self.coeffs = { - "stark_pos_coef_o1": 5e6, - "stark_pos_coef_o2": 200e6, - "stark_pos_coef_o3": -50e6, - "stark_neg_coef_o1": 5e6, - "stark_neg_coef_o2": -180e6, - "stark_neg_coef_o3": -40e6, - "stark_ferr": 100e3, - } - for i, (key, value) in enumerate(self.coeffs.items()): - self.service.create_analysis_result( - experiment_id="123456789", - result_data={"value": value}, - result_type=key, - device_components=["Q0"], - tags=[], - quality="Good", - verified=False, - result_id=str(i), - ) - def test_linear_spaced_parameters(self): """Test generating parameters with linear spacing.""" exp = StarkP1Spectroscopy((0,)) exp.set_experiment_options( - min_stark_amp=-1, - max_stark_amp=1, - num_stark_amps=5, + min_xval=-1, + max_xval=1, + num_xvals=5, spacing="linear", ) params = exp.parameters() @@ -96,9 +67,9 @@ def test_quadratic_spaced_parameters(self): """Test generating parameters with quadratic spacing.""" exp = StarkP1Spectroscopy((0,)) exp.set_experiment_options( - min_stark_amp=-1, - max_stark_amp=1, - num_stark_amps=5, + min_xval=-1, + max_xval=1, + num_xvals=5, spacing="quadratic", ) params = exp.parameters() @@ -112,6 +83,68 @@ def test_invalid_spacing(self): with self.assertRaises(ValueError): exp.set_experiment_options(spacing="invalid_option") + def test_raises_scanning_frequency_without_service(self): + """Test raises error when frequency is set without no coefficients. + + This covers following situations: + - stark_coefficients options is None + - backend object doesn't provide experiment service + """ + exp = StarkP1Spectroscopy((0,), backend=FakeHanoiV2()) + exp.set_experiment_options( + xvals=[-100e6, -50e6, 0, 50e6, 100e6], + xval_type="frequency", + ) + with self.assertRaises(RuntimeError): + exp.parameters() + + def test_scanning_frequency_with_coeffs(self): + """Test scanning frequency with manually provided Stark coefficients.""" + coeffs = StarkCoefficients( + pos_coef_o1=5e6, + pos_coef_o2=200e6, + pos_coef_o3=-50e6, + neg_coef_o1=5e6, + neg_coef_o2=-180e6, + neg_coef_o3=-40e6, + offset=100e3, + ) + exp = StarkP1Spectroscopy((0,), backend=FakeHanoiV2()) + + ref_amps = np.array([-0.50, -0.25, 0.0, 0.25, 0.50], dtype=float) + test_freqs = coeffs.convert_amp_to_freq(ref_amps) + exp.set_experiment_options( + xvals=test_freqs, + xval_type="frequency", + stark_coefficients=coeffs, + ) + params = exp.parameters() + np.testing.assert_array_almost_equal(params, ref_amps) + + def test_scanning_frequency_around_zero(self): + """Test scanning frequency around zero.""" + coeffs = StarkCoefficients( + pos_coef_o1=5e6, + pos_coef_o2=100e6, + pos_coef_o3=10e6, + neg_coef_o1=-5e6, + neg_coef_o2=-100e6, + neg_coef_o3=-10e6, + offset=500e3, + ) + exp = StarkP1Spectroscopy((0,), backend=FakeHanoiV2()) + exp.set_experiment_options( + xvals=[0, 500e3], + xval_type="frequency", + stark_coefficients=coeffs, + ) + params = exp.parameters() + # Frequency offset is 500 kHz and we need negative shift to tune frequency at zero. + self.assertLess(params[0], 0) + + # Frequency offset is 500 kHz and we don't need tone. + self.assertAlmostEqual(params[1], 0) + def test_circuits(self): """Test generated circuits.""" backend = FakeHanoiV2() @@ -125,7 +158,7 @@ def test_circuits(self): exp = StarkP1Spectroscopy((0,), backend) exp.set_experiment_options( - stark_amps=[-0.5, 0.5], + xvals=[-0.5, 0.5], stark_freq_offset=10e6, t1_delay=100, stark_sigma=15, @@ -166,18 +199,6 @@ def test_circuits(self): self.assertEqual(circs[0], qc1) self.assertEqual(circs[1], qc2) - def test_retrieve_coefficients(self): - """Test retrieving Stark coefficients from the experiment service.""" - retrieved_coeffs = StarkP1SpectAnalysis.retrieve_coefficients_from_service( - service=self.service, - qubit=0, - backend="fake_hanoi", - ) - self.assertDictEqual( - retrieved_coeffs, - self.coeffs, - ) - def test_running_analysis_without_service(self): """Test running analysis without setting service to the experiment data. @@ -186,71 +207,98 @@ def test_running_analysis_without_service(self): analysis = StarkP1SpectAnalysisReturnXvals() xvals = np.linspace(-1, 1, 11) + ref_xvals = xvals exp_data = ExperimentData() for x in xvals: exp_data.add_data({"counts": {"0": 1000, "1": 0}, "metadata": {"xval": x}}) analysis.run(exp_data, replace_results=True) test_xvals = exp_data.analysis_results("xvals").value - ref_xvals = xvals np.testing.assert_array_almost_equal(test_xvals, ref_xvals) - def test_running_analysis_with_service(self): + @named_data( + ["ordinary", 5e6, 200e6, -50e6, 5e6, -180e6, -40e6, 100e3], + ["asymmetric_inflection_1st_ord", 10e6, 200e6, -20e6, -50e6, -180e6, -20e6, -10e6], + ["inflection_3st_ord", 10e6, 200e6, -80e6, 80e6, -180e6, -200e6, 100e3], + ) + @unpack + def test_running_analysis_with_service(self, po1, po2, po3, no1, no2, no3, ferr): """Test running analysis by setting service to the experiment data. This must convert x-axis into frequencies with the Stark coefficients. """ + mock_experiment_id = "6453f3d1-04ef-4e3b-82c6-1a92e3e066eb" + mock_result_id = "d067ae34-96db-4e8e-adc8-030305d3d404" + mock_backend = FakeHanoiV2().name + + coeffs = StarkCoefficients( + pos_coef_o1=po1, + pos_coef_o2=po2, + pos_coef_o3=po3, + neg_coef_o1=no1, + neg_coef_o2=no2, + neg_coef_o3=no3, + offset=ferr, + ) + + service = FakeService() + service.create_experiment( + experiment_type="StarkRamseyXYAmpScan", + backend_name=mock_backend, + experiment_id=mock_experiment_id, + ) + service.create_analysis_result( + experiment_id=mock_experiment_id, + result_data={"value": coeffs}, + result_type="stark_coefficients", + device_components=["Q0"], + tags=[], + quality="Good", + verified=False, + result_id=mock_result_id, + ) + analysis = StarkP1SpectAnalysisReturnXvals() xvals = np.linspace(-1, 1, 11) + ref_fvals = coeffs.convert_amp_to_freq(xvals) + exp_data = ExperimentData( - service=self.service, + service=service, backend=FakeHanoiV2(), ) exp_data.metadata.update({"physical_qubits": [0]}) for x in xvals: exp_data.add_data({"counts": {"0": 1000, "1": 0}, "metadata": {"xval": x}}) - analysis.run(exp_data, replace_results=True) - test_xvals = exp_data.analysis_results("xvals").value - ref_xvals = np.where( - xvals > 0, - ( - self.coeffs["stark_pos_coef_o1"] * xvals - + self.coeffs["stark_pos_coef_o2"] * xvals**2 - + self.coeffs["stark_pos_coef_o3"] * xvals**3 - + self.coeffs["stark_ferr"] - ), - ( - self.coeffs["stark_neg_coef_o1"] * xvals - + self.coeffs["stark_neg_coef_o2"] * xvals**2 - + self.coeffs["stark_neg_coef_o3"] * xvals**3 - + self.coeffs["stark_ferr"] - ), - ) - np.testing.assert_array_almost_equal(test_xvals, ref_xvals) + analysis.run(exp_data, replace_results=True).block_for_results() + test_fvals = exp_data.analysis_results("xvals").value + np.testing.assert_array_almost_equal(test_fvals, ref_fvals) def test_running_analysis_with_user_provided_coeffs(self): """Test running analysis by manually providing Stark coefficients. This must convert x-axis into frequencies with the provided coefficients. + This is just a difference of API from the test_running_analysis_with_service. + Data driven test is omitted here. """ - analysis = StarkP1SpectAnalysisReturnXvals() - analysis.set_options( - stark_coefficients={ - "stark_pos_coef_o1": 0.0, - "stark_pos_coef_o2": 200e6, - "stark_pos_coef_o3": 0.0, - "stark_neg_coef_o1": 0.0, - "stark_neg_coef_o2": -200e6, - "stark_neg_coef_o3": 0.0, - "stark_ferr": 0.0, - } + coeffs = StarkCoefficients( + pos_coef_o1=5e6, + pos_coef_o2=200e6, + pos_coef_o3=-50e6, + neg_coef_o1=5e6, + neg_coef_o2=-180e6, + neg_coef_o3=-40e6, + offset=100e3, ) + analysis = StarkP1SpectAnalysisReturnXvals() + analysis.set_options(stark_coefficients=coeffs) + xvals = np.linspace(-1, 1, 11) + ref_fvals = coeffs.convert_amp_to_freq(xvals) + exp_data = ExperimentData() for x in xvals: exp_data.add_data({"counts": {"0": 1000, "1": 0}, "metadata": {"xval": x}}) - analysis.run(exp_data, replace_results=True) - test_xvals = exp_data.analysis_results("xvals").value - ref_xvals = np.where(xvals > 0, 200e6 * xvals**2, -200e6 * xvals**2) - np.testing.assert_array_almost_equal(test_xvals, ref_xvals) + analysis.run(exp_data, replace_results=True).block_for_results() + test_fvals = exp_data.analysis_results("xvals").value + np.testing.assert_array_almost_equal(test_fvals, ref_fvals) diff --git a/test/library/characterization/test_stark_ramsey_xy.py b/test/library/driven_freq_tuning/test_stark_ramsey_xy.py similarity index 84% rename from test/library/characterization/test_stark_ramsey_xy.py rename to test/library/driven_freq_tuning/test_stark_ramsey_xy.py index 795fde5297..dd0fd59e4a 100644 --- a/test/library/characterization/test_stark_ramsey_xy.py +++ b/test/library/driven_freq_tuning/test_stark_ramsey_xy.py @@ -21,7 +21,10 @@ from qiskit.providers.fake_provider import FakeHanoiV2 from qiskit_experiments.library import StarkRamseyXY, StarkRamseyXYAmpScan -from qiskit_experiments.library.characterization.analysis import StarkRamseyXYAmpScanAnalysis +from qiskit_experiments.library.driven_freq_tuning.ramsey_amp_scan_analysis import ( + StarkRamseyXYAmpScanAnalysis, +) +from qiskit_experiments.library.driven_freq_tuning.coefficients import StarkCoefficients from qiskit_experiments.framework import ExperimentData @@ -242,24 +245,33 @@ def test_ramsey_fast_analysis(self, c1p, c2p, c3p, c1n, c2n, c3n, ferr): exp_data = ExperimentData() exp_data.metadata.update({"stark_length": 50e-9}) + ref_coeffs = StarkCoefficients( + pos_coef_o1=c1p, + pos_coef_o2=c2p, + pos_coef_o3=c3p, + neg_coef_o1=c1n, + neg_coef_o2=c2n, + neg_coef_o3=c3n, + offset=ferr, + ) + yvals = ref_coeffs.convert_amp_to_freq(xvals) + # Generate fake data based on fit model. - for x in xvals: + for x, y in zip(xvals, yvals): if x >= 0.0: - fs = c1p * x + c2p * x**2 + c3p * x**3 + ferr direction = "pos" else: - fs = c1n * x + c2n * x**2 + c3n * x**3 + ferr direction = "neg" # Add some sampling error - ramx_count = rng.binomial(shots, amp * np.cos(const * fs) + off) + ramx_count = rng.binomial(shots, amp * np.cos(const * y) + off) exp_data.add_data( { "counts": {"0": shots - ramx_count, "1": ramx_count}, "metadata": {"xval": x, "series": "X", "direction": direction}, } ) - ramy_count = rng.binomial(shots, amp * np.sin(const * fs) + off) + ramy_count = rng.binomial(shots, amp * np.sin(const * y) + off) exp_data.add_data( { "counts": {"0": shots - ramy_count, "1": ramy_count}, @@ -271,38 +283,18 @@ def test_ramsey_fast_analysis(self, c1p, c2p, c3p, c1n, c2n, c3n, ferr): analysis.run(exp_data, replace_results=True) self.assertExperimentDone(exp_data) - # Check the fitted parameter can approximate the same polynominal - x_pos = np.linspace(0, 1, 51) - x_neg = np.linspace(-1, 0, 51) - ref_yvals_pos = c1p * x_pos + c2p * x_pos**2 + c3p * x_pos**3 + ferr - ref_yvals_neg = c1n * x_neg + c2n * x_neg**2 + c3n * x_neg**3 + ferr - - # Note that these parameter values are not necessary the same with input values - # as long as they can approximate the original phase polynominal. - c1p_est = exp_data.analysis_results("stark_pos_coef_o1").value.n - c2p_est = exp_data.analysis_results("stark_pos_coef_o2").value.n - c3p_est = exp_data.analysis_results("stark_pos_coef_o3").value.n - c1n_est = exp_data.analysis_results("stark_neg_coef_o1").value.n - c2n_est = exp_data.analysis_results("stark_neg_coef_o2").value.n - c3n_est = exp_data.analysis_results("stark_neg_coef_o3").value.n - ferr_est = exp_data.analysis_results("stark_ferr").value.n - - test_yvals_pos = c1p_est * x_pos + c2p_est * x_pos**2 + c3p_est * x_pos**3 + ferr_est - test_yvals_neg = c1n_est * x_neg + c2n_est * x_neg**2 + c3n_est * x_neg**3 + ferr_est - - # Check similality of reconstructed polynominals - # Curves must be agree within the torelance of 1.5 * 1 MHz. - np.testing.assert_array_almost_equal( - test_yvals_pos, - ref_yvals_pos, - decimal=-6, - err_msg="Reconstructed phase polynominal on positive frequency shift side " - "doesn't match with the original curve.", - ) + # Check the fitted parameter can approximate the same polynominal. + # Note that coefficient values don't need to exactly match as long as + # frequency shift is predictable. + # Since the fit model is just an empirical polynomial, + # comparing coefficients don't physically sound. + # Curves must be agreed within the tolerance of 1.5 * 1 MHz. + fit_coeffs = exp_data.analysis_results("stark_coefficients").value + fit_yvals = fit_coeffs.convert_amp_to_freq(xvals) + np.testing.assert_array_almost_equal( - test_yvals_neg, - ref_yvals_neg, + yvals, + fit_yvals, decimal=-6, - err_msg="Reconstructed phase polynominal on negative frequency shift side " - "doesn't match with the original curve.", + err_msg="Reconstructed phase polynominal doesn't match with the actual phase shift.", ) From 32f02b1e932f41de4938981239b994ebdd51f0c5 Mon Sep 17 00:00:00 2001 From: Will Shanks Date: Wed, 31 Jan 2024 02:53:43 -0500 Subject: [PATCH 15/15] Convert QVAnalysis to use BasePlotter (#1348) Add an hline method to BaseDrawer and expose linewidth and linestyle as series options. Catch expected warnings about insufficient trials in analysis tests. Remove filters preventing test failures when using the deprecated visualization APIs. --------- Co-authored-by: Conrad Haupt --- .../library/quantum_volume/__init__.py | 12 +- .../library/quantum_volume/qv_analysis.py | 194 +++++++++++------- .../visualization/drawers/base_drawer.py | 24 +++ .../drawers/legacy_curve_compat_drawer.py | 31 +++ .../visualization/drawers/mpl_drawer.py | 25 ++- .../notes/qvplotter-04efe280aaa9d555.yaml | 17 ++ test/base.py | 6 +- test/visualization/mock_drawer.py | 11 + 8 files changed, 238 insertions(+), 82 deletions(-) create mode 100644 releasenotes/notes/qvplotter-04efe280aaa9d555.yaml diff --git a/qiskit_experiments/library/quantum_volume/__init__.py b/qiskit_experiments/library/quantum_volume/__init__.py index 35173c0477..c14cb8d741 100644 --- a/qiskit_experiments/library/quantum_volume/__init__.py +++ b/qiskit_experiments/library/quantum_volume/__init__.py @@ -35,7 +35,17 @@ :template: autosummary/analysis.rst QuantumVolumeAnalysis + + +Plotter +======= + +.. autosummary:: + :toctree: ../stubs/ + :template: autosummary/plotter.rst + + QuantumVolumePlotter """ from .qv_experiment import QuantumVolume -from .qv_analysis import QuantumVolumeAnalysis +from .qv_analysis import QuantumVolumeAnalysis, QuantumVolumePlotter diff --git a/qiskit_experiments/library/quantum_volume/qv_analysis.py b/qiskit_experiments/library/quantum_volume/qv_analysis.py index 730b4bb020..92bc1a207a 100644 --- a/qiskit_experiments/library/quantum_volume/qv_analysis.py +++ b/qiskit_experiments/library/quantum_volume/qv_analysis.py @@ -15,17 +15,130 @@ import math import warnings -from typing import Optional +from typing import List import numpy as np import uncertainties from qiskit_experiments.exceptions import AnalysisError -from qiskit_experiments.curve_analysis.visualization import plot_scatter, plot_errorbar from qiskit_experiments.framework import ( BaseAnalysis, AnalysisResultData, Options, ) +from qiskit_experiments.visualization import BasePlotter, MplDrawer + + +class QuantumVolumePlotter(BasePlotter): + """Plotter for QuantumVolumeAnalysis + + .. note:: + + This plotter only supports one series, named ``hops``, which it expects + to have an ``individual`` data key containing the individual heavy + output probabilities for each circuit in the experiment. Additional + series will be ignored. + """ + + @classmethod + def expected_series_data_keys(cls) -> List[str]: + """Returns the expected series data keys supported by this plotter. + + Data Keys: + individual: Heavy-output probability fraction for each individual circuit + """ + return ["individual"] + + @classmethod + def expected_supplementary_data_keys(cls) -> List[str]: + """Returns the expected figures data keys supported by this plotter. + + Data Keys: + depth: The depth of the quantun volume circuits used in the experiment + """ + return ["depth"] + + def set_supplementary_data(self, **data_kwargs): + """Sets supplementary data for the plotter. + + Args: + data_kwargs: See :meth:`expected_supplementary_data_keys` for the + expected supplementary data keys. + """ + # Hook method to capture the depth for inclusion in the plot title + if "depth" in data_kwargs: + self.set_figure_options( + figure_title=( + f"Quantum Volume experiment for depth {data_kwargs['depth']}" + " - accumulative hop" + ), + ) + super().set_supplementary_data(**data_kwargs) + + @classmethod + def _default_figure_options(cls) -> Options: + options = super()._default_figure_options() + options.xlabel = "Number of Trials" + options.ylabel = "Heavy Output Probability" + options.figure_title = "Quantum Volume experiment - accumulative hop" + options.series_params = { + "hop": {"color": "gray", "symbol": "."}, + "threshold": {"color": "black", "linestyle": "dashed", "linewidth": 1}, + "hop_cumulative": {"color": "r"}, + "hop_twosigma": {"color": "lightgray"}, + } + return options + + @classmethod + def _default_options(cls) -> Options: + options = super()._default_options() + options.style["figsize"] = (6.4, 4.8) + options.style["axis_label_size"] = 14 + options.style["symbol_size"] = 2 + return options + + def _plot_figure(self): + (hops,) = self.data_for("hops", ["individual"]) + trials = np.arange(1, 1 + len(hops)) + hop_accumulative = np.cumsum(hops) / trials + hop_twosigma = 2 * (hop_accumulative * (1 - hop_accumulative) / trials) ** 0.5 + + self.drawer.line( + trials, + hop_accumulative, + name="hop_cumulative", + label="Cumulative HOP", + legend=True, + ) + self.drawer.hline( + 2 / 3, + name="threshold", + label="Threshold", + legend=True, + ) + self.drawer.scatter( + trials, + hops, + name="hop", + label="Individual HOP", + legend=True, + linewidth=1.5, + ) + self.drawer.filled_y_area( + trials, + hop_accumulative - hop_twosigma, + hop_accumulative + hop_twosigma, + alpha=0.5, + legend=True, + name="hop_twosigma", + label="2σ", + ) + + self.drawer.set_figure_options( + ylim=( + max(hop_accumulative[-1] - 4 * hop_twosigma[-1], 0), + min(hop_accumulative[-1] + 4 * hop_twosigma[-1], 1), + ), + ) class QuantumVolumeAnalysis(BaseAnalysis): @@ -49,10 +162,12 @@ def _default_options(cls) -> Options: Analysis Options: plot (bool): Set ``True`` to create figure for fit result. ax (AxesSubplot): Optional. A matplotlib axis object to draw. + plotter (BasePlotter): Plotter object to use for figure generation. """ options = super()._default_options() options.plot = True options.ax = None + options.plotter = QuantumVolumePlotter(MplDrawer()) return options def _run_analysis(self, experiment_data): @@ -77,8 +192,9 @@ def _run_analysis(self, experiment_data): hop_result, qv_result = self._calc_quantum_volume(heavy_output_prob_exp, depth, num_trials) if self.options.plot: - ax = self._format_plot(hop_result, ax=self.options.ax) - figures = [ax.get_figure()] + self.options.plotter.set_series_data("hops", individual=hop_result.extra["HOPs"]) + self.options.plotter.set_supplementary_data(depth=hop_result.extra["depth"]) + figures = [self.options.plotter.figure()] else: figures = None return [hop_result, qv_result], figures @@ -238,73 +354,3 @@ def _calc_quantum_volume(self, heavy_output_prob_exp, depth, trials): }, ) return hop_result, qv_result - - @staticmethod - def _format_plot( - hop_result: AnalysisResultData, ax: Optional["matplotlib.pyplot.AxesSubplot"] = None - ): - """Format the QV plot - - Args: - hop_result: the heavy output probability analysis result. - ax: matplotlib axis to add plot to. - - Returns: - AxesSubPlot: the matplotlib axes containing the plot. - """ - trials = hop_result.extra["trials"] - heavy_probs = hop_result.extra["HOPs"] - trial_list = np.arange(1, trials + 1) # x data - - hop_accumulative = np.cumsum(heavy_probs) / trial_list - two_sigma = 2 * (hop_accumulative * (1 - hop_accumulative) / trial_list) ** 0.5 - - # Plot individual HOP as scatter - ax = plot_scatter( - trial_list, - heavy_probs, - ax=ax, - s=3, - zorder=3, - label="Individual HOP", - ) - # Plot accumulative HOP - ax.plot(trial_list, hop_accumulative, color="r", label="Cumulative HOP") - - # Plot two-sigma shaded area - ax = plot_errorbar( - trial_list, - hop_accumulative, - two_sigma, - ax=ax, - fmt="none", - ecolor="lightgray", - elinewidth=20, - capsize=0, - alpha=0.5, - label="2$\\sigma$", - ) - # Plot 2/3 success threshold - ax.axhline(2 / 3, color="k", linestyle="dashed", linewidth=1, label="Threshold") - - ax.set_ylim( - max(hop_accumulative[-1] - 4 * two_sigma[-1], 0), - min(hop_accumulative[-1] + 4 * two_sigma[-1], 1), - ) - - ax.set_xlabel("Number of Trials", fontsize=14) - ax.set_ylabel("Heavy Output Probability", fontsize=14) - - ax.set_title( - "Quantum Volume experiment for depth " - + str(hop_result.extra["depth"]) - + " - accumulative hop", - fontsize=14, - ) - - # Re-arrange legend order - handles, labels = ax.get_legend_handles_labels() - handles = [handles[1], handles[2], handles[0], handles[3]] - labels = [labels[1], labels[2], labels[0], labels[3]] - ax.legend(handles, labels) - return ax diff --git a/qiskit_experiments/visualization/drawers/base_drawer.py b/qiskit_experiments/visualization/drawers/base_drawer.py index ea63e0afd2..d9fb1af075 100644 --- a/qiskit_experiments/visualization/drawers/base_drawer.py +++ b/qiskit_experiments/visualization/drawers/base_drawer.py @@ -403,6 +403,30 @@ def line( options: Valid options for the drawer backend API. """ + @abstractmethod + def hline( + self, + y_value: float, + name: Optional[SeriesName] = None, + label: Optional[str] = None, + legend: bool = False, + **options, + ): + """Draw a horizontal line. + + Args: + y_value: Y value for line. + name: Name of this series. + label: Optional legend label to override ``name`` and ``series_params``. + legend: Whether the drawn area must have a legend entry. Defaults to False. + The series label in the legend will be ``label`` if it is not None. If + it is, then ``series_params`` is searched for a ``"label"`` entry for + the series identified by ``name``. If this is also ``None``, then + ``name`` is used as the fallback. If no ``name`` is provided, then no + legend entry is generated. + options: Valid options for the drawer backend API. + """ + @abstractmethod def filled_y_area( self, diff --git a/qiskit_experiments/visualization/drawers/legacy_curve_compat_drawer.py b/qiskit_experiments/visualization/drawers/legacy_curve_compat_drawer.py index 2a175744a7..2d6698e60f 100644 --- a/qiskit_experiments/visualization/drawers/legacy_curve_compat_drawer.py +++ b/qiskit_experiments/visualization/drawers/legacy_curve_compat_drawer.py @@ -120,6 +120,37 @@ def line( """ self._curve_drawer.draw_fit_line(x_data, y_data, name, **options) + # pylint: disable=unused-argument + def hline( + self, + y_value: float, + name: Optional[str] = None, + label: Optional[str] = None, + legend: bool = False, + **options, + ): + """Draw a horizontal line. + + .. note:: + + This method was added to fulfill the + :class:`~qiskit_experiments.visualization.BaseDrawer` interface, + but it is not supported for this class since there was no + equivalent in + :class:`~qiskit_experiments.curve_analysis.visualization.BaseCurveDrawer`. + + Args: + y_value: Y value for line. + name: Name of this series. + label: Unsupported label option + legend: Unsupported legend option + options: Additional options + """ + warnings.warn( + "hline is not supported by the LegacyCurveCompatDrawer", + UserWarning, + ) + # pylint: disable=unused-argument def filled_y_area( self, diff --git a/qiskit_experiments/visualization/drawers/mpl_drawer.py b/qiskit_experiments/visualization/drawers/mpl_drawer.py index d2e6956c15..6ab12bfaaa 100644 --- a/qiskit_experiments/visualization/drawers/mpl_drawer.py +++ b/qiskit_experiments/visualization/drawers/mpl_drawer.py @@ -436,13 +436,34 @@ def line( draw_ops = { "color": color, - "linestyle": "-", - "linewidth": 2, + "linestyle": series_params.get("linestyle", "-"), + "linewidth": series_params.get("linewidth", 2), } self._update_label_in_options(draw_ops, name, label, legend) draw_ops.update(**options) self._get_axis(axis).plot(x_data, y_data, **draw_ops) + def hline( + self, + y_value: float, + name: Optional[SeriesName] = None, + label: Optional[str] = None, + legend: bool = False, + **options, + ): + series_params = self.figure_options.series_params.get(name, {}) + axis = series_params.get("canvas", None) + color = series_params.get("color", self._get_default_color(name)) + + draw_ops = { + "color": color, + "linestyle": series_params.get("linestyle", "-"), + "linewidth": series_params.get("linewidth", 2), + } + self._update_label_in_options(draw_ops, name, label, legend) + draw_ops.update(**options) + self._get_axis(axis).axhline(y_value, **draw_ops) + def filled_y_area( self, x_data: Sequence[float], diff --git a/releasenotes/notes/qvplotter-04efe280aaa9d555.yaml b/releasenotes/notes/qvplotter-04efe280aaa9d555.yaml new file mode 100644 index 0000000000..e0f6acbd3f --- /dev/null +++ b/releasenotes/notes/qvplotter-04efe280aaa9d555.yaml @@ -0,0 +1,17 @@ +--- +features: + - | + An :meth:`~qiskit_experiments.visualization.BasePlotter.hline` method was + added to :class:`~qiskit_experiments.visualization.BasePlotter` for + generating horizontal lines. See `#1348 + `__. + - | + The + :class:`~qiskit_experiments.library.quantum_volume.QuantumVolumeAnalysis` + analysis class was updated to use + :class:`~qiskit_experiments.library.quantum_volume.QuantumVolumePlotter` + for its figure generation. The appearance of the figure should be the same + as in previous + releases, but now it is easier to customize the figure by setting options + on the plotter object. See `#1348 + `__. diff --git a/test/base.py b/test/base.py index fc73f5665e..eda6c5d177 100644 --- a/test/base.py +++ b/test/base.py @@ -125,11 +125,7 @@ def setUpClass(cls): # ``QiskitTestCase`` sets all warnings to be treated as an error by # default. # pylint: disable=invalid-name - allow_deprecationwarning_message = [ - # TODO: Remove in 0.6, when submodule `.curve_analysis.visualization` is removed. - r".*Plotting and drawing functionality has been moved", - r".*Legacy drawers from `.curve_analysis.visualization are deprecated", - ] + allow_deprecationwarning_message = [] for msg in allow_deprecationwarning_message: warnings.filterwarnings("default", category=DeprecationWarning, message=msg) diff --git a/test/visualization/mock_drawer.py b/test/visualization/mock_drawer.py index ee451370e6..f6c23c055c 100644 --- a/test/visualization/mock_drawer.py +++ b/test/visualization/mock_drawer.py @@ -75,6 +75,17 @@ def line( """Does nothing.""" pass + def hline( + self, + y_value: float, + name: Optional[str] = None, + label: Optional[str] = None, + legend: bool = False, + **options, + ): + """Does nothing.""" + pass + def filled_y_area( self, x_data: Sequence[float],