From 2785023f4bf1f1ed722d00ef23df5ba49645361f Mon Sep 17 00:00:00 2001 From: Jessie Yu Date: Wed, 20 Sep 2023 18:44:59 -0400 Subject: [PATCH 01/35] Add experimental options (#1067) * add options * fix mypy * rename meas err mit * Add additional resilience options * Add additional execution options for twirling * A twirling strategy option and validation * handle default resilience options * add _experimental * Update default resilience options (#1062) * remove default resilience options * add reno * add logic to override default * add test * purge None values from options (cherry picked from commit 76603f2224f859670be2a4f7eaa92c7431307b28) * add finalize options * add tests * Update qiskit_ibm_runtime/options/resilience_options.py * add validation * lint * lint again * lint again * Allow None values for specific options to be passed through * Fix parameter validation, allow computational basis * black * Fix ZneExtrapolatorType validation * lint * lint again * fix mypy * Fix ZNE extrapolator default option * fix level options * black * use _isreal * Disable gate twirling for default lvl 1 opts * Support for legacy options --------- Co-authored-by: Christopher J. Wood Co-authored-by: Kevin Tian Co-authored-by: mberna Co-authored-by: Mariana C Bernagozzi --- qiskit_ibm_runtime/__init__.py | 3 + qiskit_ibm_runtime/base_primitive.py | 46 ++- qiskit_ibm_runtime/estimator.py | 116 +++++++- qiskit_ibm_runtime/options/__init__.py | 2 + .../options/execution_options.py | 60 +++- qiskit_ibm_runtime/options/options.py | 262 +++++++++++++++--- .../options/resilience_options.py | 193 +++++++++++-- .../options/twirling_options.py | 80 ++++++ qiskit_ibm_runtime/options/utils.py | 11 + qiskit_ibm_runtime/sampler.py | 10 +- ...t-resilience-options-7929458af000314f.yaml | 10 + test/integration/test_options.py | 22 ++ test/unit/test_estimator.py | 150 +++++++++- test/unit/test_ibm_primitives.py | 47 ++-- test/unit/test_options.py | 76 ++++- test/utils.py | 4 + 16 files changed, 958 insertions(+), 134 deletions(-) create mode 100644 qiskit_ibm_runtime/options/twirling_options.py create mode 100644 releasenotes/notes/default-resilience-options-7929458af000314f.yaml diff --git a/qiskit_ibm_runtime/__init__.py b/qiskit_ibm_runtime/__init__.py index fc62dc57d..98a187fe6 100644 --- a/qiskit_ibm_runtime/__init__.py +++ b/qiskit_ibm_runtime/__init__.py @@ -175,6 +175,7 @@ def result_callback(job_id, result): """ import logging +import warnings from .qiskit_runtime_service import QiskitRuntimeService from .ibm_backend import IBMBackend @@ -203,3 +204,5 @@ def result_callback(job_id, result): """The environment variable name that is used to set the level for the IBM Quantum logger.""" QISKIT_IBM_RUNTIME_LOG_FILE = "QISKIT_IBM_RUNTIME_LOG_FILE" """The environment variable name that is used to set the file for the IBM Quantum logger.""" + +warnings.warn("You are using the experimental branch. Stability is not guaranteed.") diff --git a/qiskit_ibm_runtime/base_primitive.py b/qiskit_ibm_runtime/base_primitive.py index ba88cf482..50f08c023 100644 --- a/qiskit_ibm_runtime/base_primitive.py +++ b/qiskit_ibm_runtime/base_primitive.py @@ -22,7 +22,6 @@ from qiskit.providers.options import Options as TerraOptions from .options import Options -from .options.utils import set_default_error_levels from .runtime_job import RuntimeJob from .ibm_backend import IBMBackend from .session import get_cm_session @@ -75,15 +74,6 @@ def __init__( self._service: QiskitRuntimeService = None self._backend: Optional[IBMBackend] = None - if options is None: - self._options = asdict(Options()) - elif isinstance(options, Options): - self._options = asdict(copy.deepcopy(options)) - else: - options_copy = copy.deepcopy(options) - default_options = asdict(Options()) - self._options = Options._merge_options(default_options, options_copy) - if isinstance(session, Session): self._session = session self._service = self._session.service @@ -148,6 +138,21 @@ def __init__( raise ValueError( "A backend or session must be specified when not using ibm_cloud channel." ) + self._simulator_backend = ( + self._backend.configuration().simulator if self._backend else False + ) + + if options is None: + self._options = asdict(Options()) + elif isinstance(options, Options): + self._options = asdict(copy.deepcopy(options)) + else: + options_copy = copy.deepcopy(options) + default_options = asdict(Options()) + self._options = Options._merge_options_with_defaults( + default_options, options_copy, is_simulator=self._simulator_backend + ) + # self._first_run = True # self._circuits_map = {} # if self.circuits: @@ -169,20 +174,11 @@ def _run_primitive(self, primitive_inputs: Dict, user_kwargs: Dict) -> RuntimeJo Returns: Submitted job. """ - combined = Options._merge_options(self._options, user_kwargs) - - if self._backend: - combined = set_default_error_levels( - combined, - self._backend, - Options._DEFAULT_OPTIMIZATION_LEVEL, - Options._DEFAULT_RESILIENCE_LEVEL, - ) - else: - combined["optimization_level"] = Options._DEFAULT_OPTIMIZATION_LEVEL - combined["resilience_level"] = Options._DEFAULT_RESILIENCE_LEVEL - + combined = Options._merge_options_with_defaults( + self._options, user_kwargs, self._simulator_backend + ) self._validate_options(combined) + primitive_inputs.update(Options._get_program_inputs(combined)) if self._backend and combined["transpilation"]["skip_transpilation"]: @@ -238,7 +234,9 @@ def set_options(self, **fields: Any) -> None: Args: **fields: The fields to update the options """ - self._options = Options._merge_options(self._options, fields) + self._options = Options._merge_options_with_defaults( + self._options, fields, self._simulator_backend + ) @abstractmethod def _validate_options(self, options: dict) -> None: diff --git a/qiskit_ibm_runtime/estimator.py b/qiskit_ibm_runtime/estimator.py index 13a30e262..a579c6d0e 100644 --- a/qiskit_ibm_runtime/estimator.py +++ b/qiskit_ibm_runtime/estimator.py @@ -14,13 +14,19 @@ from __future__ import annotations import os -from typing import Optional, Dict, Sequence, Any, Union +from typing import Optional, Dict, Sequence, Any, Union, Mapping import logging +import numpy as np +from numpy.typing import ArrayLike + from qiskit.circuit import QuantumCircuit -from qiskit.opflow import PauliSumOp from qiskit.quantum_info.operators.base_operator import BaseOperator from qiskit.primitives import BaseEstimator +from qiskit.quantum_info import SparsePauliOp, Pauli +from qiskit.primitives.utils import init_observable +from qiskit.circuit import Parameter +from qiskit.primitives.base.base_primitive import _isreal # TODO import _circuit_key from terra once 0.23 is released from .runtime_job import RuntimeJob @@ -28,6 +34,7 @@ from .options import Options from .base_primitive import BasePrimitive from .utils.qctrl import validate as qctrl_validate +from .utils.deprecation import issue_deprecation_msg # pylint: disable=unused-import,cyclic-import from .session import Session @@ -35,6 +42,23 @@ logger = logging.getLogger(__name__) +BasisObservableLike = Union[str, Pauli, SparsePauliOp, Mapping[Union[str, Pauli], complex]] +"""Types that can be natively used to construct a :const:`BasisObservable`.""" + +ObservablesArrayLike = Union[ArrayLike, Sequence[BasisObservableLike], BasisObservableLike] + +ParameterMappingLike = Mapping[ + Parameter, Union[float, np.ndarray, Sequence[float], Sequence[Sequence[float]]] +] +BindingsArrayLike = Union[ + float, + np.ndarray, + ParameterMappingLike, + Sequence[Union[float, Sequence[float], np.ndarray, ParameterMappingLike]], +] +"""Parameter types that can be bound to a single circuit.""" + + class Estimator(BasePrimitive, BaseEstimator): """Class for interacting with Qiskit Runtime Estimator primitive service. @@ -85,6 +109,7 @@ class Estimator(BasePrimitive, BaseEstimator): """ _PROGRAM_ID = "estimator" + _ALLOWED_BASIS: str = "IXYZ01+-rl" def __init__( self, @@ -119,8 +144,11 @@ def __init__( def run( # pylint: disable=arguments-differ self, circuits: QuantumCircuit | Sequence[QuantumCircuit], - observables: BaseOperator | PauliSumOp | Sequence[BaseOperator | PauliSumOp], - parameter_values: Sequence[float] | Sequence[Sequence[float]] | None = None, + observables: Sequence[ObservablesArrayLike] + | ObservablesArrayLike + | Sequence[BaseOperator] + | BaseOperator, + parameter_values: BindingsArrayLike | Sequence[BindingsArrayLike] | None = None, **kwargs: Any, ) -> RuntimeJob: """Submit a request to the estimator primitive. @@ -155,7 +183,7 @@ def run( # pylint: disable=arguments-differ def _run( # pylint: disable=arguments-differ self, circuits: Sequence[QuantumCircuit], - observables: Sequence[BaseOperator | PauliSumOp], + observables: Sequence[ObservablesArrayLike], parameter_values: Sequence[Sequence[float]], **kwargs: Any, ) -> RuntimeJob: @@ -220,6 +248,84 @@ def _validate_options(self, options: dict) -> None: ) Options.validate_options(options) + @staticmethod + def _validate_observables( + observables: Sequence[ObservablesArrayLike] | ObservablesArrayLike, + ) -> Sequence[ObservablesArrayLike]: + def _check_and_init(obs: Any) -> Any: + if isinstance(obs, str): + pass + if not all(basis in Estimator._ALLOWED_BASIS for basis in obs): + raise ValueError( + f"Invalid character(s) found in observable string. " + f"Allowed basis are {Estimator._ALLOWED_BASIS}." + ) + elif isinstance(obs, Sequence): + return tuple(_check_and_init(obs_) for obs_ in obs) + elif not isinstance(obs, (Pauli, SparsePauliOp)) and isinstance(obs, BaseOperator): + issue_deprecation_msg( + msg="Only Pauli and SparsePauliOp operators can be used as observables.", + version="0.13", + remedy="", + ) + return init_observable(obs) + elif isinstance(obs, Mapping): + for key in obs.keys(): + _check_and_init(key) + + return obs + + if isinstance(observables, str) or not isinstance(observables, Sequence): + observables = (observables,) + + if len(observables) == 0: + raise ValueError("No observables were provided.") + + return tuple(_check_and_init(obs_array) for obs_array in observables) + + @staticmethod + def _validate_parameter_values( + parameter_values: BindingsArrayLike | Sequence[BindingsArrayLike] | None, + default: Sequence[Sequence[float]] | Sequence[float] | None = None, + ) -> Sequence: + + # Allow optional (if default) + if parameter_values is None: + if default is None: + raise ValueError("No default `parameter_values`, optional input disallowed.") + parameter_values = default + + # Convert single input types to length-1 lists + if _isreal(parameter_values): + parameter_values = [[parameter_values]] + elif isinstance(parameter_values, Mapping): + parameter_values = [parameter_values] + elif isinstance(parameter_values, Sequence) and all( + _isreal(item) for item in parameter_values + ): + parameter_values = [parameter_values] + return tuple(parameter_values) # type: ignore[arg-type] + + @staticmethod + def _cross_validate_circuits_parameter_values( + circuits: tuple[QuantumCircuit, ...], parameter_values: tuple[tuple[float, ...], ...] + ) -> None: + if len(circuits) != len(parameter_values): + raise ValueError( + f"The number of circuits ({len(circuits)}) does not match " + f"the number of parameter value sets ({len(parameter_values)})." + ) + + @staticmethod + def _cross_validate_circuits_observables( + circuits: tuple[QuantumCircuit, ...], observables: tuple[ObservablesArrayLike, ...] + ) -> None: + if len(circuits) != len(observables): + raise ValueError( + f"The number of circuits ({len(circuits)}) does not match " + f"the number of observables ({len(observables)})." + ) + @classmethod def _program_id(cls) -> str: """Return the program ID.""" diff --git a/qiskit_ibm_runtime/options/__init__.py b/qiskit_ibm_runtime/options/__init__.py index 25eec52bb..baf48c0f9 100644 --- a/qiskit_ibm_runtime/options/__init__.py +++ b/qiskit_ibm_runtime/options/__init__.py @@ -49,6 +49,7 @@ ExecutionOptions EnvironmentOptions SimulatorOptions + TwirlingOptions """ @@ -58,3 +59,4 @@ from .simulator_options import SimulatorOptions from .transpilation_options import TranspilationOptions from .resilience_options import ResilienceOptions +from .twirling_options import TwirlingOptions diff --git a/qiskit_ibm_runtime/options/execution_options.py b/qiskit_ibm_runtime/options/execution_options.py index 01022f7d7..6dc063daa 100644 --- a/qiskit_ibm_runtime/options/execution_options.py +++ b/qiskit_ibm_runtime/options/execution_options.py @@ -13,13 +13,17 @@ """Execution options.""" from dataclasses import dataclass -from typing import Literal, get_args +from typing import Literal, get_args, Optional +from numbers import Integral from .utils import _flexible ExecutionSupportedOptions = Literal[ "shots", "init_qubits", + "samples", + "shots_per_sample", + "interleave_samples", ] @@ -29,14 +33,34 @@ class ExecutionOptions: """Execution options. Args: - shots: Number of repetitions of each circuit, for sampling. Default: 4000. + shots: Number of repetitions of each circuit, for sampling. Default: 4096. init_qubits: Whether to reset the qubits to the ground state for each shot. Default: ``True``. + + samples: The number of samples of each measurement circuit to run. This + is used when twirling or resilience levels 1, 2, 3. If None it will + be calculated automatically based on the ``shots`` and + ``shots_per_sample`` (if specified). + Default: None + + shots_per_sample: The number of shots per sample of each measurement + circuit to run. This is used when twirling or resilience levels 1, 2, 3. + If None it will be calculated automatically based on the ``shots`` and + ``samples`` (if specified). + Default: None + + interleave_samples: If True interleave samples from different measurement + circuits when running. If False run all samples from each measurement + circuit in order. + Default: False """ - shots: int = 4000 + shots: int = 4096 init_qubits: bool = True + samples: Optional[int] = None + shots_per_sample: Optional[int] = None + interleave_samples: bool = False @staticmethod def validate_execution_options(execution_options: dict) -> None: @@ -47,3 +71,33 @@ def validate_execution_options(execution_options: dict) -> None: for opt in execution_options: if not opt in get_args(ExecutionSupportedOptions): raise ValueError(f"Unsupported value '{opt}' for execution.") + + shots = execution_options.get("shots") + samples = execution_options.get("samples") + shots_per_sample = execution_options.get("shots_per_sample") + if ( + shots is not None + and samples is not None + and shots_per_sample is not None + and shots != samples * shots_per_sample + ): + raise ValueError( + f"If shots ({shots}) != samples ({samples}) * shots_per_sample ({shots_per_sample})" + ) + if shots is not None: + if not isinstance(shots, Integral): + raise ValueError(f"shots must be None or an integer, not {type(shots)}") + if shots < 1: + raise ValueError("shots must be None or >= 1") + if samples is not None: + if not isinstance(samples, Integral): + raise ValueError(f"samples must be None or an integer, not {type(samples)}") + if samples < 1: + raise ValueError("samples must be None or >= 1") + if shots_per_sample is not None: + if not isinstance(shots_per_sample, Integral): + raise ValueError( + f"shots_per_sample must be None or an integer, not {type(shots_per_sample)}" + ) + if shots_per_sample < 1: + raise ValueError("shots_per_sample must be None or >= 1") diff --git a/qiskit_ibm_runtime/options/options.py b/qiskit_ibm_runtime/options/options.py index b9f894c2f..75d4b428b 100644 --- a/qiskit_ibm_runtime/options/options.py +++ b/qiskit_ibm_runtime/options/options.py @@ -12,21 +12,24 @@ """Primitive options.""" -from typing import Optional, Union, ClassVar -from dataclasses import dataclass, fields, field +from typing import Optional, Union, ClassVar, Literal, get_args, Any +from dataclasses import dataclass, fields, field, asdict import copy import warnings from qiskit.transpiler import CouplingMap -from .utils import _flexible, Dict +from .utils import _flexible, Dict, _remove_dict_none_values from .environment_options import EnvironmentOptions from .execution_options import ExecutionOptions from .simulator_options import SimulatorOptions from .transpilation_options import TranspilationOptions -from .resilience_options import ResilienceOptions +from .resilience_options import ResilienceOptions, _ZneOptions, _PecOptions +from .twirling_options import TwirlingOptions from ..runtime_options import RuntimeOptions +DDSequenceType = Literal[None, "XX", "XpXm", "XY4"] + @_flexible @dataclass @@ -68,6 +71,10 @@ class Options: `system imposed maximum `_. + dynamical_decoupling: Optional, specify a dynamical decoupling sequence to use. + Allowed values are ``"XX"``, ``"XpXm"``, ``"XY4"``. + Default: None + transpilation: Transpilation options. See :class:`TranspilationOptions` for all available options. @@ -86,7 +93,9 @@ class Options: # Defaults for optimization_level and for resilience_level will be assigned # in Sampler/Estimator _DEFAULT_OPTIMIZATION_LEVEL = 3 + _DEFAULT_NOISELESS_OPTIMIZATION_LEVEL = 1 _DEFAULT_RESILIENCE_LEVEL = 1 + _DEFAULT_NOISELESS_RESILIENCE_LEVEL = 0 _MAX_OPTIMIZATION_LEVEL = 3 _MAX_RESILIENCE_LEVEL_ESTIMATOR = 3 _MAX_RESILIENCE_LEVEL_SAMPLER = 1 @@ -96,11 +105,13 @@ class Options: optimization_level: Optional[int] = None resilience_level: Optional[int] = None max_execution_time: Optional[int] = None + dynamical_decoupling: Optional[DDSequenceType] = None transpilation: Union[TranspilationOptions, Dict] = field(default_factory=TranspilationOptions) resilience: Union[ResilienceOptions, Dict] = field(default_factory=ResilienceOptions) execution: Union[ExecutionOptions, Dict] = field(default_factory=ExecutionOptions) environment: Union[EnvironmentOptions, Dict] = field(default_factory=EnvironmentOptions) simulator: Union[SimulatorOptions, Dict] = field(default_factory=SimulatorOptions) + twirling: Union[TwirlingOptions, Dict] = field(default_factory=TwirlingOptions) _obj_fields: ClassVar[dict] = { "transpilation": TranspilationOptions, @@ -108,6 +119,7 @@ class Options: "environment": EnvironmentOptions, "simulator": SimulatorOptions, "resilience": ResilienceOptions, + "twirling": TwirlingOptions, } @staticmethod @@ -117,39 +129,80 @@ def _get_program_inputs(options: dict) -> dict: Returns: Inputs acceptable by primitives. """ - sim_options = options.get("simulator", {}) - inputs = {} - inputs["transpilation_settings"] = options.get("transpilation", {}) - inputs["transpilation_settings"].update( - { - "optimization_settings": {"level": options.get("optimization_level")}, - "coupling_map": sim_options.get("coupling_map", None), - "basis_gates": sim_options.get("basis_gates", None), - } - ) - if isinstance(inputs["transpilation_settings"]["coupling_map"], CouplingMap): - inputs["transpilation_settings"]["coupling_map"] = list( - map(list, inputs["transpilation_settings"]["coupling_map"].get_edges()) + + if not options.get("_experimental", True): + sim_options = options.get("simulator", {}) + inputs = {} + inputs["transpilation_settings"] = options.get("transpilation", {}) + inputs["transpilation_settings"].update( + { + "optimization_settings": {"level": options.get("optimization_level")}, + "coupling_map": sim_options.get("coupling_map", None), + "basis_gates": sim_options.get("basis_gates", None), + } ) + if isinstance(inputs["transpilation_settings"]["coupling_map"], CouplingMap): + inputs["transpilation_settings"]["coupling_map"] = list( + map(list, inputs["transpilation_settings"]["coupling_map"].get_edges()) + ) - inputs["resilience_settings"] = options.get("resilience", {}) - inputs["resilience_settings"].update({"level": options.get("resilience_level")}) - inputs["run_options"] = options.get("execution") - inputs["run_options"].update( - { - "noise_model": sim_options.get("noise_model", None), - "seed_simulator": sim_options.get("seed_simulator", None), - } - ) + inputs["resilience_settings"] = options.get("resilience", {}) + inputs["resilience_settings"].update({"level": options.get("resilience_level")}) + inputs["run_options"] = options.get("execution") + inputs["run_options"].update( + { + "noise_model": sim_options.get("noise_model", None), + "seed_simulator": sim_options.get("seed_simulator", None), + } + ) + + known_keys = list(Options.__dataclass_fields__.keys()) + known_keys.append("image") + # Add additional unknown keys. + for key in options.keys(): + if key not in known_keys: + warnings.warn(f"Key '{key}' is an unrecognized option. It may be ignored.") + inputs[key] = options[key] + inputs["_experimental"] = False + return inputs + else: + sim_options = options.get("simulator", {}) + inputs = {} + inputs["transpilation"] = copy.copy(options.get("transpilation", {})) + inputs["skip_transpilation"] = inputs["transpilation"].pop("skip_transpilation") + coupling_map = sim_options.get("coupling_map", None) + if isinstance(coupling_map, CouplingMap): + coupling_map = list(map(list, coupling_map.get_edges())) + inputs["transpilation"].update( + { + "optimization_level": options.get("optimization_level"), + "coupling_map": coupling_map, + "basis_gates": sim_options.get("basis_gates", None), + } + ) - known_keys = list(Options.__dataclass_fields__.keys()) - known_keys.append("image") - # Add additional unknown keys. - for key in options.keys(): - if key not in known_keys: - warnings.warn(f"Key '{key}' is an unrecognized option. It may be ignored.") - inputs[key] = options[key] - return inputs + inputs["resilience_level"] = options.get("resilience_level") + inputs["resilience"] = options.get("resilience", {}) + inputs["twirling"] = options.get("twirling", {}) + + inputs["execution"] = options.get("execution") + inputs["execution"].update( + { + "noise_model": sim_options.get("noise_model", None), + "seed_simulator": sim_options.get("seed_simulator", None), + } + ) + + known_keys = list(Options.__dataclass_fields__.keys()) + known_keys.append("image") + # Add additional unknown keys. + for key in options.keys(): + if key not in known_keys: + warnings.warn(f"Key '{key}' is an unrecognized option. It may be ignored.") + inputs[key] = options[key] + + inputs["_experimental"] = True + return inputs @staticmethod def validate_options(options: dict) -> None: @@ -165,6 +218,15 @@ def validate_options(options: dict) -> None: f"optimization_level can only take the values " f"{list(range(Options._MAX_OPTIMIZATION_LEVEL + 1))}" ) + + dd_seq = options.get("dynamical_decoupling") + if dd_seq not in get_args(DDSequenceType): + raise ValueError( + f"Unsupported value '{dd_seq}' for dynamical_decoupling. " + f"Allowed values are {get_args(DDSequenceType)}" + ) + + TwirlingOptions.validate_twirling_options(options.get("twirling")) ResilienceOptions.validate_resilience_options(options.get("resilience")) TranspilationOptions.validate_transpilation_options(options.get("transpilation")) execution_time = options.get("max_execution_time") @@ -202,7 +264,11 @@ def _get_runtime_options(options: dict) -> dict: return out @staticmethod - def _merge_options(old_options: dict, new_options: Optional[dict] = None) -> dict: + def _merge_options( + old_options: dict, + new_options: Optional[dict] = None, + allowed_none_keys: Optional[set] = None, + ) -> dict: """Merge current options with the new ones. Args: @@ -211,6 +277,7 @@ def _merge_options(old_options: dict, new_options: Optional[dict] = None) -> dic Returns: Merged dictionary. """ + allowed_none_keys = allowed_none_keys or set() def _update_options(old: dict, new: dict, matched: Optional[dict] = None) -> None: if not new and not matched: @@ -222,9 +289,13 @@ def _update_options(old: dict, new: dict, matched: Optional[dict] = None) -> Non matched = new.pop(key, {}) _update_options(val, new, matched) elif key in new.keys(): - old[key] = new.pop(key) + new_val = new.pop(key) + if new_val is not None or key in allowed_none_keys: + old[key] = new_val elif key in matched.keys(): - old[key] = matched.pop(key) + new_val = matched.pop(key) + if new_val is not None or key in allowed_none_keys: + old[key] = new_val # Add new keys. for key, val in matched.items(): @@ -242,3 +313,120 @@ def _update_options(old: dict, new: dict, matched: Optional[dict] = None) -> Non combined.update(new_options_copy) return combined + + @classmethod + def _merge_options_with_defaults( + cls, + primitive_options: dict, + overwrite_options: Optional[dict] = None, + is_simulator: bool = False, + ) -> dict: + def _get_merged_value(name: str, first: dict = None, second: dict = None) -> Any: + first = first or overwrite_options + second = second or primitive_options + return first.get(name) or second.get(name) + + # 1. Determine optimization and resilience levels + optimization_level = _get_merged_value("optimization_level") + resilience_level = _get_merged_value("resilience_level") + noise_model = _get_merged_value( + "noise_model", + first=overwrite_options.get("simulator", {}), + second=primitive_options.get("simulator", {}), + ) + if optimization_level is None: + optimization_level = ( + cls._DEFAULT_NOISELESS_OPTIMIZATION_LEVEL + if (is_simulator and noise_model is None) + else cls._DEFAULT_OPTIMIZATION_LEVEL + ) + if resilience_level is None: + resilience_level = ( + cls._DEFAULT_NOISELESS_RESILIENCE_LEVEL + if (is_simulator and noise_model is None) + else cls._DEFAULT_RESILIENCE_LEVEL + ) + + # 2. Determine the default resilience options + if resilience_level not in _DEFAULT_RESILIENCE_LEVEL_OPTIONS: + raise ValueError(f"resilience_level {resilience_level} is not a valid value.") + default_options = asdict(_DEFAULT_RESILIENCE_LEVEL_OPTIONS[resilience_level]) + default_options["optimization_level"] = optimization_level + + # HACK: To allow certain values to be explicitly updated with None + none_keys = {"shots", "samples", "shots_per_sample", "zne_extrapolator", "pec_max_overhead"} + + # 3. Merge in primitive options. + final_options = Options._merge_options( + default_options, primitive_options, allowed_none_keys=none_keys + ) + + # 4. Merge in overwrites. + final_options = Options._merge_options( + final_options, overwrite_options, allowed_none_keys=none_keys + ) + + # 5. Remove Nones + _remove_dict_none_values(final_options, allowed_none_keys=none_keys) + + return final_options + + +@dataclass(frozen=True) +class _ResilienceLevel0Options: + resilience_level: int = 0 + resilience: ResilienceOptions = field( + default_factory=lambda: ResilienceOptions( + measure_noise_mitigation=False, zne_mitigation=False, pec_mitigation=False + ) + ) + twirling: TwirlingOptions = field( + default_factory=lambda: TwirlingOptions(gates=False, measure=False) + ) + + +@dataclass(frozen=True) +class _ResilienceLevel1Options: + resilience_level: int = 1 + resilience: ResilienceOptions = field( + default_factory=lambda: ResilienceOptions( + measure_noise_mitigation=True, zne_mitigation=False, pec_mitigation=False + ) + ) + twirling: TwirlingOptions = field( + default_factory=lambda: TwirlingOptions(gates=False, measure=True, strategy="active-accum") + ) + + +@dataclass(frozen=True) +class _ResilienceLevel2Options: + resilience_level: int = 2 + resilience: ResilienceOptions = field( + default_factory=lambda: ResilienceOptions( + measure_noise_mitigation=True, pec_mitigation=False, **asdict(_ZneOptions()) + ) + ) + twirling: TwirlingOptions = field( + default_factory=lambda: TwirlingOptions(gates=True, measure=True, strategy="active-accum") + ) + + +@dataclass(frozen=True) +class _ResilienceLevel3Options: + resilience_level: int = 3 + resilience: ResilienceOptions = field( + default_factory=lambda: ResilienceOptions( + measure_noise_mitigation=True, zne_mitigation=False, **asdict(_PecOptions()) + ) + ) + twirling: TwirlingOptions = field( + default_factory=lambda: TwirlingOptions(gates=True, measure=True, strategy="active") + ) + + +_DEFAULT_RESILIENCE_LEVEL_OPTIONS = { + 0: _ResilienceLevel0Options(), + 1: _ResilienceLevel1Options(), + 2: _ResilienceLevel2Options(), + 3: _ResilienceLevel3Options(), +} diff --git a/qiskit_ibm_runtime/options/resilience_options.py b/qiskit_ibm_runtime/options/resilience_options.py index 866ce0741..6ace12515 100644 --- a/qiskit_ibm_runtime/options/resilience_options.py +++ b/qiskit_ibm_runtime/options/resilience_options.py @@ -12,11 +12,11 @@ """Resilience options.""" -from typing import Sequence, Literal, get_args +from typing import Sequence, Literal, get_args, Union from dataclasses import dataclass from .utils import _flexible -from ..utils.deprecation import issue_deprecation_msg +from ..utils.deprecation import issue_deprecation_msg, deprecate_arguments ResilienceSupportedOptions = Literal[ "noise_amplifier", @@ -36,6 +36,17 @@ "QuarticExtrapolator", ] +ZneExtrapolatorType = Literal[ + None, + "exponential", + "double_exponential", + "linear", + "polynomial_degree_1", + "polynomial_degree_2", + "polynomial_degree_3", + "polynomial_degree_4", +] + @_flexible @dataclass @@ -43,39 +54,93 @@ class ResilienceOptions: """Resilience options. Args: - noise_factors: An list of real valued noise factors that determine by what amount the - circuits' noise is amplified. + noise_factors (DEPRECATED): An list of real valued noise factors that determine + by what amount the circuits' noise is amplified. Only applicable for ``resilience_level=2``. - Default: (1, 3, 5). + Default: (1, 3, 5) if resilience level is 2. Otherwise ``None``. noise_amplifier (DEPRECATED): A noise amplification strategy. One of ``"TwoQubitAmplifier"``, ``"GlobalFoldingAmplifier"``, ``"LocalFoldingAmplifier"``, ``"CxAmplifier"``. Only applicable for ``resilience_level=2``. - Default: "TwoQubitAmplifier". + Default: "TwoQubitAmplifier" if resilience level is 2. Otherwise ``None``. - extrapolator: An extrapolation strategy. One of ``"LinearExtrapolator"``, + extrapolator (DEPRECATED): An extrapolation strategy. One of ``"LinearExtrapolator"``, ``"QuadraticExtrapolator"``, ``"CubicExtrapolator"``, ``"QuarticExtrapolator"``. Note that ``"CubicExtrapolator"`` and ``"QuarticExtrapolator"`` require more noise factors than the default. Only applicable for ``resilience_level=2``. - Default: "LinearExtrapolator". + Default: ``LinearExtrapolator`` if resilience level is 2. Otherwise ``None``. + + measure_noise_mitigation: Whether to enable measurement error mitigation method. + By default, this is enabled for resilience level 1, 2, and 3 (when applicable). + + zne_mitigation: Whether to turn on Zero Noise Extrapolation error mitigation method. + By default, ZNE is enabled for resilience level 2. + + zne_noise_factors: An list of real valued noise factors that determine by what amount the + circuits' noise is amplified. + Only applicable if ZNE is enabled. + Default: (1, 3, 5). + + zne_extrapolator: An extrapolation strategy. One or more of ``"multi_exponential"``, + ``"single_exponential"``, ``"double_exponential"``, ``"linear"``. + Only applicable if ZNE is enabled. + Default: ``("exponential, "linear")`` + + zne_stderr_threshold: A standard error threshold for accepting the ZNE result of Pauli basis + expectation values when using ZNE mitigation. Any extrapolator model resulting an larger + standard error than this value, or mean that is outside of the allowed range and threshold + will be rejected. If all models are rejected the result for the lowest noise factor is + used for that basis term. + Only applicable if ZNE is enabled. + Default: 0.25 + + pec_mitigation: Whether to turn on Probabilistic Error Cancellation error mitigation method. + By default, PEC is enabled for resilience level 3. + + pec_max_overhead: Specify a maximum sampling overhead for the PEC sampling noise model. + If None the full learned model will be sampled from, otherwise if the learned noise + model has a sampling overhead greater than this value it will be scaled down to + implement partial PEC with a scaled noise model corresponding to the maximum + sampling overhead. + Only applicable if PEC is enabled. + Default: 100 """ noise_amplifier: NoiseAmplifierType = None - noise_factors: Sequence[float] = (1, 3, 5) - extrapolator: ExtrapolatorType = "LinearExtrapolator" + noise_factors: Sequence[float] = None + extrapolator: ExtrapolatorType = None + + # Measurement error mitigation + measure_noise_mitigation: bool = None + + # ZNE + zne_mitigation: bool = None + zne_noise_factors: Sequence[float] = None + zne_extrapolator: Union[ZneExtrapolatorType, Sequence[ZneExtrapolatorType]] = ( + "exponential", + "linear", + ) + zne_stderr_threshold: float = None + + # PEC + pec_mitigation: bool = None + pec_max_overhead: float = None @staticmethod def validate_resilience_options(resilience_options: dict) -> None: """Validate that resilience options are legal. + Raises: ValueError: if any resilience option is not supported ValueError: if noise_amplifier is not in NoiseAmplifierType. ValueError: if extrapolator is not in ExtrapolatorType. ValueError: if extrapolator == "QuarticExtrapolator" and number of noise_factors < 5. ValueError: if extrapolator == "CubicExtrapolator" and number of noise_factors < 4. + TypeError: if an input value has an invalid type. """ - if resilience_options.get("noise_amplifier", None) is not None: + noise_amplifier = resilience_options.get("noise_amplifier") + if noise_amplifier is not None: issue_deprecation_msg( msg="The 'noise_amplifier' resilience option is deprecated", version="0.12.0", @@ -85,22 +150,32 @@ def validate_resilience_options(resilience_options: dict) -> None: "Refer to https://github.com/qiskit-community/prototype-zne " "for global folding amplification in ZNE.", ) + if noise_amplifier not in get_args(NoiseAmplifierType): + raise ValueError( + f"Unsupported value {noise_amplifier} for noise_amplifier. " + f"Supported values are {get_args(NoiseAmplifierType)}" + ) - for opt in resilience_options: - if not opt in get_args(ResilienceSupportedOptions): - raise ValueError(f"Unsupported value '{opt}' for resilience.") - noise_amplifier = resilience_options.get("noise_amplifier") or "TwoQubitAmplifier" - if not noise_amplifier in get_args(NoiseAmplifierType): - raise ValueError( - f"Unsupported value {noise_amplifier} for noise_amplifier. " - f"Supported values are {get_args(NoiseAmplifierType)}" + if resilience_options.get("noise_factors", None) is not None: + deprecate_arguments( + deprecated="noise_factors", + version="0.13.0", + remedy="Please use 'zne_noise_factors' instead.", ) + extrapolator = resilience_options.get("extrapolator") - if not extrapolator in get_args(ExtrapolatorType): - raise ValueError( - f"Unsupported value {extrapolator} for extrapolator. " - f"Supported values are {get_args(ExtrapolatorType)}" + if extrapolator is not None: + deprecate_arguments( + deprecated="extrapolator", + version="0.13.0", + remedy="Please use 'zne_extrapolator' instead.", ) + if extrapolator not in get_args(ExtrapolatorType): + raise ValueError( + f"Unsupported value {extrapolator} for extrapolator. " + f"Supported values are {get_args(ExtrapolatorType)}" + ) + if ( extrapolator == "QuarticExtrapolator" and len(resilience_options.get("noise_factors")) < 5 @@ -108,3 +183,75 @@ def validate_resilience_options(resilience_options: dict) -> None: raise ValueError("QuarticExtrapolator requires at least 5 noise_factors.") if extrapolator == "CubicExtrapolator" and len(resilience_options.get("noise_factors")) < 4: raise ValueError("CubicExtrapolator requires at least 4 noise_factors.") + + # Validation of new ZNE options + if resilience_options.get("zne_mitigation"): + # Validate extrapolator + extrapolator = resilience_options.get("zne_extrapolator") + if isinstance(extrapolator, str): + extrapolator = (extrapolator,) + if extrapolator is not None: + for extrap in extrapolator: + if extrap not in get_args(ZneExtrapolatorType): + raise ValueError( + f"Unsupported value {extrapolator} for zne_extrapolator. " + f"Supported values are {get_args(ZneExtrapolatorType)}" + ) + + # Validation of noise factors + factors = resilience_options.get("zne_noise_factors") + if not isinstance(factors, (list, tuple)): + raise TypeError( + f"zne_noise_factors option value must be a sequence, not {type(factors)}" + ) + if any(i <= 0 for i in factors): + raise ValueError("zne_noise_factors` option value must all be non-negative") + if len(factors) < 1: + raise ValueError("zne_noise_factors cannot be empty") + if extrapolator is not None: + required_factors = { + "exponential": 2, + "double_exponential": 4, + "linear": 2, + "polynomial_degree_1": 2, + "polynomial_degree_2": 3, + "polynomial_degree_3": 4, + "polynomial_degree_4": 5, + } + for extrap in extrapolator: + if len(factors) < required_factors[extrap]: + raise ValueError( + f"{extrap} requires at least {required_factors[extrap]} zne_noise_factors" + ) + + # Validation of threshold + threshold = resilience_options.get("zne_stderr_threshold") + if threshold is not None and threshold <= 0: + raise ValueError("Invalid zne_stderr_threshold option value must be > 0") + + if resilience_options.get("pec_mitigation"): + if resilience_options.get("zne_mitigation"): + raise ValueError( + "pec_mitigation and zne_mitigation`options cannot be " + "simultaneously enabled. Set one of them to False." + ) + max_overhead = resilience_options.get("pec_max_overhead") + if max_overhead is not None and max_overhead < 1: + raise ValueError("pec_max_overhead must be None or >= 1") + + +@dataclass(frozen=True) +class _ZneOptions: + zne_mitigation: bool = True + zne_noise_factors: Sequence[float] = (1, 3, 5) + zne_extrapolator: Union[ZneExtrapolatorType, Sequence[ZneExtrapolatorType]] = ( + "exponential", + "linear", + ) + zne_stderr_threshold: float = 0.25 + + +@dataclass(frozen=True) +class _PecOptions: + pec_mitigation: bool = True + pec_max_overhead: float = 100 diff --git a/qiskit_ibm_runtime/options/twirling_options.py b/qiskit_ibm_runtime/options/twirling_options.py new file mode 100644 index 000000000..13fbc9af4 --- /dev/null +++ b/qiskit_ibm_runtime/options/twirling_options.py @@ -0,0 +1,80 @@ +# 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. + +"""Twirling options.""" + +from typing import Literal, get_args +from dataclasses import dataclass + +from .utils import _flexible + + +TwirlingStrategyType = Literal[ + None, + "active", + "active-accum", + "active-circuit", + "all", +] + + +@_flexible +@dataclass +class TwirlingOptions: + """Twirling options. + + Args: + gates: Whether to apply 2-qubit gate twirling. + By default, gate twirling is enabled for resilience level >0. + + measure: Whether to apply measurement twirling. + By default, measurement twirling is enabled for resilience level >0. + + strategy: Specify the strategy of twirling qubits in identified layers of + 2-qubit twirled gates. Allowed values are + + - If ``"active"`` only the instruction qubits in each individual twirled + layer will be twirled. + - If ``"active-circuit"`` the union of all instruction qubits in the circuit + will be twirled in each twirled layer. + - If ``"active-accum"`` the union of instructions qubits in the circuit up to + the current twirled layer will be twirled in each individual twirled layer. + - If ``"all"`` all qubits in the input circuit will be twirled in each + twirled layer. + - If None twirling will be disabled. + + Default: ``"active-accum"`` for resilience levels 0, 1, 2. ``"active"`` for + resilience level 3. + """ + + gates: bool = None + measure: bool = None + strategy: TwirlingStrategyType = None + + @staticmethod + def validate_twirling_options(twirling_options: dict) -> None: + """Validate that twirling options are legal. + + Raises: + ValueError: if any resilience option is not supported + ValueError: if noise_amplifier is not in NoiseAmplifierType. + ValueError: if extrapolator is not in ExtrapolatorType. + ValueError: if extrapolator == "QuarticExtrapolator" and number of noise_factors < 5. + ValueError: if extrapolator == "CubicExtrapolator" and number of noise_factors < 4. + """ + if twirling_options.get("gates"): + strategy = twirling_options.get("strategy") + if strategy not in get_args(TwirlingStrategyType): + raise ValueError( + f"Unsupported value {strategy} for twirling strategy. " + f"Supported values are {get_args(TwirlingStrategyType)}" + ) diff --git a/qiskit_ibm_runtime/options/utils.py b/qiskit_ibm_runtime/options/utils.py index 78cd0ad21..58e8a98da 100644 --- a/qiskit_ibm_runtime/options/utils.py +++ b/qiskit_ibm_runtime/options/utils.py @@ -12,7 +12,9 @@ """Utility functions for options.""" +from typing import Optional from dataclasses import fields, field, make_dataclass + from ..ibm_backend import IBMBackend @@ -53,6 +55,15 @@ def set_default_error_levels( return options +def _remove_dict_none_values(in_dict: dict, allowed_none_keys: Optional[set] = None) -> None: + allowed_none_keys = allowed_none_keys or set() + for key, val in list(in_dict.items()): + if val is None and key not in allowed_none_keys: + del in_dict[key] + elif isinstance(val, dict): + _remove_dict_none_values(val, allowed_none_keys=allowed_none_keys) + + def _to_obj(cls_, data): # type: ignore if data is None: return cls_() diff --git a/qiskit_ibm_runtime/sampler.py b/qiskit_ibm_runtime/sampler.py index b0a43d47d..81a69ca5a 100644 --- a/qiskit_ibm_runtime/sampler.py +++ b/qiskit_ibm_runtime/sampler.py @@ -166,13 +166,11 @@ def _validate_options(self, options: dict) -> None: qctrl_validate(options) return - if options.get("resilience_level") and not options.get("resilience_level") in [ - 0, - 1, - ]: + valid_levels = list(range(Options._MAX_RESILIENCE_LEVEL_SAMPLER + 1)) + if options.get("resilience_level") and not options.get("resilience_level") in valid_levels: raise ValueError( - f"resilience_level can only take the values " - f"{list(range(Options._MAX_RESILIENCE_LEVEL_SAMPLER + 1))} in Sampler" + f"resilience_level {options.get('resilience_level')} is not a valid value." + f"It can only take the values {valid_levels} in Sampler." ) Options.validate_options(options) diff --git a/releasenotes/notes/default-resilience-options-7929458af000314f.yaml b/releasenotes/notes/default-resilience-options-7929458af000314f.yaml new file mode 100644 index 000000000..74c9e9dc9 --- /dev/null +++ b/releasenotes/notes/default-resilience-options-7929458af000314f.yaml @@ -0,0 +1,10 @@ +--- +fixes: + - | + The ``noise_factors`` and ``extrapolator`` options in :class:`qiskit_ibm_runtime.options.ResilienceOptions` + will now default to ``None`` unless ``resilience_level`` is set to 2. + Only options relevant to the resilience level will be set, so when using ``resilience_level`` + 2, ``noise_factors`` will still default to ``(1, 3, 5)`` and ``extrapolator`` will default to + ``LinearExtrapolator``. Additionally, options with a value of ``None`` will no longer be sent to + the server. + diff --git a/test/integration/test_options.py b/test/integration/test_options.py index 66c88a841..eb51247e2 100644 --- a/test/integration/test_options.py +++ b/test/integration/test_options.py @@ -116,6 +116,28 @@ def test_unsupported_input_combinations(self, service): inst.run(circ, observables=obs) self.assertIn("a coupling map is required.", str(exc.exception)) + @run_integration_test + def test_default_resilience_settings(self, service): + """Test that correct default resilience settings are used.""" + circ = QuantumCircuit(1) + obs = SparsePauliOp.from_list([("I", 1)]) + options = Options(resilience_level=2) + backend = service.backends(simulator=True)[0] + with Session(service=service, backend=backend) as session: + inst = Estimator(session=session, options=options) + job = inst.run(circ, observables=obs) + self.assertEqual(job.inputs["resilience_settings"]["noise_factors"], [1, 3, 5]) + self.assertEqual( + job.inputs["resilience_settings"]["extrapolator"], "LinearExtrapolator" + ) + + options = Options(resilience_level=1) + with Session(service=service, backend=backend) as session: + inst = Estimator(session=session, options=options) + job = inst.run(circ, observables=obs) + self.assertIsNone(job.inputs["resilience_settings"]["noise_factors"]) + self.assertIsNone(job.inputs["resilience_settings"]["extrapolator"]) + @production_only @run_integration_test def test_all_resilience_levels(self, service): diff --git a/test/unit/test_estimator.py b/test/unit/test_estimator.py index fdc3e96af..f7751ce6d 100644 --- a/test/unit/test_estimator.py +++ b/test/unit/test_estimator.py @@ -15,7 +15,10 @@ import warnings from qiskit import QuantumCircuit -from qiskit.quantum_info import SparsePauliOp +from qiskit.quantum_info import SparsePauliOp, Pauli, random_hermitian, random_pauli_list +from qiskit.circuit import Parameter + +import numpy as np from qiskit_ibm_runtime import Estimator, Session, Options @@ -69,3 +72,148 @@ def test_deprecated_noise_amplifier_run(self): estimator.run(self.circuit, self.observables, noise_amplifier="GlobalFoldingAmplifier") self.assertEqual(len(warn), 1, "Deprecation warning not found.") self.assertIn("noise_amplifier", str(warn[-1].message)) + + def test_observable_types_single_circuit(self): + """Test different observable types for a single circuit.""" + all_obs = [ + "IX", + Pauli("YZ"), + SparsePauliOp(["IX", "YZ"]), + {"YZ": 1 + 2j}, + {Pauli("XX"): 1 + 2j}, + random_hermitian((2, 2)), + [["XX", "YY"]], + [[Pauli("XX"), Pauli("YY")]], + [[SparsePauliOp(["XX"]), SparsePauliOp(["YY"])]], + [ + [ + {"XX": 1 + 2j}, + {"YY": 1 + 2j}, + ] + ], + [ + [ + {Pauli("XX"): 1 + 2j}, + {Pauli("YY"): 1 + 2j}, + ] + ], + [random_pauli_list(2, 2)], + ] + + circuit = QuantumCircuit(2) + estimator = Estimator(backend=get_mocked_backend()) + for obs in all_obs: + with self.subTest(obs=obs): + estimator.run(circuits=circuit, observables=obs) + + def test_observable_types_multi_circuits(self): + """Test different observable types for multiple circuits.""" + num_qx = 2 + all_obs = [ + ["XX", "YY"], + [Pauli("XX"), Pauli("YY")], + [SparsePauliOp(["XX"]), SparsePauliOp(["YY"])], + [ + {"XX": 1 + 2j}, + {"YY": 1 + 2j}, + ], + [ + {Pauli("XX"): 1 + 2j}, + {Pauli("YY"): 1 + 2j}, + ], + [["XX", "YY"]] * num_qx, + [[Pauli("XX"), Pauli("YY")]] * num_qx, + [[SparsePauliOp(["XX"]), SparsePauliOp(["YY"])]] * num_qx, + [[{"XX": 1 + 2j}, {"YY": 1 + 2j}]] * num_qx, + [[{Pauli("XX"): 1 + 2j}, {Pauli("YY"): 1 + 2j}]] * num_qx, + [random_pauli_list(2, 2)] * num_qx, + ] + + circuit = QuantumCircuit(2) + estimator = Estimator(backend=get_mocked_backend()) + for obs in all_obs: + with self.subTest(obs=obs): + estimator.run(circuits=[circuit] * num_qx, observables=obs) + + def test_invalid_basis(self): + """Test observable containing invalid basis.""" + all_obs = [ + "JJ", + {"JJ": 1 + 2j}, + [["0J", "YY"]], + [ + [ + {"XX": 1 + 2j}, + {"JJ": 1 + 2j}, + ] + ], + ] + + circuit = QuantumCircuit(2) + estimator = Estimator(backend=get_mocked_backend()) + for obs in all_obs: + with self.subTest(obs=obs): + with self.assertRaises(ValueError): + estimator.run(circuits=circuit, observables=obs) + + def test_single_parameter_single_circuit(self): + """Test single parameter for a single cirucit.""" + theta = Parameter("θ") + circuit = QuantumCircuit(2) + circuit.rz(theta, 0) + + param_vals = [ + np.pi, + [np.pi], + [[np.pi]], + np.array([np.pi]), + np.array([[np.pi]]), + [np.array([np.pi])], + [[[np.pi], [np.pi / 2]]], + {theta: np.pi}, + [{theta: np.pi}], + ] + + estimator = Estimator(backend=get_mocked_backend()) + for val in param_vals: + with self.subTest(val=val): + estimator.run(circuits=circuit, observables="ZZ", parameter_values=val) + + def test_multiple_parameters_single_circuit(self): + """Test multiple parameters for a single circuit.""" + theta = Parameter("θ") + circuit = QuantumCircuit(2) + circuit.rz(theta, [0, 1]) + + param_vals = [ + [[np.pi, np.pi]], + np.array([[np.pi, np.pi]]), + [np.array([np.pi, np.pi])], + [[[np.pi, np.pi], [np.pi / 2, np.pi / 2]]], + {theta: [np.pi, np.pi / 2]}, + {theta: [[np.pi, np.pi / 2], [np.pi / 4, np.pi / 8]]}, + [{theta: [np.pi, np.pi / 2]}], + ] + + estimator = Estimator(backend=get_mocked_backend()) + for val in param_vals: + with self.subTest(val=val): + estimator.run(circuits=circuit, observables="ZZ", parameter_values=val) + + def test_multiple_parameters_multiple_circuits(self): + """Test multiple parameters for multiple circuits.""" + theta = Parameter("θ") + circuit = QuantumCircuit(2) + circuit.rz(theta, [0, 1]) + + param_vals = [ + [[np.pi, np.pi], [0.5, 0.5]], + [np.array([np.pi, np.pi]), np.array([0.5, 0.5])], + [[[np.pi, np.pi], [np.pi / 2, np.pi / 2]], [[0.5, 0.5], [0.1, 0.1]]], + [{theta: [[np.pi, np.pi / 2], [np.pi / 4, np.pi / 8]]}, {theta: [0.5, 0.5]}], + ] + + estimator = Estimator(backend=get_mocked_backend()) + for val in param_vals: + with self.subTest(val=val): + estimator.run(circuits=[circuit] * 2, observables=["ZZ"] * 2, parameter_values=val) diff --git a/test/unit/test_ibm_primitives.py b/test/unit/test_ibm_primitives.py index 27e020079..6d5f2814f 100644 --- a/test/unit/test_ibm_primitives.py +++ b/test/unit/test_ibm_primitives.py @@ -13,7 +13,6 @@ """Tests for primitive classes.""" import sys -import copy import os from unittest.mock import MagicMock, patch import warnings @@ -81,9 +80,7 @@ def test_dict_options(self): for options in options_vars: with self.subTest(primitive=cls, options=options): inst = cls(session=MagicMock(spec=MockSession), options=options) - expected = asdict(Options()) - self._update_dict(expected, copy.deepcopy(options)) - self.assertDictEqual(expected, inst.options.__dict__) + self.assertTrue(dict_paritally_equal(inst.options.__dict__, options)) def test_backend_in_options(self): """Test specifying backend in options.""" @@ -308,10 +305,10 @@ def test_run_default_options(self): """Test run using default options.""" session = MagicMock(spec=MockSession) options_vars = [ - (Options(resilience_level=1), {"resilience_settings": {"level": 1}}), + (Options(resilience_level=1), {"resilience_level": 1}), ( Options(optimization_level=3), - {"transpilation_settings": {"optimization_settings": {"level": 3}}}, + {"transpilation": {"optimization_level": 3}}, ), ( { @@ -319,8 +316,8 @@ def test_run_default_options(self): "execution": {"shots": 100}, }, { - "transpilation_settings": {"initial_layout": [1, 2]}, - "run_options": {"shots": 100}, + "transpilation": {"initial_layout": [1, 2]}, + "execution": {"shots": 100}, }, ), ] @@ -354,9 +351,9 @@ def test_run_updated_default_options(self): self._assert_dict_partially_equal( inputs, { - "resilience_settings": {"level": 1}, - "transpilation_settings": {"optimization_settings": {"level": 2}}, - "run_options": {"shots": 99}, + "resilience_level": 1, + "transpilation": {"optimization_level": 2}, + "execution": {"shots": 99}, }, ) @@ -364,17 +361,17 @@ def test_run_overwrite_options(self): """Test run using overwritten options.""" session = MagicMock(spec=MockSession) options_vars = [ - ({"resilience_level": 1}, {"resilience_settings": {"level": 1}}), - ({"shots": 200}, {"run_options": {"shots": 200}}), + ({"resilience_level": 1}, {"resilience_level": 1}), + ({"shots": 200}, {"execution": {"shots": 200}}), ( {"optimization_level": 3}, - {"transpilation_settings": {"optimization_settings": {"level": 3}}}, + {"transpilation": {"optimization_level": 3}}, ), ( {"initial_layout": [1, 2], "optimization_level": 2}, { - "transpilation_settings": { - "optimization_settings": {"level": 2}, + "transpilation": { + "optimization_level": 2, "initial_layout": [1, 2], } }, @@ -458,7 +455,7 @@ def test_run_multiple_different_options(self): inst.run(self.qx, observables=self.obs, shots=200) kwargs_list = session.run.call_args_list for idx, shots in zip([0, 1], [100, 200]): - self.assertEqual(kwargs_list[idx][1]["inputs"]["run_options"]["shots"], shots) + self.assertEqual(kwargs_list[idx][1]["inputs"]["execution"]["shots"], shots) self.assertDictEqual(inst.options.__dict__, asdict(Options())) def test_run_same_session(self): @@ -533,10 +530,6 @@ def test_accept_level_1_options(self): # Make sure the values are equal. inst1_options = inst1.options.__dict__ expected_dict = inst2.options.__dict__ - self.assertTrue( - dict_paritally_equal(inst1_options, expected_dict), - f"inst_options={inst1_options}, options={opts}", - ) # Make sure the structure didn't change. self.assertTrue( dict_keys_equal(inst1_options, expected_dict), @@ -561,11 +554,11 @@ def test_default_error_levels(self): _, kwargs = session.run.call_args inputs = kwargs["inputs"] self.assertEqual( - inputs["transpilation_settings"]["optimization_settings"]["level"], + inputs["transpilation"]["optimization_level"], Options._DEFAULT_OPTIMIZATION_LEVEL, ) self.assertEqual( - inputs["resilience_settings"]["level"], + inputs["resilience_level"], Options._DEFAULT_RESILIENCE_LEVEL, ) @@ -578,11 +571,11 @@ def test_default_error_levels(self): _, kwargs = session.run.call_args inputs = kwargs["inputs"] self.assertEqual( - inputs["transpilation_settings"]["optimization_settings"]["level"], + inputs["transpilation"]["optimization_level"], Options._DEFAULT_OPTIMIZATION_LEVEL, ) self.assertEqual( - inputs["resilience_settings"]["level"], + inputs["resilience_level"], Options._DEFAULT_RESILIENCE_LEVEL, ) @@ -595,10 +588,10 @@ def test_default_error_levels(self): _, kwargs = session.run.call_args inputs = kwargs["inputs"] self.assertEqual( - inputs["transpilation_settings"]["optimization_settings"]["level"], + inputs["transpilation"]["optimization_level"], 1, ) - self.assertEqual(inputs["resilience_settings"]["level"], 0) + self.assertEqual(inputs["resilience_level"], 0) def test_resilience_options(self): """Test resilience options.""" diff --git a/test/unit/test_options.py b/test/unit/test_options.py index ba39839de..e6432ec7c 100644 --- a/test/unit/test_options.py +++ b/test/unit/test_options.py @@ -147,14 +147,14 @@ def test_program_inputs(self): self.assertEqual(len(warn), 2) expected = { - "run_options": {"shots": 100, "noise_model": noise_model}, - "transpilation_settings": { - "optimization_settings": {"level": 1}, - "skip_transpilation": True, + "execution": {"shots": 100, "noise_model": noise_model}, + "skip_transpilation": True, + "transpilation": { + "optimization_level": 1, "initial_layout": [1, 2], }, - "resilience_settings": { - "level": 2, + "resilience_level": 2, + "resilience": { "noise_factors": (0, 2, 4), }, "foo": "foo", @@ -197,6 +197,7 @@ def test_unsupported_options(self): options = { "optimization_level": 1, "resilience_level": 2, + "dynamical_decoupling": "XX", "transpilation": {"initial_layout": [1, 2], "skip_transpilation": True}, "execution": {"shots": 100}, "environment": {"log_level": "DEBUG"}, @@ -205,9 +206,10 @@ def test_unsupported_options(self): "noise_factors": (0, 2, 4), "extrapolator": "LinearExtrapolator", }, + "twirling": {}, } Options.validate_options(options) - for opt in ["resilience", "simulator", "transpilation", "execution"]: + for opt in ["simulator", "transpilation", "execution"]: temp_options = options.copy() temp_options[opt] = {"aaa": "bbb"} with self.assertRaises(ValueError) as exc: @@ -227,7 +229,7 @@ def test_coupling_map_options(self): options = Options() options.simulator.coupling_map = variant inputs = Options._get_program_inputs(asdict(options)) - resulting_cmap = inputs["transpilation_settings"]["coupling_map"] + resulting_cmap = inputs["transpilation"]["coupling_map"] self.assertEqual(coupling_map, set(map(tuple, resulting_cmap))) @data(FakeManila(), FakeNairobiV2()) @@ -311,3 +313,61 @@ def test_qctrl_overrides(self): with self.subTest(msg=f"{option}"): _warn_and_clean_options(option) self.assertEqual(expected_, option) + + def test_merge_with_defaults_overwrite(self): + """Test merge_with_defaults with different overwrite.""" + expected = {"twirling": {"measure": True}} + all_options = [ + ({"twirling": {"measure": True}}, {}), + ({}, {"twirling": {"measure": True}}), + ({"twirling": {"measure": False}}, {"twirling": {"measure": True}}), + ] + + for old, new in all_options: + with self.subTest(old=old, new=new): + old["resilience_level"] = 0 + final = Options._merge_options_with_defaults(old, new) + self.assertTrue(dict_paritally_equal(final, expected)) + self.assertEqual(final["resilience_level"], 0) + res_dict = final["resilience"] + self.assertFalse(res_dict["measure_noise_mitigation"]) + self.assertFalse(res_dict["zne_mitigation"]) + self.assertFalse(res_dict["pec_mitigation"]) + + def test_merge_with_defaults_different_level(self): + """Test merge_with_defaults with different resilience level.""" + + old = {"resilience_level": 0} + new = {"resilience_level": 3, "measure_noise_mitigation": False} + final = Options._merge_options_with_defaults(old, new) + self.assertEqual(final["resilience_level"], 3) + res_dict = final["resilience"] + self.assertFalse(res_dict["measure_noise_mitigation"]) + self.assertFalse(res_dict["zne_mitigation"]) + self.assertTrue(res_dict["pec_mitigation"]) + + def test_merge_with_defaults_noiseless_simulator(self): + """Test merge_with_defaults with noiseless simulator.""" + + new = {"measure_noise_mitigation": True} + final = Options._merge_options_with_defaults({}, new, is_simulator=True) + self.assertEqual(final["resilience_level"], 0) + self.assertEqual(final["optimization_level"], 1) + res_dict = final["resilience"] + self.assertTrue(res_dict["measure_noise_mitigation"]) + self.assertFalse(res_dict["zne_mitigation"]) + self.assertFalse(res_dict["pec_mitigation"]) + + def test_merge_with_defaults_noisy_simulator(self): + """Test merge_with_defaults with noisy simulator.""" + + new = {"measure_noise_mitigation": False} + final = Options._merge_options_with_defaults( + {"simulator": {"noise_model": "foo"}}, new, is_simulator=True + ) + self.assertEqual(final["resilience_level"], 1) + self.assertEqual(final["optimization_level"], 3) + res_dict = final["resilience"] + self.assertFalse(res_dict["measure_noise_mitigation"]) + self.assertFalse(res_dict["zne_mitigation"]) + self.assertFalse(res_dict["pec_mitigation"]) diff --git a/test/utils.py b/test/utils.py index 5ae84b371..4ec9bc0af 100644 --- a/test/utils.py +++ b/test/utils.py @@ -254,4 +254,8 @@ def get_mocked_backend(name: str = "ibm_gotham") -> Any: mock_backend = mock.MagicMock(spec=IBMBackend) mock_backend.name = name mock_backend._instance = None + + mock_service = mock.MagicMock() + mock_backend.service = mock_service + return mock_backend From 989a321c704c69de82593817d870b94e36815f17 Mon Sep 17 00:00:00 2001 From: Jessie Yu Date: Fri, 27 Oct 2023 17:30:26 -0400 Subject: [PATCH 02/35] fix merge issues --- qiskit_ibm_runtime/base_primitive.py | 4 +- qiskit_ibm_runtime/options/options.py | 10 +- .../options/resilience_options.py | 50 ++---- .../options/twirling_options.py | 3 - test/unit/test_estimator.py | 147 ++++++++++++++++++ 5 files changed, 169 insertions(+), 45 deletions(-) diff --git a/qiskit_ibm_runtime/base_primitive.py b/qiskit_ibm_runtime/base_primitive.py index 4f5ba8311..3adc658b4 100644 --- a/qiskit_ibm_runtime/base_primitive.py +++ b/qiskit_ibm_runtime/base_primitive.py @@ -78,11 +78,9 @@ def __init__( self._backend = self._service.backend( name=self._session.backend(), instance=self._session._instance ) - return elif session is not None: raise ValueError("session must be of type Session or None") - - if isinstance(backend, IBMBackend): + elif isinstance(backend, IBMBackend): self._service = backend.service self._backend = backend elif isinstance(backend, str): diff --git a/qiskit_ibm_runtime/options/options.py b/qiskit_ibm_runtime/options/options.py index d2dfd25b2..0e29d0532 100644 --- a/qiskit_ibm_runtime/options/options.py +++ b/qiskit_ibm_runtime/options/options.py @@ -19,7 +19,7 @@ from qiskit.transpiler import CouplingMap -from .utils import _flexible, Dict, _remove_dict_none_values +from .utils import Dict, _to_obj, _remove_dict_none_values from .environment_options import EnvironmentOptions from .execution_options import ExecutionOptions from .simulator_options import SimulatorOptions @@ -124,6 +124,14 @@ class Options: "twirling": TwirlingOptions, } + def __post_init__(self): # type: ignore + """Convert dictionary fields to object.""" + obj_fields = getattr(self, "_obj_fields", {}) + for key in list(obj_fields): + if hasattr(self, key): + orig_val = getattr(self, key) + setattr(self, key, _to_obj(obj_fields[key], orig_val)) + @staticmethod def _get_program_inputs(options: dict) -> dict: """Convert the input options to program compatible inputs. diff --git a/qiskit_ibm_runtime/options/resilience_options.py b/qiskit_ibm_runtime/options/resilience_options.py index 5342961d4..606869ea1 100644 --- a/qiskit_ibm_runtime/options/resilience_options.py +++ b/qiskit_ibm_runtime/options/resilience_options.py @@ -32,8 +32,6 @@ "QuarticExtrapolator", ] - - ZneExtrapolatorType = Literal[ None, "exponential", @@ -55,10 +53,9 @@ class ResilienceOptions: Only applicable for ``resilience_level=2``. Default: (1, 3, 5) if resilience level is 2. Otherwise ``None``. - noise_amplifier (DEPRECATED): A noise amplification strategy. One of ``"TwoQubitAmplifier"``, - ``"GlobalFoldingAmplifier"``, ``"LocalFoldingAmplifier"``, ``"CxAmplifier"``. - Only applicable for ``resilience_level=2``. - Default: "TwoQubitAmplifier" if resilience level is 2. Otherwise ``None``. + noise_amplifier (DEPRECATED): A noise amplification strategy. Currently only + ``"LocalFoldingAmplifier"`` is supported Only applicable for ``resilience_level=2``. + Default: "LocalFoldingAmplifier". extrapolator (DEPRECATED): An extrapolation strategy. One of ``"LinearExtrapolator"``, ``"QuadraticExtrapolator"``, ``"CubicExtrapolator"``, ``"QuarticExtrapolator"``. @@ -135,42 +132,19 @@ def validate_resilience_options(resilience_options: dict) -> None: ValueError: if extrapolator == "CubicExtrapolator" and number of noise_factors < 4. TypeError: if an input value has an invalid type. """ - noise_amplifier = resilience_options.get("noise_amplifier") - if noise_amplifier is not None: - issue_deprecation_msg( - msg="The 'noise_amplifier' resilience option is deprecated", - version="0.12.0", - period="1 month", - remedy="After the deprecation period, only local folding amplification " - "will be supported. " - "Refer to https://github.com/qiskit-community/prototype-zne " - "for global folding amplification in ZNE.", - ) - if noise_amplifier not in get_args(NoiseAmplifierType): - raise ValueError( - f"Unsupported value {noise_amplifier} for noise_amplifier. " - f"Supported values are {get_args(NoiseAmplifierType)}" + noise_amplifier = resilience_options.get("noise_amplifier") or "LocalFoldingAmplifier" + if noise_amplifier not in get_args(NoiseAmplifierType): + raise ValueError( + f"Unsupported value {noise_amplifier} for noise_amplifier. " + f"Supported values are {get_args(NoiseAmplifierType)}" ) - if resilience_options.get("noise_factors", None) is not None: - deprecate_arguments( - deprecated="noise_factors", - version="0.13.0", - remedy="Please use 'zne_noise_factors' instead.", - ) - extrapolator = resilience_options.get("extrapolator") - if extrapolator is not None: - deprecate_arguments( - deprecated="extrapolator", - version="0.13.0", - remedy="Please use 'zne_extrapolator' instead.", + if extrapolator and extrapolator not in get_args(ExtrapolatorType): + raise ValueError( + f"Unsupported value {extrapolator} for extrapolator. " + f"Supported values are {get_args(ExtrapolatorType)}" ) - if extrapolator not in get_args(ExtrapolatorType): - raise ValueError( - f"Unsupported value {extrapolator} for extrapolator. " - f"Supported values are {get_args(ExtrapolatorType)}" - ) if ( extrapolator == "QuarticExtrapolator" diff --git a/qiskit_ibm_runtime/options/twirling_options.py b/qiskit_ibm_runtime/options/twirling_options.py index 13fbc9af4..5d14507f2 100644 --- a/qiskit_ibm_runtime/options/twirling_options.py +++ b/qiskit_ibm_runtime/options/twirling_options.py @@ -15,8 +15,6 @@ from typing import Literal, get_args from dataclasses import dataclass -from .utils import _flexible - TwirlingStrategyType = Literal[ None, @@ -27,7 +25,6 @@ ] -@_flexible @dataclass class TwirlingOptions: """Twirling options. diff --git a/test/unit/test_estimator.py b/test/unit/test_estimator.py index d8ffc5933..10c145033 100644 --- a/test/unit/test_estimator.py +++ b/test/unit/test_estimator.py @@ -23,6 +23,8 @@ from .mock.fake_runtime_service import FakeRuntimeService from ..ibm_test_case import IBMTestCase +from ..utils import get_mocked_backend +from .mock.fake_runtime_service import FakeRuntimeService class TestEstimator(IBMTestCase): @@ -49,3 +51,148 @@ def test_unsupported_values_for_estimator_options(self): with self.assertRaises(ValueError) as exc: _ = inst.run(self.circuit, observables=self.observables, **bad_opt) self.assertIn(list(bad_opt.keys())[0], str(exc.exception)) + + def test_observable_types_single_circuit(self): + """Test different observable types for a single circuit.""" + all_obs = [ + "IX", + Pauli("YZ"), + SparsePauliOp(["IX", "YZ"]), + {"YZ": 1 + 2j}, + {Pauli("XX"): 1 + 2j}, + random_hermitian((2, 2)), + [["XX", "YY"]], + [[Pauli("XX"), Pauli("YY")]], + [[SparsePauliOp(["XX"]), SparsePauliOp(["YY"])]], + [ + [ + {"XX": 1 + 2j}, + {"YY": 1 + 2j}, + ] + ], + [ + [ + {Pauli("XX"): 1 + 2j}, + {Pauli("YY"): 1 + 2j}, + ] + ], + [random_pauli_list(2, 2)], + ] + + circuit = QuantumCircuit(2) + estimator = Estimator(backend=get_mocked_backend()) + for obs in all_obs: + with self.subTest(obs=obs): + estimator.run(circuits=circuit, observables=obs) + + def test_observable_types_multi_circuits(self): + """Test different observable types for multiple circuits.""" + num_qx = 2 + all_obs = [ + ["XX", "YY"], + [Pauli("XX"), Pauli("YY")], + [SparsePauliOp(["XX"]), SparsePauliOp(["YY"])], + [ + {"XX": 1 + 2j}, + {"YY": 1 + 2j}, + ], + [ + {Pauli("XX"): 1 + 2j}, + {Pauli("YY"): 1 + 2j}, + ], + [["XX", "YY"]] * num_qx, + [[Pauli("XX"), Pauli("YY")]] * num_qx, + [[SparsePauliOp(["XX"]), SparsePauliOp(["YY"])]] * num_qx, + [[{"XX": 1 + 2j}, {"YY": 1 + 2j}]] * num_qx, + [[{Pauli("XX"): 1 + 2j}, {Pauli("YY"): 1 + 2j}]] * num_qx, + [random_pauli_list(2, 2)] * num_qx, + ] + + circuit = QuantumCircuit(2) + estimator = Estimator(backend=get_mocked_backend()) + for obs in all_obs: + with self.subTest(obs=obs): + estimator.run(circuits=[circuit] * num_qx, observables=obs) + + def test_invalid_basis(self): + """Test observable containing invalid basis.""" + all_obs = [ + "JJ", + {"JJ": 1 + 2j}, + [["0J", "YY"]], + [ + [ + {"XX": 1 + 2j}, + {"JJ": 1 + 2j}, + ] + ], + ] + + circuit = QuantumCircuit(2) + estimator = Estimator(backend=get_mocked_backend()) + for obs in all_obs: + with self.subTest(obs=obs): + with self.assertRaises(ValueError): + estimator.run(circuits=circuit, observables=obs) + + def test_single_parameter_single_circuit(self): + """Test single parameter for a single cirucit.""" + theta = Parameter("θ") + circuit = QuantumCircuit(2) + circuit.rz(theta, 0) + + param_vals = [ + np.pi, + [np.pi], + [[np.pi]], + np.array([np.pi]), + np.array([[np.pi]]), + [np.array([np.pi])], + [[[np.pi], [np.pi / 2]]], + {theta: np.pi}, + [{theta: np.pi}], + ] + + estimator = Estimator(backend=get_mocked_backend()) + for val in param_vals: + with self.subTest(val=val): + estimator.run(circuits=circuit, observables="ZZ", parameter_values=val) + + def test_multiple_parameters_single_circuit(self): + """Test multiple parameters for a single circuit.""" + theta = Parameter("θ") + circuit = QuantumCircuit(2) + circuit.rz(theta, [0, 1]) + + param_vals = [ + [[np.pi, np.pi]], + np.array([[np.pi, np.pi]]), + [np.array([np.pi, np.pi])], + [[[np.pi, np.pi], [np.pi / 2, np.pi / 2]]], + {theta: [np.pi, np.pi / 2]}, + {theta: [[np.pi, np.pi / 2], [np.pi / 4, np.pi / 8]]}, + [{theta: [np.pi, np.pi / 2]}], + ] + + estimator = Estimator(backend=get_mocked_backend()) + for val in param_vals: + with self.subTest(val=val): + estimator.run(circuits=circuit, observables="ZZ", parameter_values=val) + + def test_multiple_parameters_multiple_circuits(self): + """Test multiple parameters for multiple circuits.""" + theta = Parameter("θ") + circuit = QuantumCircuit(2) + circuit.rz(theta, [0, 1]) + + param_vals = [ + [[np.pi, np.pi], [0.5, 0.5]], + [np.array([np.pi, np.pi]), np.array([0.5, 0.5])], + [[[np.pi, np.pi], [np.pi / 2, np.pi / 2]], [[0.5, 0.5], [0.1, 0.1]]], + [{theta: [[np.pi, np.pi / 2], [np.pi / 4, np.pi / 8]]}, {theta: [0.5, 0.5]}], + ] + + estimator = Estimator(backend=get_mocked_backend()) + for val in param_vals: + with self.subTest(val=val): + estimator.run(circuits=[circuit] * 2, observables=["ZZ"] * 2, parameter_values=val) From 5ab8a93e4281d6d61be5c9bf761c71bcf2dfd70d Mon Sep 17 00:00:00 2001 From: Jessie Yu Date: Mon, 30 Oct 2023 16:55:41 -0400 Subject: [PATCH 03/35] add pydantic --- qiskit_ibm_runtime/options/options.py | 30 +++++++++++++++++++++++++++ setup.py | 1 + 2 files changed, 31 insertions(+) diff --git a/qiskit_ibm_runtime/options/options.py b/qiskit_ibm_runtime/options/options.py index 0e29d0532..814e51f18 100644 --- a/qiskit_ibm_runtime/options/options.py +++ b/qiskit_ibm_runtime/options/options.py @@ -18,6 +18,8 @@ import warnings from qiskit.transpiler import CouplingMap +from pydantic.dataclasses import dataclass as pydantic_dataclass +from pydantic import Field from .utils import Dict, _to_obj, _remove_dict_none_values from .environment_options import EnvironmentOptions @@ -31,6 +33,34 @@ DDSequenceType = Literal[None, "XX", "XpXm", "XY4"] +@pydantic_dataclass +class PrimitiveOptions: + """Base primitive options. + + Args: + max_execution_time: Maximum execution time in seconds, which is based + on system execution time (not wall clock time). System execution time is + the amount of time that the system is dedicated to processing your job. + If a job exceeds this time limit, it is forcibly cancelled. + Simulator jobs continue to use wall clock time. + + Refer to the + `Max execution time documentation + `_. + for more information. + + environment: Options related to the execution environment. See + :class:`EnvironmentOptions` for all available options. + + simulator: Simulator options. See + :class:`SimulatorOptions` for all available options. + """ + + max_execution_time: Optional[int] = None + environment: Union[EnvironmentOptions, Dict] = Field(default_factory=EnvironmentOptions) + simulator: Union[SimulatorOptions, Dict] = Field(default_factory=SimulatorOptions) + + @dataclass class Options: """Options for the primitives. diff --git a/setup.py b/setup.py index e89854899..4ed437259 100644 --- a/setup.py +++ b/setup.py @@ -28,6 +28,7 @@ "websocket-client>=1.5.1", "ibm-platform-services>=0.22.6", "qiskit-ibm-provider>=0.7.0", + "pydantic", ] # Handle version. From 11c30ccbbe07a163b01e5f50cbdfe2775fe0df1f Mon Sep 17 00:00:00 2001 From: Jessie Yu Date: Mon, 30 Oct 2023 16:56:48 -0400 Subject: [PATCH 04/35] black --- qiskit_ibm_runtime/options/resilience_options.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/qiskit_ibm_runtime/options/resilience_options.py b/qiskit_ibm_runtime/options/resilience_options.py index 606869ea1..5f3302f88 100644 --- a/qiskit_ibm_runtime/options/resilience_options.py +++ b/qiskit_ibm_runtime/options/resilience_options.py @@ -43,6 +43,7 @@ "polynomial_degree_4", ] + @dataclass class ResilienceOptions: """Resilience options. @@ -137,7 +138,7 @@ def validate_resilience_options(resilience_options: dict) -> None: raise ValueError( f"Unsupported value {noise_amplifier} for noise_amplifier. " f"Supported values are {get_args(NoiseAmplifierType)}" - ) + ) extrapolator = resilience_options.get("extrapolator") if extrapolator and extrapolator not in get_args(ExtrapolatorType): From e6ef47f92a0c00c8597102c3955bc50b48e6846c Mon Sep 17 00:00:00 2001 From: Jessie Yu Date: Mon, 30 Oct 2023 17:11:04 -0400 Subject: [PATCH 05/35] lint --- qiskit_ibm_runtime/options/resilience_options.py | 2 -- qiskit_ibm_runtime/options/utils.py | 1 - test/unit/test_estimator.py | 1 - 3 files changed, 4 deletions(-) diff --git a/qiskit_ibm_runtime/options/resilience_options.py b/qiskit_ibm_runtime/options/resilience_options.py index 5f3302f88..1c66e6645 100644 --- a/qiskit_ibm_runtime/options/resilience_options.py +++ b/qiskit_ibm_runtime/options/resilience_options.py @@ -15,8 +15,6 @@ from typing import Sequence, Literal, get_args, Union from dataclasses import dataclass -from ..utils.deprecation import issue_deprecation_msg - ResilienceSupportedOptions = Literal[ "noise_amplifier", "noise_factors", diff --git a/qiskit_ibm_runtime/options/utils.py b/qiskit_ibm_runtime/options/utils.py index 30eeaf191..0968c0ace 100644 --- a/qiskit_ibm_runtime/options/utils.py +++ b/qiskit_ibm_runtime/options/utils.py @@ -13,7 +13,6 @@ """Utility functions for options.""" from typing import Optional -from dataclasses import fields, field, make_dataclass from ..ibm_backend import IBMBackend diff --git a/test/unit/test_estimator.py b/test/unit/test_estimator.py index 10c145033..7d29a9df4 100644 --- a/test/unit/test_estimator.py +++ b/test/unit/test_estimator.py @@ -24,7 +24,6 @@ from .mock.fake_runtime_service import FakeRuntimeService from ..ibm_test_case import IBMTestCase from ..utils import get_mocked_backend -from .mock.fake_runtime_service import FakeRuntimeService class TestEstimator(IBMTestCase): From 3e0b4afa3772105d040e39ba45f35573af4a765f Mon Sep 17 00:00:00 2001 From: Jessie Yu Date: Tue, 31 Oct 2023 13:39:33 -0400 Subject: [PATCH 06/35] Fast forward experimental to latest main (#1178) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Remove min execution time check (#1065) * Remove min execution time check * update unit test * remove integration test * Update `max_execution_time` docstrings (#1059) * Update max_execution_time docstrings * add commas * Update qiskit_ibm_runtime/options/options.py Co-authored-by: Rebecca Dimock <66339736+beckykd@users.noreply.github.com> * Update qiskit_ibm_runtime/options/options.py Co-authored-by: Rebecca Dimock <66339736+beckykd@users.noreply.github.com> * Update qiskit_ibm_runtime/runtime_options.py Co-authored-by: Rebecca Dimock <66339736+beckykd@users.noreply.github.com> * Update releasenotes/notes/max-execution-time-definition-196cb6297693c0f2.yaml Co-authored-by: Rebecca Dimock <66339736+beckykd@users.noreply.github.com> --------- Co-authored-by: Rebecca Dimock <66339736+beckykd@users.noreply.github.com> * Removed remaining code related to Schedules (#1068) Co-authored-by: Kevin Tian * Enable datetime parameter for backend properties (#1070) * enable datetime param for backend properties * add test & reno * improve test * Update default resilience options (#1062) * remove default resilience options * add reno * add logic to override default * add test * purge None values from options * fix test (#1074) * Prepare release 0.12.1 (#1075) * Update main branch veresion 0.12.2 (#1076) * use ibmq_qasm_simulator (#1078) * Add reason code to error message (#1072) * Add reason code to error message * add reno * Remove importing PauliSumOp, which is deprecated. (#1079) --------- Co-authored-by: Jake Lishman Co-authored-by: Kevin Tian * Adjusts default value for optimization_level (#1082) * add instance info (#1083) * add instance info * Add cloud note * Update README.md Co-authored-by: Kevin Tian --------- Co-authored-by: Kevin Tian * Fix links to options in README.md (#1084) * Remove auth parameter (#1077) * Removed auth parameter * Removed calls to migrate() * black and lint --------- Co-authored-by: Kevin Tian * Remove opflow and algorithms from serialization tests (#1085) * Remove opflow from tests * Re-add test for PauliSumOp * Fix lint * Fix black --------- Co-authored-by: Kevin Tian * RuntimeJobTimeoutError should inherit from JobTimeoutError (#1090) * RuntimeJobTimeoutError inherits from JobTimeoutError * black --------- Co-authored-by: Kevin Tian * Allow user to define a default account as an environment variable (#1018) * Allow user to define a default account as an environment variable * Fixed test * Fixed mistaken paste * Cleaned up test * Moved test to TestAccountManager * Added ability to define default channel in save_account * Cleaned up code, fixed bugs * Changed name of parameter * Added test. Cleaned up code surrounding preferences of channel selection * black and lint * Fixed bug when json file was empty * Code cleanup and documentation * Documentation * Removed channel from condition, because unnecessary * changed default_channel to default_account * Changed saving and getting default channel to default account * black * Documentation * Release notes * Reverted diff that was unnecessary --------- Co-authored-by: Kevin Tian * Skip test_job_logs (#1094) * Update session max_time docstring (#1089) * Update session max_time docstring * Update qiskit_ibm_runtime/session.py Co-authored-by: Rebecca Dimock <66339736+beckykd@users.noreply.github.com> --------- Co-authored-by: Rebecca Dimock <66339736+beckykd@users.noreply.github.com> * Fix unit tests against qiskit/main (#1099) * Add measurements to sampler * Remove observables from sampler run --------- Co-authored-by: Kevin Tian * fix iqp link (#1096) Co-authored-by: Kevin Tian * Only return channel strategy supported backends (#1095) * Q-CTRL Backend filters * add reno * fix unit tests * Attempt to fix terra unit tests (#1102) * Attempt to fix terra unit tests * test on my fork * revert coder_qc change * Making account code more generic (#1060) * Making account code more generic by defining subclasses for channel types * Removed channel parameter from _assert_valid_instance * mypy, lint and black * Changed order of decorators * Code cleanup * Documentation fixes * black --------- Co-authored-by: Kevin Tian * Remove old deprecations (#1106) * removing old deprecations * update unit tests * Open plan updates (#1105) * Initial edits * edit * Update docs/faqs/max_execution_time.rst * Update docs/faqs/max_execution_time.rst * Update docs/faqs/max_execution_time.rst * Update docs/faqs/max_execution_time.rst * Update docs/faqs/max_execution_time.rst * Update docs/faqs/max_execution_time.rst Co-authored-by: Jessie Yu * Jessie comments * Update docs/faqs/max_execution_time.rst Co-authored-by: Jessie Yu * Update docs/faqs/max_execution_time.rst Co-authored-by: Jessie Yu * Update docs/sessions.rst * Update docs/faqs/max_execution_time.rst --------- Co-authored-by: Jessie Yu * New method to create a new Session object with a given id (#1101) * Added the Session.from_id method * release notes * Added integration test --------- Co-authored-by: Kevin Tian * fix test_session_from_id (#1110) * Warn users if job submitted will exceed quota (#1100) * Warn users if job will exceed quota * update reno * update reno again * Removed support for backend as session parameter (#1091) Co-authored-by: Kevin Tian * Fix minor todos (#1112) * Change qpu complex wording (#1109) * Don't use QPU complex * don't use the word 'limit' * Update docs/faqs/max_execution_time.rst * Update qiskit_ibm_runtime/options/options.py * Update qiskit_ibm_runtime/runtime_job.py * Update releasenotes/notes/0.11/job-cost-estimation-d0ba83dbc95c3f67.yaml * Clarify usage * reclarify reset time * rogue comma * fix whitespace & formatting * job -> system execution time --------- Co-authored-by: Kevin Tian * Add IBM Cloud channel (#1113) * Changes for #1806 * Update docs/faqs/max_execution_time.rst Co-authored-by: Jessie Yu --------- Co-authored-by: Kevin Tian Co-authored-by: Jessie Yu Co-authored-by: Kevin Tian * Prepare release 0.12.2 (#1115) * Update main branch 0.13.0 (#1116) * remove error message test (#1125) * Exceptions should inherit from Terra where suitable (#1120) * Changed RuntimeJobFailureError to inherit from JobError * Changed error type --------- Co-authored-by: Kevin Tian * update docstring & remove max_time (#1137) * Expose new session details (#1119) * add session details method * add reno * support iqp urls * update details mehotd, add status() * update status() to use enum * revert previous change, wait for impl details * update fields returned * update status method * update docstring & reno * update docstrings for both methods * fix docs build * address comments * fix docs build * fix typo * docs build again * fix indent * fix indent again * fix indent 3rd time * Support only LocalFoldingAmplifier as noise_amplifier option (#1093) * Removed support for all noise_amplifier options other than LocalFoldingAmplifier. Removed deprecation warning. * Removed tests that covered deprecation. Updated documentation --------- Co-authored-by: Kevin Tian * Fix target_history date bug (#1143) * fix target_history datetime bug * add reno * add test * Fixed bug when defining shots as int64 (#1151) * Log instance on initialization and when running a job (#1150) * log instances * add _default_instance & fix lint * add test * change var name to current_instance * Move methods into class pages for docs (#1144) * Fix link to `Close a session` (#1152) [1] links to [2] but doesn't go directly to the target section. [1] https://qiskit.org/ecosystem/ibm-runtime/sessions.html#what-happens-when-a-session-ends [2] https://qiskit.org/ecosystem/ibm-runtime/how_to/run_session.html#close-a-session Co-authored-by: Kevin Tian * logging instance test is IQP channel only (#1154) * Update deploy yml (#1148) * Allow users to indicate they are done submitting jobs to a session (#1139) * copy changes over * clean up branch again * address comments, update docstrings * catch appropriate error code * update status code to 404 * Update qiskit_ibm_runtime/session.py Co-authored-by: Jessie Yu * set self._active & remove runtime_session * Update releasenotes/notes/session-accepting-jobs-d7ef6b60c0f5527b.yaml Co-authored-by: Jessie Yu --------- Co-authored-by: Jessie Yu * Prepare release 0.13 (#1157) * Update main branch 0.13.1 (#1158) * Update Sphinx theme (#1156) Co-authored-by: Kevin Tian * Added IBM Quantum logo (#1164) * sessions changes (#1169) * sessions changes * add more ticks * fix link * fix links * Update docs/sessions.rst Co-authored-by: abbycross * Update docs/sessions.rst Co-authored-by: abbycross --------- Co-authored-by: abbycross * Disallow unsupported options (#1108) * Disallow unsupported options * Moved checking of unsupported options to 'flexible' decorator * Modified the test to give TypeError where needed * Removed empty newline * Moved tests from test_ibm_primitives to test_options, because they don't require a primitive * typo * Release note * black and lint * black again * Fixed test failing in CI * Removed _flexible decorator. Moved _post_init into Options class * lint * lint * Fixed bug * lint --------- Co-authored-by: Kevin Tian * fix merge issues * black * lint --------- Co-authored-by: Kevin Tian Co-authored-by: Rebecca Dimock <66339736+beckykd@users.noreply.github.com> Co-authored-by: merav-aharoni Co-authored-by: Luciano Bello Co-authored-by: Jake Lishman Co-authored-by: Esteban Ginez <175813+eginez@users.noreply.github.com> Co-authored-by: Kevin Tian Co-authored-by: mberna Co-authored-by: Elena Peña Tapia <57907331+ElePT@users.noreply.github.com> Co-authored-by: Arnau Casau <47946624+arnaucasau@users.noreply.github.com> Co-authored-by: Matt Riedemann Co-authored-by: abbycross --- .github/workflows/deploy.yml | 19 +- README.md | 60 ++- docs/_templates/autosummary/class.rst | 10 +- docs/cloud/cost.rst | 2 +- docs/conf.py | 18 +- docs/faqs/max_execution_time.rst | 50 +- docs/how_to/backends.rst | 8 +- docs/how_to/error-mitigation.rst | 19 +- docs/how_to/error-suppression.rst | 2 - docs/how_to/run_session.rst | 192 +++++++- docs/images/ibm-quantum-logo.png | Bin 0 -> 45708 bytes docs/index.rst | 2 +- docs/sessions.rst | 133 ++++-- qiskit_ibm_runtime/VERSION.txt | 2 +- qiskit_ibm_runtime/__init__.py | 2 - qiskit_ibm_runtime/accounts/account.py | 241 +++++++--- qiskit_ibm_runtime/accounts/management.py | 139 +++--- qiskit_ibm_runtime/accounts/storage.py | 22 +- qiskit_ibm_runtime/api/clients/runtime.py | 33 +- qiskit_ibm_runtime/api/rest/base.py | 57 +++ qiskit_ibm_runtime/api/rest/cloud_backend.py | 12 +- qiskit_ibm_runtime/api/rest/runtime.py | 16 +- .../api/rest/runtime_session.py | 63 +++ qiskit_ibm_runtime/base_primitive.py | 36 +- qiskit_ibm_runtime/estimator.py | 8 +- qiskit_ibm_runtime/exceptions.py | 5 +- qiskit_ibm_runtime/ibm_backend.py | 19 +- .../options/environment_options.py | 3 - .../options/execution_options.py | 2 - qiskit_ibm_runtime/options/options.py | 66 ++- .../options/resilience_options.py | 55 +-- .../options/simulator_options.py | 9 +- .../options/transpilation_options.py | 2 - .../options/twirling_options.py | 3 - qiskit_ibm_runtime/options/utils.py | 44 -- qiskit_ibm_runtime/program/program_backend.py | 7 +- qiskit_ibm_runtime/qiskit_runtime_service.py | 72 ++- qiskit_ibm_runtime/runtime_job.py | 14 +- qiskit_ibm_runtime/runtime_options.py | 7 +- qiskit_ibm_runtime/runtime_session.py | 146 ------ qiskit_ibm_runtime/sampler.py | 4 - qiskit_ibm_runtime/session.py | 129 ++++-- qiskit_ibm_runtime/utils/backend_converter.py | 2 +- qiskit_ibm_runtime/utils/json.py | 2 + qiskit_ibm_runtime/utils/qctrl.py | 12 +- .../job-cost-estimation-d0ba83dbc95c3f67.yaml | 4 +- ...-properties-datetime-0fe6a364c0a291d2.yaml | 6 + ...ategy-backend-filter-a4fe5248d9aea9c1.yaml | 6 + ...ata-tracking-updates-97327c62c51b5891.yaml | 0 ...ult-channel-strategy-6899049ad4a7321b.yaml | 0 ...t-resilience-options-7929458af000314f.yaml | 10 + .../default_account-13d86d50f5b1d972.yaml | 6 + .../0.12/error-codes-82a392efad5963da.yaml | 5 + .../error-message-case-31b4b2b7a5a2f624.yaml | 0 .../notes/0.12/from_id-23fc85f3fbf01e0b.yaml | 6 + .../job-quota-warning-0512f30571897f53.yaml | 7 + ...tion-time-definition-196cb6297693c0f2.yaml | 7 + .../q-ctrl-validation-08d249f1e84a43a5.yaml | 0 ...pose-session-details-c4a44316d30dad33.yaml | 10 + .../0.13/fix_np_int64-864b605a88f57419.yaml | 5 + ...og-instance-selected-a18c4791418b5e0d.yaml | 7 + ...ssion-accepting-jobs-d7ef6b60c0f5527b.yaml | 13 + ...get-history-date-bug-7d6dad84fc5b3d2e.yaml | 6 + ...emove_kwargs_options-9024d3ec6572a53e.yaml | 4 + requirements-dev.txt | 4 +- test/account.py | 3 + test/integration/test_account.py | 13 + test/integration/test_backend.py | 15 +- test/integration/test_estimator.py | 18 - test/integration/test_job.py | 7 +- test/integration/test_options.py | 12 +- test/integration/test_sampler.py | 19 - test/integration/test_session.py | 13 + test/unit/mock/fake_runtime_client.py | 9 +- test/unit/test_account.py | 428 ++++++++---------- test/unit/test_data_serialization.py | 132 ++---- test/unit/test_estimator.py | 28 +- test/unit/test_ibm_primitives.py | 96 ++-- test/unit/test_logger.py | 1 - test/unit/test_options.py | 79 +--- test/unit/test_sampler.py | 5 +- test/unit/test_session.py | 23 +- test/utils.py | 3 +- 83 files changed, 1482 insertions(+), 1277 deletions(-) create mode 100644 docs/images/ibm-quantum-logo.png create mode 100644 qiskit_ibm_runtime/api/rest/base.py create mode 100644 qiskit_ibm_runtime/api/rest/runtime_session.py delete mode 100644 qiskit_ibm_runtime/runtime_session.py create mode 100644 releasenotes/notes/0.12/backend-properties-datetime-0fe6a364c0a291d2.yaml create mode 100644 releasenotes/notes/0.12/channel-strategy-backend-filter-a4fe5248d9aea9c1.yaml rename releasenotes/notes/{ => 0.12}/data-tracking-updates-97327c62c51b5891.yaml (100%) rename releasenotes/notes/{ => 0.12}/default-channel-strategy-6899049ad4a7321b.yaml (100%) create mode 100644 releasenotes/notes/0.12/default-resilience-options-7929458af000314f.yaml create mode 100644 releasenotes/notes/0.12/default_account-13d86d50f5b1d972.yaml create mode 100644 releasenotes/notes/0.12/error-codes-82a392efad5963da.yaml rename releasenotes/notes/{ => 0.12}/error-message-case-31b4b2b7a5a2f624.yaml (100%) create mode 100644 releasenotes/notes/0.12/from_id-23fc85f3fbf01e0b.yaml create mode 100644 releasenotes/notes/0.12/job-quota-warning-0512f30571897f53.yaml create mode 100644 releasenotes/notes/0.12/max-execution-time-definition-196cb6297693c0f2.yaml rename releasenotes/notes/{ => 0.12}/q-ctrl-validation-08d249f1e84a43a5.yaml (100%) create mode 100644 releasenotes/notes/0.13/expose-session-details-c4a44316d30dad33.yaml create mode 100644 releasenotes/notes/0.13/fix_np_int64-864b605a88f57419.yaml create mode 100644 releasenotes/notes/0.13/log-instance-selected-a18c4791418b5e0d.yaml create mode 100644 releasenotes/notes/0.13/session-accepting-jobs-d7ef6b60c0f5527b.yaml create mode 100644 releasenotes/notes/0.13/target-history-date-bug-7d6dad84fc5b3d2e.yaml create mode 100644 releasenotes/notes/remove_kwargs_options-9024d3ec6572a53e.yaml diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 1acdafa99..f79b2ba33 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -20,6 +20,9 @@ on: jobs: Deploy: runs-on: ubuntu-latest + environment: release + permissions: + id-token: write strategy: matrix: python-version: [3.9] @@ -28,12 +31,12 @@ jobs: - uses: actions/setup-python@v2 with: python-version: ${{ matrix.python-version }} + - name: Install deps + run: pip install -U pip setuptools build + - name: Build sdist + run: python3 -m build + - uses: actions/upload-artifact@v3 + with: + path: ./dist/* - name: Deploy to Pypi - env: - TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} - TWINE_USERNAME: qiskit - run : | - pip install -U twine pip setuptools virtualenv wheel - python3 setup.py sdist bdist_wheel - twine upload dist/qiskit* - shell: bash \ No newline at end of file + uses: pypa/gh-action-pypi-publish@release/v1 \ No newline at end of file diff --git a/README.md b/README.md index 892e7d493..5bbb1e37d 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,8 @@ **Qiskit** is an open-source SDK for working with quantum computers at the level of extended quantum circuits, operators, and primitives. -**Qiskit IBM Runtime** is a new environment offered by IBM Quantum that streamlines quantum computations and provides optimal -implementations of the Qiskit primitives `sampler` and `estimator` for IBM Quantum hardware. It is designed to use additional classical compute resources to execute quantum circuits with more efficiency on quantum processors, by including near-time computations such as error suppression and error mitigation. Examples of error suppression include dynamical decoupling, noise-aware compilation, error mitigation including readout mitigation, zero-noise extrapolation (ZNE), and probabilistic error cancellation (PEC). +**Qiskit IBM Runtime** is a new environment offered by IBM Quantum that streamlines quantum computations and provides optimal +implementations of the Qiskit primitives `sampler` and `estimator` for IBM Quantum hardware. It is designed to use additional classical compute resources to execute quantum circuits with more efficiency on quantum processors, by including near-time computations such as error suppression and error mitigation. Examples of error suppression include dynamical decoupling, noise-aware compilation, error mitigation including readout mitigation, zero-noise extrapolation (ZNE), and probabilistic error cancellation (PEC). Using the runtime service, a research team at IBM Quantum was able to achieve a 120x speedup in their lithium hydride simulation. For more information, see the @@ -30,8 +30,6 @@ pip install qiskit-ibm-runtime ### Qiskit Runtime service on IBM Quantum Platform -The default method for using the runtime service is IBM Quantum Platform. - You will need your IBM Quantum API token to authenticate with the runtime service: 1. Create an IBM Quantum account or log in to your existing account by visiting the [IBM Quantum login page]. @@ -114,7 +112,7 @@ All quantum applications and algorithms level are fundamentally built using thre The IBM Runtime service offers these primitives with additional features, such as built-in error suppression and mitigation. -There are several different options you can specify when calling the primitives. See [`qiskit_ibm_runtime.Options`](https://github.com/Qiskit/qiskit-ibm-runtime/blob/main/qiskit_ibm_runtime/options.py#L103) class for more information. +There are several different options you can specify when calling the primitives. See [`qiskit_ibm_runtime.Options`](https://github.com/Qiskit/qiskit-ibm-runtime/blob/main/qiskit_ibm_runtime/options/options.py#L33) class for more information. ### Sampler @@ -138,7 +136,7 @@ bell.cx(0, 1) # 2. Map the qubits to a classical register in ascending order bell.measure_all() -# 3. Execute using the Sampler primitive +# 3. Execute using the Sampler primitive backend = service.get_backend('ibmq_qasm_simulator') sampler = Sampler(backend=backend, options=options) job = sampler.run(circuits=bell) @@ -174,14 +172,14 @@ qc_example.cx(0, 2) # condition 2nd qubit on 0th qubit # 2. the observable to be measured M1 = SparsePauliOp.from_list([("XXY", 1), ("XYX", 1), ("YXX", 1), ("YYY", -1)]) -# batch of theta parameters to be executed +# batch of theta parameters to be executed points = 50 theta1 = [] for x in range(points): theta = [x*2.0*np.pi/50] theta1.append(theta) -# 3. Execute using the Estimator primitive +# 3. Execute using the Estimator primitive backend = service.get_backend('ibmq_qasm_simulator') estimator = Estimator(backend, options=options) job = estimator.run(circuits=[qc_example]*points, observables=[M1]*points, parameter_values=theta1) @@ -197,7 +195,7 @@ This code batches together 50 parameters to be executed in a single job. If a us In many algorithms and applications, an Estimator needs to be called iteratively without incurring queuing delays on each iteration. To solve this, the IBM Runtime service provides a **Session**. A session starts when the first job within the session is started, and subsequent jobs within the session are prioritized by the scheduler. You can use the [`qiskit_ibm_runtime.Session`](https://github.com/Qiskit/qiskit-ibm-runtime/blob/main/qiskit_ibm_runtime/session.py) class to start a -session. Consider the same example above and try to find the optimal `theta`. The following example uses the [golden search method](https://en.wikipedia.org/wiki/Golden-section_search) to iteratively find the optimal theta that maximizes the observable. +session. Consider the same example above and try to find the optimal `theta`. The following example uses the [golden search method](https://en.wikipedia.org/wiki/Golden-section_search) to iteratively find the optimal theta that maximizes the observable. To invoke the `Estimator` primitive within a session: @@ -224,15 +222,15 @@ qc_example.cx(0, 2) # condition 2nd qubit on 0th qubit M1 = SparsePauliOp.from_list([("XXY", 1), ("XYX", 1), ("YXX", 1), ("YYY", -1)]) -gr = (np.sqrt(5) + 1) / 2 # golden ratio +gr = (np.sqrt(5) + 1) / 2 # golden ratio thetaa = 0 # lower range of theta thetab = 2*np.pi # upper range of theta -tol = 1e-1 # tol +tol = 1e-1 # tol -# 3. Execute iteratively using the Estimator primitive +# 3. Execute iteratively using the Estimator primitive with Session(service=service, backend="ibmq_qasm_simulator") as session: estimator = Estimator(session=session, options=options) - #next test range + #next test range thetac = thetab - (thetab - thetaa) / gr thetad = thetaa + (thetab - thetaa) / gr while abs(thetab - thetaa) > tol: @@ -245,8 +243,8 @@ with Session(service=service, backend="ibmq_qasm_simulator") as session: thetaa = thetac thetac = thetab - (thetab - thetaa) / gr thetad = thetaa + (thetab - thetaa) / gr - - # Final job to evaluate Estimator at midpoint found using golden search method + + # Final job to evaluate Estimator at midpoint found using golden search method theta_mid = (thetab + thetaa) / 2 job = estimator.run(circuits=qc_example, observables=M1, parameter_values=theta_mid) print(f"Session ID is {session.session_id}") @@ -256,6 +254,38 @@ with Session(service=service, backend="ibmq_qasm_simulator") as session: This code returns `Job result is [4.] at theta = 1.575674623307102` using only nine iterations. This is a very powerful extension to the primitives. However, using too much code between iterative calls can lock the QPU and use excessive QPU time, which is expensive. We recommend only using sessions when needed. The Sampler can also be used within a session, but there are not any well-defined examples for this. +## Instances + +Access to IBM Quantum Platform channel is controlled by the instances (previously called providers) to which you are assigned. An instance is defined by a hierarchical organization of hub, group, and project. A hub is the top level of a given hierarchy (organization) and contains within it one or more groups. These groups are in turn populated with projects. The combination of hub/group/project is called an instance. Users can belong to more than one instance at any time. + +> **_NOTE:_** IBM Cloud instances are different from IBM Quantum Platform instances. IBM Cloud does not use the hub/group/project structure for user management. To view and create IBM Cloud instances, visit the [IBM Cloud Quantum Instances page](https://cloud.ibm.com/quantum/instances). + +To view a list of your instances, visit your [account settings page](https://www.quantum-computing.ibm.com/account) or use the `instances()` method. + +You can specify an instance when initializing the service or provider, or when picking a backend: + +```python +# Optional: List all the instances you can access. +service = QiskitRuntimeService(channel='ibm_quantum') +print(service.instances()) + +# Optional: Specify the instance at service level. This becomes the default unless overwritten. +service = QiskitRuntimeService(channel='ibm_quantum', instance="hub1/group1/project1") +backend1 = service.backend("ibmq_manila") + +# Optional: Specify the instance at the backend level, which overwrites the service-level specification when this backend is used. +backend2 = service.backend("ibmq_manila", instance="hub2/group2/project2") + +sampler1 = Sampler(backend=backend1) # this will use hub1/group1/project1 +sampler2 = Sampler(backend=backend2) # this will use hub2/group2/project2 +``` + +If you do not specify an instance, then the code will select one in the following order: + +1. If your account only has access to one instance, it is selected by default. +2. If your account has access to multiple instances, but only one can access the requested backend, the instance with access is selected. +3. In all other cases, the code selects the first instance other than ibm-q/open/main that has access to the backend. + ## Access your IBM Quantum backends A **backend** is a quantum device or simulator capable of running quantum circuits or pulse schedules. diff --git a/docs/_templates/autosummary/class.rst b/docs/_templates/autosummary/class.rst index e4d661a00..6f917320a 100644 --- a/docs/_templates/autosummary/class.rst +++ b/docs/_templates/autosummary/class.rst @@ -17,11 +17,9 @@ .. rubric:: Attributes - .. autosummary:: - :toctree: ../stubs/ {% for item in all_attributes %} {%- if not item.startswith('_') %} - {{ name }}.{{ item }} + .. autoattribute:: {{ name }}.{{ item }} {%- endif -%} {%- endfor %} {% endif %} @@ -32,16 +30,14 @@ .. rubric:: Methods - .. autosummary:: - :toctree: ../stubs/ {% for item in all_methods %} {%- if not item.startswith('_') or item in ['__call__', '__mul__', '__getitem__', '__len__'] %} - {{ name }}.{{ item }} + .. automethod:: {{ name }}.{{ item }} {%- endif -%} {%- endfor %} {% for item in inherited_members %} {%- if item in ['__call__', '__mul__', '__getitem__', '__len__'] %} - {{ name }}.{{ item }} + .. automethod:: {{ name }}.{{ item }} {%- endif -%} {%- endfor %} diff --git a/docs/cloud/cost.rst b/docs/cloud/cost.rst index 6c43de223..b313daa53 100644 --- a/docs/cloud/cost.rst +++ b/docs/cloud/cost.rst @@ -8,7 +8,7 @@ Time limits on programs The maximum execution time for the Sampler primitive is 10000 seconds (2.78 hours). The maximum execution time for the Estimator primitive is 18000 seconds (5 hours). -Additionally, the system limit on the job execution time is 3 hours for a job that is running on a simulator and 8 hours for a job running on a physical system. +Additionally, the system limit on the system execution time is 3 hours for a job that is running on a simulator and 8 hours for a job running on a physical system. How to limit your cost *********************** diff --git a/docs/conf.py b/docs/conf.py index 653be22c2..9c89d3c49 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -42,7 +42,7 @@ # The short X.Y version version = '' # The full version, including alpha/beta/rc tags -release = '0.12.1' +release = '0.13.1' docs_url_prefix = "ecosystem/ibm-runtime" @@ -146,18 +146,20 @@ # -- Options for HTML output ------------------------------------------------- -html_theme = 'qiskit_sphinx_theme' +html_theme = "qiskit-ecosystem" +html_title = f"{project} {release}" -html_logo = 'images/logo.png' -html_last_updated_fmt = '%Y/%m/%d' +html_logo = "images/ibm-quantum-logo.png" html_theme_options = { - 'logo_only': True, - 'display_version': True, - 'prev_next_buttons_location': 'bottom', - 'style_external_links': True, + # Because this is an IBM-focused project, we use a blue color scheme. + "light_css_variables": { + "color-brand-primary": "var(--qiskit-color-blue)", + }, } +html_last_updated_fmt = '%Y/%m/%d' + html_sourcelink_suffix = '' autoclass_content = 'both' diff --git a/docs/faqs/max_execution_time.rst b/docs/faqs/max_execution_time.rst index 6b2cab112..32e763372 100644 --- a/docs/faqs/max_execution_time.rst +++ b/docs/faqs/max_execution_time.rst @@ -13,12 +13,11 @@ a job exceeds this time limit, it is forcibly cancelled and a ``RuntimeJobMaxTim exception is raised. .. note:: - As of August 7, 2023, the ``max_execution_time`` value is based on quantum - time instead of wall clock time. Quantum time represents the time that the QPU + As of August 7, 2023, the ``max_execution_time`` value is based on system execution time, which is the time that the QPU complex (including control software, control electronics, QPU, and so on) is engaged in - processing the job. + processing the job, instead of wall clock time. - Simulator jobs continue to use wall clock time because they do not have quantum time. + Simulator jobs continue to use wall clock time. You can set the maximum execution time (in seconds) on the job options by using one of the following methods: @@ -32,12 +31,12 @@ You can set the maximum execution time (in seconds) on the job options by using # Create the options object with attributes and values options = {"max_execution_time": 360} -You can also find quantum time used by previously completed jobs by using: +You can also find the system execution time for previously completed jobs by using: .. code-block:: python - # Find quantum time used by the job - print(f"Quantum time used by job {job.job_id()} was {job.metrics()['usage']['quantum_seconds']} seconds") + # Find the system execution time + print(f"Job {job.job_id()} system execution time was {job.metrics()['usage']['seconds']} seconds") In addition, the system calculates an appropriate job timeout value based on the input circuits and options. This system-calculated timeout is currently capped @@ -48,42 +47,15 @@ For example, if you specify ``max_execution_time=5000``, but the system determin it should not take more than 5 minutes (300 seconds) to execute the job, then the job will be cancelled after 5 minutes. -Session time limits -*************************** - -When a session is started, it is assigned a maximum session timeout value. -After this timeout is reached, the session is terminated, any jobs that are already running continue running, and any queued jobs that remain in the session are put into a ``failed`` state. -You can set the maximum session timeout value using the ``max_time`` parameter: - -.. code-block:: python - - # Set the session max time - with Session(max_time="1h"): - ... - -If you don't specify a session ``max_time``, the system defaults are used: - -+--------------+------------------+--------------+-----------+ -| Primitive programs | Private programs | -+==============+==================+==============+===========+ -| Premium User | Open User | Premium User | Open User | -+--------------+------------------+--------------+-----------+ -| 8h | 4h | 8h | N/A | -+--------------+------------------+--------------+-----------+ - -Note that a *premium user* here means a user who has access to backends in providers other than ``ibm-q/open/main``. - -.. note:: - Session ``max_time`` is based on wall clock time, not quantum time. - - -Additionally, there is a 5 minute *interactive* timeout value. If there are no session jobs queued within that window, the session is temporarily deactivated and normal job selection resumes. During job selection, if the job scheduler gets a new job from the session and its maximum timeout value has not been reached, the session is reactivated until its maximum timeout value is reached. +Session maximum execution time +******************************* -.. note:: The timer for the session's ``max_time`` is not paused during any temporary deactivation periods. +When a session is started, it is assigned a maximum session timeout value. After this timeout is reached, the session is terminated, any jobs that are already running continue running, and any queued jobs that remain in the session are put into a failed state. For instructions to set the session maximum time, see `Specify the session length <../how_to/run_session#session_length.html>`__. Other limitations *************************** - Programs cannot exceed 750KB in size. -- Inputs to jobs cannot exceed 64MB in size. \ No newline at end of file +- Inputs to jobs cannot exceed 64MB in size. +- Open plan users can use up to 10 minutes of system execution time per month (resets at 00:00 UTC on the first of each month). System execution time is the amount of time that the system is dedicated to processing your job. You can track your monthly usage on the `Platform dashboard, `__ `Jobs, `__ and `Account `__ page. \ No newline at end of file diff --git a/docs/how_to/backends.rst b/docs/how_to/backends.rst index c215f5535..3ec7d47b0 100644 --- a/docs/how_to/backends.rst +++ b/docs/how_to/backends.rst @@ -122,11 +122,9 @@ If you are using a runtime session, add the ``backend`` option when starting you service = QiskitRuntimeService() with Session(service=service, backend="ibmq_qasm_simulator") as session: - estimator = Estimator(session=session, options=options) - job = estimator.run(circuit, observable) - result = job.result() - # Close the session only if all jobs are finished, and you don't need to run more in the session - session.close() # Closes the session + estimator = Estimator(session=session, options=options) + job = estimator.run(circuit, observable) + result = job.result() display(circuit.draw("mpl")) print(f" > Observable: {observable.paulis}") diff --git a/docs/how_to/error-mitigation.rst b/docs/how_to/error-mitigation.rst index edbcfdaa4..166916fc2 100644 --- a/docs/how_to/error-mitigation.rst +++ b/docs/how_to/error-mitigation.rst @@ -140,8 +140,6 @@ The Estimator interface lets users seamlessly work with the variety of error mit estimator = Estimator(session=session, options=options) job = estimator.run(circuits=[psi1], observables=[H1], parameter_values=[theta1]) psi1_H1 = job.result() - # Close the session only if all jobs are finished, and you don't need to run more in the session - session.close() .. note:: As you increase the resilience level, you will be able to use additional methods to improve the accuracy of your result. However, because the methods become more advanced with each level, they require additional sampling overhead (time) to generate more accurate expectation values. @@ -191,17 +189,8 @@ As a part of the beta release of the resilience options, users will be able conf +---------------------------------------------------------------+----------------------------------+--------------------------------------------------------+ | Options | Inputs | Description | +===============================================================+==================================+========================================================+ -| options.resilience.noise_amplifier(Optional[str]) | ``TwoQubitAmplifier`` [Default] | Amplifies noise of all two qubit gates by performing | -| | | local gate folding. | -| select your amplification strategy +----------------------------------+--------------------------------------------------------+ -| | ``CxAmplifier`` | Amplifies noise of all CNOT gates by performing local | -| | | gate folding. | -| +----------------------------------+--------------------------------------------------------+ -| | ``LocalFoldingAmplifier`` | Amplifies noise of all gates by performing local | -| | | gate folding. | -| +----------------------------------+--------------------------------------------------------+ -| | ``GlobalFoldingAmplifier`` | Amplifies noise of the input circuit by performing | -| | | global folding of the entire input circuit. | +| options.resilience.noise_amplifier(Optional[str]) | ``LocalFoldingAmplifier`` | Amplifies noise of all gates by performing local | +| (currently only one available option) | | gate folding. | +---------------------------------------------------------------+----------------------------------+--------------------------------------------------------+ | options.resilience.noise_factors((Optional[Sequence[float]]) | (1, 3, 5) [Default] | Noise amplification factors, where `1` represents the | | | | baseline noise. They all need to be greater than or | @@ -228,7 +217,7 @@ Example of adding ``resilience_options`` into your estimator session options.optimization_level = 3 options.resilience_level = 2 options.resilience.noise_factors = (1, 2, 3, 4) - options.resilience.noise_amplifier = 'CxAmplifier' + options.resilience.noise_amplifier = 'LocalFoldingAmplifier' options.resilience.extrapolator = 'QuadraticExtrapolator' @@ -236,6 +225,4 @@ Example of adding ``resilience_options`` into your estimator session estimator = Estimator(session=session, options=options) job = estimator.run(circuits=[psi1], observables=[H1], parameter_values=[theta1]) psi1_H1 = job.result() - # Close the session only if all jobs are finished, and you don't need to run more in the session - session.close() diff --git a/docs/how_to/error-suppression.rst b/docs/how_to/error-suppression.rst index db91e3aa0..04132cad1 100644 --- a/docs/how_to/error-suppression.rst +++ b/docs/how_to/error-suppression.rst @@ -66,8 +66,6 @@ Example: configure Estimator with optimization levels estimator = Estimator(session=session, options=options) job = estimator.run(circuits=[psi], observables=[H], parameter_values=[theta]) psi1_H1 = job.result() - # Close the session only if all jobs are finished, and you don't need to run more in the session - session.close() .. note:: If optimization level is not specified, the service uses ``optimization_level = 3``. diff --git a/docs/how_to/run_session.rst b/docs/how_to/run_session.rst index 796a74ab8..1339e983a 100644 --- a/docs/how_to/run_session.rst +++ b/docs/how_to/run_session.rst @@ -1,4 +1,4 @@ -Run a primitive in a session +Run jobs in a session ================================= There are several ways to set up and use sessions. The following information should not be considered mandatory steps to follow. Instead, choose the configuration that best suits your needs. To learn more about sessions, see `Introduction to sessions <../sessions.html>`__. This information assumes that you are using Qiskit Runtime `primitives <../primitives.html>`__. @@ -34,7 +34,7 @@ A session can be created by initializing the `Session` class, which can then be **Context manager** -The context manager automatically opens a session for you. A session is started when the first primitive job in this context manager starts (not when it is queued). Primitives created in the context automatically use that session. Example: +The context manager automatically opens and closes a session for you. A session is started when the first primitive job in this context manager starts (not when it is queued). Primitives created in the context automatically use that session. Example: .. code-block:: python @@ -66,13 +66,14 @@ There are two ways to specify a backend in a session: with Session(backend=backend): ... +.. _session_length: Specify the session length -------------------------- When a session is started, it is assigned a maximum session timeout value. After the session has been open the specified amount of time, the session expires and is forcefully closed. You can no longer submit jobs to that session. See `What happens when a session ends <../sessions.html#ends>`__ for further details. -You can configure the maximum session timeout value through the ``max_time`` parameter, which can be specified as seconds (int) or a string, like "2h 30m 40s". This value has to be greater than the ``max_execution_time`` of the job and less than the system’s ``max_time``. The default value is the system’s ``max_time``. See `What is the maximum execution time for a Qiskit Runtime job? <../faqs/max_execution_time.html>`__ to determine the system limit. +You can configure the maximum session timeout value through the ``max_time`` parameter, which can be specified as seconds (int) or a string, like "2h 30m 40s". This value has to be greater than the ``max_execution_time`` of the job and less than the system’s ``max_time``. The default value is the system’s ``max_time``. See `Determine session details <#determine-session-details>`__ to determine the system limit. When setting the session length, consider how long each job within the session might take. For example, if you run five jobs within a session and each job is estimated to be five minutes long, the maximum time for the session should at least 25 min. @@ -81,27 +82,186 @@ When setting the session length, consider how long each job within the session m with Session(service=service, backend=backend, max_time="25m"): ... -There is also an interactive timeout value (5 minutes), which is not configurable. If no session jobs are queued within that window, the session is temporarily deactivated. For more details about session length and timeout, see `How long a session stays active <../sessions.html#active>`__. +There is also an interactive timeout value (ITTL) that cannot be configured. If no session jobs are queued within that window, the session is temporarily deactivated. For more details about session length and timeout, see `How long a session stays active <../sessions.html#active>`__. To determine a session's ITTL, follow the instructions in `Determine session details <#determine-session-details>`__ and look for the ``interactive_timeout`` value. -.. _close session: + +.. _close_session: Close a session --------------- -When jobs are all done, it is recommended that you use `session.close()` to close the session. This allows the scheduler to run the next job without waiting for the session timeout, therefore making it easier for everyone. You cannot submit jobs to a closed session. +With `qiskit-ibm-runtime` 0.13 or later releases, when the session context manager is exited, the session is put into `In progress, not accepting new jobs` status. This means that the session will finish processing all running or queued jobs until the maximum timeout value is reached. After all jobs are completed, the session is immediately closed. This allows the +scheduler to run the next job without waiting for the session interactive timeout, +therefore reducing the average job queueing time. You cannot submit jobs to a +closed session. -.. warning:: - Close a session only after all session jobs **complete**, rather than immediately after they have all been submitted. Session jobs that are not completed will fail. +This behavior exists in `qiskit-ibm-runtime` 0.13 or later releases only. Previously, `session.close()` **canceled** the session. .. code-block:: python - with Session(service=service, backend=backend) as session: + with Session(service=service, backend=backend): estimator = Estimator() job = estimator.run(...) - # Do not close here, the job might not be completed! - result = job.result() - # job.result() is blocking, so this job is now finished and the session can be safely closed. - session.close() + + # The session is no longer accepting jobs but the submitted job will run to completion + result = job.result() + +.. _cancel_session: + +Cancel a session +---------------- + +If a session is canceled, the session is put into `Closed` status. Any jobs that are already running continue to run but queued jobs are put into a failed state and no further jobs can be submitted to the session. This is a convenient way to quickly fail all queued jobs within a session. + +### For Qiskit runtime releases 0.13 or later + +Use the `session.cancel()` method to cancel a session. + +.. code-block:: python + + with Session(service=service, backend=backend) as session: + estimator = Estimator() + job1 = estimator.run(...) + job2 = estimator.run(...) + # You can use session.cancel() to fail all pending jobs, for example, + # if you realize you made a mistake. + session.cancel() + +For Qiskit Runtime releases 0.13 or later ++++++++++++++++++++++++++++++++++++++++++ + +Use the `session.cancel()` method to cancel a session. + +.. code-block:: python + + with Session(service=service, backend=backend) as session: + estimator = Estimator() + job1 = estimator.run(...) + job2 = estimator.run(...) + # You can use session.cancel() to fail all pending jobs, for example, + # if you realize you made a mistake. + session.cancel() + +For Qiskit Runtime releases before 0.13 ++++++++++++++++++++++++++++++++++++++++++ + +Use the `session.close()` method to cancel a session. This allows the +scheduler to run the next job without waiting for the session timeout, +therefore making it easier for everyone. You cannot submit jobs to a +closed session. + +.. code-block:: python + + with Session(service=service, backend=backend) as session: + estimator = Estimator() + job = estimator.run(...) + # Do not close here, the job might not be completed! + result = job.result() + # Reaching this line means that the job is finished. + # This close() method would fail all pending jobs. + session.close() + +Invoke multiple primitives in a session +---------------------------------------- +You are not restricted to a single primitive function within a session. In this section we will show you an example of using multiple primitives. + +First we prepare a circuit for the Sampler primitive. + +.. code-block:: python + + from qiskit.circuit.random import random_circuit + + sampler_circuit = random_circuit(2, 2, seed=0).decompose(reps=1) + sampler_circuit.measure_all() + display(circuit.draw("mpl")) + +The following example shows how you can create both an instance of the `Sampler` class and one of the `Estimator` class and invoke their `run()` methods within a session. + +.. code-block:: python + + from qiskit_ibm_runtime import Session, Sampler, Estimator + + with Session(backend=backend): + sampler = Sampler() + estimator = Estimator() + + result = sampler.run(sampler_circuit).result() + print(f">>> Quasi-probability distribution from the sampler job: {result.quasi_dists[0]}") + + result = estimator.run(circuit, observable).result() + print(f">>> Expectation value from the estimator job: {result.values[0]}") + +The calls can also be synchronous. You don’t need to wait for the result of a previous job before submitting another one, as shown below: + +.. code-block:: python + + from qiskit_ibm_runtime import Session, Sampler, Estimator + + with Session(backend=backend): + sampler = Sampler() + estimator = Estimator() + + sampler_job = sampler.run(sampler_circuit) + estimator_job = estimator.run(circuit, observable) + + print( + f">>> Quasi-probability distribution from the sampler job: {sampler_job.result().quasi_dists[0]}" + ) + print(f">>> Expectation value from the estimator job: {estimator_job.result().values[0]}") + +.. _session_status: + +Query session status +--------------------- + + +You can query the status of a session using `session.status()`. You can also view a session's status on the Jobs page for your channel. + +Session status can be one of the following: + +- `Pending`: Session has not started or has been deactivated. The next session job needs to wait in the queue like other jobs. +- `In progress, accepting new jobs`: Session is active and accepting new jobs. +- `In progress, not accepting new jobs`: Session is active but not accepting new jobs. Job submission to the session will be rejected, but outstanding session jobs will run to completion. The session will be automatically closed once all jobs finish. +- `Closed`: Session maximum timeout value has been reached, or session was explicitly closed. + +.. _session_details: + +Determine session details +-------------------------- + +You can find details about a session by using the `session.details()` method, from the `Quantum Platform Jobs page `__, or from the IBM Cloud Jobs page, which you access from your `Instances page `__. From the session details you can determine the `maximum <..sessions#max-ttl.html>`__ and `interactive <..sessions#ttl.html>`__ time to live (TTL) values, its status, whether it's currently accepting jobs, and more. + +Example: + +.. code-block:: python + + from qiskit_ibm_runtime import QiskitRuntimeService + + service = QiskitRuntimeService() + + with Session(service=service, backend="ibmq_qasm_simulator") as session: + estimator = Estimator() + job = estimator.run(circuit, observable) + print(session.details()) + +Output: + +.. code-block:: text + + { + 'id': 'cki5d18m3kt305s4pndg', + 'backend_name': 'ibm_algiers', + 'interactive_timeout': 300, # This is the interactive timeout, in seconds + 'max_time': 28800, # This is the maximum session timeout, in seconds + 'active_timeout': 28800, + 'state': 'closed', + 'accepting_jobs': True, + 'last_job_started': '2023-10-09T19:37:42.004Z', + 'last_job_completed': '2023-10-09T19:38:10.064Z', + 'started_at': '2023-10-09T19:37:42.004Z', + 'closed_at': '2023-10-09T19:38:39.406Z' + } + Full example ------------ @@ -122,14 +282,12 @@ In this example, we start a session, run an Estimator job, and output the result options.resilience_level = 2 service = QiskitRuntimeService() - with Session(service=service, backend="ibmq_qasm_simulator") as session: + with Session(service=service, backend="ibmq_qasm_simulator"): estimator = Estimator(options=options) job = estimator.run(circuit, observable) result = job.result() - # Close the session only if all jobs are finished, and you don't need to run more in the session - session.close() display(circuit.draw("mpl")) print(f" > Observable: {observable.paulis}") print(f" > Expectation value: {result.values[0]}") - print(f" > Metadata: {result.metadata[0]}") + print(f" > Metadata: {result.metadata[0]}") \ No newline at end of file diff --git a/docs/images/ibm-quantum-logo.png b/docs/images/ibm-quantum-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..252c31b2f7134666f2712136c9556b9795932e29 GIT binary patch literal 45708 zcmeFZby!qu_dg8dpoB`70SF>U3K$?Uqog3xog&?#bd3c_i3*4kgQ%30wA2`s#0(;x zDljxiI^^#jU{KHT>v{iwpXXf9EMiifJnmgJh*hQxPFz*)4|@+?YyTX z`_>cZ!8QK2AUoUEBiHRD*>#mw*`%FZE!fTqoDw+2E=A79#wOuvZh2l^MsDYI@RuaJ z_4Vt{=LH2lJUj$EL3 z+gqMjowr}w+ST@C68NFctGL=&faT&BB_$-WHT-Y)elAV|jDB9))#B=PCs$1;Cwr-X z@=ikVpHJ+(EFp;B?l0TlrtQ{UKwK$u@Fsy#Qsinn-ZVr+aH2~x7c@PIC;KQ|drDpt zVd_8k=-iJc(Tj_V9=|N)=*L^Hh3TrG30+2 z|0BZxknle#_@5N~|3L~+D2U14W~1>x;k{P?;{;ns@`L8!@fKVBm(6ijC#KN7a{l|re~-y@1u0v@tTMIQ znn3InAkk0?E1MDc58HK_$CS9?Ic+B2zf2q=nMB__Dav8!4o?+t$y${Pg+YwyacPc! zZ{^oJh)YmD(>QbX=nMZco2Lrk0gW4%L$w4Fa#LmbRpeq>tYH6|_V@L2A2%gdO0K_8 z{`+k}c9act<=P8|SB9`*h5|#U5z@bpbVLB`^NR)>F+_u6?a7<-)x#vxN8iJm-MHQw zK$Ps)aFZdv@yXOBa>&}|6cfxi`ktL-ZzVlc|M+*+V^N3QW{+;li3U-3UP4xjGMlhE zeUme|U>YJdm+#SU`Clwk)kIbE%W}!-Lr+jUU)?-Ti@U-spty11n?wK>nun7{_^z89 zuyD-SL5(REI%n4xYlvA_n}r6SCx{LQrBxg-tm^1NJ>*)nA<@^25qh6ArdC7653>`D z6q0%O;P|i9{}+|E^;L-po8Tnl{Go+am8g^6&Jk#zh#ARZ+>?(OQVFt!lKf?_l4?)A zAURVgg5i?m_d}ieXzKW3g~9z0{(F}jK77@g4z0lEX@AixZl0vqi;CXlX zLCXNez(*LC6D$8;SldIgA?a){O-qo%a=}^2#E%qHbC7bD7WUJS6!Ib5E9x{p3`eQ9 z*o9JRHdSq;tffj)*BrXc_f;FMb(DHDIwyGR*+0qpPv@^wZ*U-?R^$kQz^FxtS6-j= zZHnpaVmFxs2nHK-YiI`f(CRC>Kq7=4)O#{M2`#LD`)mZf68p5wWkl)aUnjT>aNZ#E zFr982#e;MZv|!0ZYMO3TKIu?tCUqJ&hW7*~7XXtA?;J+5Pt7Q45=n<-LiT(Kui+ZLgz7< zhZp+CW!pxqo3We!>f^D7Cx5qn*GP3y!Zi~04|X1#jS8fgmiqMFkdv5kUHi#_(&zA6 z09Awv)LSBn;_w>t64~eDDyxQ)^)fuS{x&q?2~eV+;~Dj+ci8Mm?fkZn-!`I416gg# zde17^=Qh2V^o6_RiPZknD^2;6P^LSl)ZkT*WmtJ8T zyjezjc@D+-hRSoW{wd$0l%C|rV{p@>2$sIJd$ZWK^NA_Ta|@+!!i|I1$bK2=-$JN* z0g#+LpKad`Yfxt)UT^!PWqMxvRM3(Khg8NPJd6W`+q>^2OLa6=`10;1D(eJI{a1abJ`rL7mzv% z7Z0Y=S*zW+i-$c6w3IDb)*326Z=H9|KYN<`5J6nF_#4KH;aFVrKj&kf@cR!`+#Ep5 z$EDvV-Fe=W5mBxnBF*7kfLczWJ+)y&67Q3m;C+YohYGT~VohRbeGo?D;Yd|C&xKBL zf1h{o*uUxj%N^875t>_ET2Rp;i2jxK!}=oF)7!R zZZ(T<+UfMOpi?W|YghiRgiv7sosFWIw(L6-`U+so7#W@Xr_CcBNGhFrD*DORI0xfb zLqF_=q*7a}+4wJgmvVk`a-a|+ku$euTNYt^>hCPre*)+RQ`!?o1SEZgcqJQ|l&bXM z&6P=hz8_idG0fM_{ozE2d9nrWuCF)en^ybj&tbdzhid-1eAClF0OXzu;RudQJEP|c zOfF|2pM}g<;664<&Xf{S?rFpUAM}Obx|gk*a3kw=_vYj0t(v1mOzr==@Q>uWD0O$+ z;{Y04eC^aiR$B`c^hEcXqM`9V=u^FYGDxRSnODfDfQn`>HKF#(r0z68RtMs6C!Egw zb!<{9PrBT|>hxVM&mPBojkp6lXp<@>{&(Y_uz)b3SPB_~l){cD3Z%kJ~ zxK?9voedV8$Ns+PBW!?^#G`i!Hk*#oIx2|*7OL{;_SmpjY%;a3!Qg85(eX$k#A^r+ z_aeIKikTHd9W2zhQfi(V`FBy>;4&o+r9}`_=^#IX;joeUa!i-b&e^XSt3 z;V69wgKQROIxFM`FiMu+75i{eT}lWB%7h$2WeI|8?9%kti$NJ6LGjkFqZM}sFS&rM z7Fxgi?a0MJZDNXpj?r$tc8GGgE(hGy5y3F*m4%k9713V4-anE>fYu*$J3t0PgG?9Y znlYx$x1(1EPaq6cF^=ayU4UjYTDvIRl^bM1FzmAe4qPKI=Go~`osonA5zQE^lel!U zErCnFE_P>$U_R7CRF#THFF|lfseqy+ac3R$w7o&hcs%Ed_nU)NN!ey3w4$-_P}g-m zmu#`5Lj9yXs|39p1WWcun$NxrB;j*<-KjvBYxZs+#k&D`o{b2iQV)GT(FiGvU4Ka@kP*M> z+s|$02R-SWr$yWdr-|g-;{Z8gLDYD&Todx zub2G095r$RIWnA2KLlxmdKcdAuTSlU#2A(f-|G{9ewLQ)R4LrpaM@z+P_<+h*AG z8TF`YHb|RP$ANbzlp(_)ta+RSI}ORF81izYZkWt}CH1%IxEOK+hd(^bxsHG6vHGCA zW?Sm3S8{4jU5SfkW2vDNwvAzYmJD?rMgUGC2nKQB36eH)VxAoVaC)NA=F2y`Am)Sf zJcs^?l|SLtR08PYnx`VnJML^P*}}QI{*_Z>8_W!a1-W%w#3LV?NOuwotW2eLMy#IJ zNT-5qk`4M~5p&AUNIVw3-We(a;XPwvfHTs;5y+lYLx|=J`+Tw4U5q$_CZ1u#U zaOWW(!1Gg^==xHmMNKsy|M)odAw(9*9HhBuk!^)lA0=BUt&%iER(^8u#`=lSa4s5a zwTbs3<63{)1~G(-3NQxx@RiIq_mH>Yk$j1QmM5b=lV2(+xlSTuWO#hguq=4&k@%v9 zc9u27jbcc4)nS@OOMZ|Y!QcaAhB`Xok1|LQ945RIM{Rj2V%xt$*+V4By;pSfEYtWW zT1f5rF=l~4_t_DLJ~a;YyliSu9I8{324^d0It%SvZ)j*(DhwWa8OWasgyD?jr4r6lmGDp+mKs5666jglx(@lCR~|=qzAUKzpaVFPXsBt6@?PIl|@phS~uC#qg+O6MRHP3_3Vn3gA-91GSWXB{h>3tg~| zS&a#M>@JN5p}s>T)vaq&kL`iHCoQNu{T~Sslr!Mz{&N;Igph(gS0;f&z-){wj@5#N z7i0Fr-=16!ri!fY#Ov^H6b0OGZx(#Vs*$WxNKC?>Q~y=dvN>0Q#V;QoCz$!%XcW!S z$fDrk32mko&RIni2|5RUWdYNh1Pto=iolNf1&vpz10*bGCk;CO9!qq}0IVzP-XNG^ zU1igdL^=HrpV$Wg##tbE45}$2WzTM}dx1MS+~h7f=@C3P{5wbY{yQJ_D}zx709|?m zN2qpGEvgx!6n3tgJtiS!zlS>UCU#?9xm*l35TEIJ1;Jq9C5aX*&gj-*Gm&=~I2QgN zcD@Jr_oaQ!QG_T+k|c&{vNmx~zfcT?`V5NK*E%tP9{tw1^Rn#(`H*4pjk5C6V$O)h z4(WbdD5;^~VE(&B%LumF#K4YLAK{|E;$HO%Ad{;G`!T{s${>=7di{Py?X+3i=U?kx zK+3Z+w=v|ESc{w(KA!c-5IcdCUy}%_5GH$d5|ff&WEr%=?(B+SSX_R1m^+E!t`X2l z&^&nBAua$u>TRlOyWZr$Ip8Sj^L{k zn3PnD!v3O(G>4ADL`sIyeUY2(vjX@0l`JA0DpZ-3h8}G1CQ`b4F-Ytt5rTq5e-e1e zf7}CMGsss!IxJG<%m_fBB$4~UNYouf@9BCSVT(0QbYkxIg+w!OX^GEZpE}St@u`j# zO62EvH}emgToqeh%Sw z9GWJ%i339s{aQuN>UE*7 zFE39-U6LD#_1Xx=6wzC^_~DZmgi*o8427@X=1KHF*H|XSyk84|n+$#b zRczqqn01P~e4G&Vu{md=&<2#(k07F-tfwUG^%t1bmFf#cdmk$RZ&R(lhnl^m*^V$wP*DKNeBgrkzWaFOkNO6DM?U8gt?b!0Ja5fz7hT|qSY;$@QzOG;i(vX{su1i z7`Zv&`Sud-_zOk1k*{MaCmLFzQa~l@Z?H9eQsIy4i(|+xPYh!@Tq(;(-R%c4j$Fox z{gN|9h>=t_>&0Xc_OuOlxF68IET)sM}$C=td(w-w)pt) zeU}mFtTUL3MA3Lu$qZ$=KZU=5U31Drsr@Rux+=(=j^7!Lw`O*DE3MtQvHNHTd4U~e z?j{C8FSs@%9h%62vvt$tPjZVV$%wyzl%;0wUWAl7b4vS|1h%Z6O_FQ{VE&zz8@O8b z04w5Oxl2kVstUwq&-Vk$J5sHyhRo@V%_i;Tz|4qwrd(UUGbESG>uz8Z!6WcaKT2N* zdX^sWvA+OAFK+TI$MA7faW@vCG-;NM#$Iun`m%_Nx#zIEXeepCGBPR4bn*892w@BS z8_lQGI0Alv*qR`}LIQ)n>!oHP88ct$OI3S5mmUOihc>s4rQplUtmcjNk(`QiGy&Pv zGDDi@J%PV+S31S!8t%$L-><@ee^D@QB05u(P)`O>FhXUq}5;*{$$~TsUV`L15O*3 zFLQt)=aonlL!KdaG#_)rcU*lYM` ziM;y#d#HDq;gqj^lm@VFM+GtLcNDP-)v`LZ=m+~r4NqVMdzy@o<;v)mzn)INpC};n zrnjIr8?S=_9Y<%Ve8Z=v60p-<dVfj-{auOTnyYhYMv(TG(G=lLc~4UKh_W6k{1I_?(lv|!%M{0tXP6fbfp8+>+o z>N9WrPj-wzU}m=s0zP!{lr~y*Ww@;ybE;1Dj_O!ASwWzQ6q-JgFm%?ZbAvQd*cD+Tl zt9Je7NuW%O!(w`uzI_kb@SGUYMKHX#GWU_x*&VD9`;9NJ#p59tb1`8ATC%l!`n
)-&X=V(lbe%9$Cf+93SeWU9{1tR zZzsIO=3J^;jzx+K&vaZH(7pSfw3id}ZL|aXtTy1OMDL5qK_!w;UAX1a3uSbl{kH_* zHawJX^;-NF^xIVZd2NvY+YbtDOTptw9U`^+9S$N`EWmHoO176Nw$(zs9#Z~0bL?8F z*9R%eNL+Pz(JgqWkhjbmR-u8|usGZ=_KudqaPF-$+Hd7UyGTqCw$!Cww>gO!{kp;K zBd%&lLd-0wQMv32vUXa&vCZe2aqN0hMP^&nqU72obj2;G>%o5Uj)+cwUg@Og5e5?; zM-X&rOWsmnC5Q5bS`~U!P+~Y-Ql4Mc*eM!32JP7}Dd;e?)UEh29M?kz0RYT1Q19nL zb5`?>l6G6rny$n4X$QD3=e<-b+M>59J%XhoY^9XV&563(8zTAU(4uc$xk$jS0yGsy zWclweFC37Ho0%N$J&!gxid~Ca4_hs%jNi;^&hlAp+GdD<&gzp|li2l!ijKx>S(V)l zbdud_Y*QUiYR{~Ud3;W+&5nAn@7Jq#PnxWkFSi{hZU>`0*KmH81)+ydJu~s9wj&R} zqTC#KJjv(JnN#0lvu`R8An}?+z zRr9{n;WB)r+e;-r!RDZz6Uj1%Tc?R%JT=>Q}OZ$Kp7S#LBC`!)6&dE7mkzubim_cgMc zp@?5!^E9!nv}(K&E;bcaGwJr7>P!*o)HMWy%5tzsJf&VLpa9}3$;Q#MZXv$}m8b5( z1)o^AmEUvCdY~O@ESyomsznpE4{?E{lJd#2u}wfIBBo-mNHYcE`ZCMr&7quqCY#-6 zd+;`iHM%U8v{aGB;%FxM( z*-r0XsRW8Cm!2-iO4y-=eEMu-2($*IVJ#+c4<)yh`c`N32gJ7R}$O=Z!!#w;39D}cVJ4TJg!Gf03Hg#^h z9iw6~&>s?OVPP!;6-c-Z@Z}U$B8Ld?K}j!js=B-lWG!40ug=h0UROLvgE&c&QSp-Z zn89f^o3p@u@hyk38rRPBc-{p{-vC6A3txnQXca=|(A6f?lFvXnm2{2T+U49p)!dC| z6(Hm2c4(3GrxNf(ti6hEX`k^PWoi9(IY~Od--LRAqo9gJ-~QwLF}!{HzCT21dz z?SG>%9BvGqzn&-GV@X;;!=#9mFTy>srUqV<%FMB;d84V_<{xJSJk-x$b|`D95;d75 zZSW{@KuNd&4PyDPH|9$pO8(nhV}lRghR`z}9HR8@E`qd$triWBC!iJ}4lCqNf|#l$ zm$JSXdF{2eJd28sg}&^ru1Y+goTK5974v2Y+kv2Lp06Loi__M>2+dW>4cbHa$(uU@ zmvbkqHtMrY0@65kxsymH;m74uRNqGl)t891lr(#}be*Lj?p}9#3^O_?VRXNAAgUdB zb@u$P0syoLj$IEGN zJx(gdewe^HFd6{2&N&huc`HEHGqOTHuQ`uj-D^t%M1g@~pD#xnOt~Z0+}MupRy`3D zpprj5KYGIwy_`7gW}UbXUvn=rGL4!4;!Z42!=|bZ z9m8&I+X{7I*JG!U@WZFD73$MI2h8K2LL1Sm_EYPr64H1P*C8$FJSX@nZRzOfJ(|fC zz$`2a%ePy760)YA#8})Ljm~FfYx&217Gpsupg;K_>C!w{3bHzxBjDg-#n4h|z(idO zI;hz8H7G&91?~{DFXRQG*Mtpj3P%{9#?Hvj9}>%2bIDWBEb+{E5NSwi$c_={8oRyf zD}J?!BW4nfD7MO~sOZwh@2CW1Nc{y~l~CE;>~pOQkX;}R2ibR=al0jNhJvJh=;hm| zw>g6N1mS<(fK~=JGR5zpgW8IWm5^wuCy)mcQGeytsOj1(9SFKwDMxGRRyFlBLI)q+lEfJO;_K@wBXinY-yHQbDK67k)^2+J^;x<`65Bmib>xA##idI=0bTu` zP1KDY15c)Al#B3CL~(%@4s_LQ&Qb;TVS!}DYk26!oWv-otf-ObA5X-(^xNVS;_MYa zAq4|9<2ANhj=C zP1u8$|GHuL-VXwGX9P;$!*vfK*1B>sH~zJ)Y`_T&tmg|7Tg7^M`to~ikyj_M<;@KZAWY38$oQy4Ri=eJ^OFk-6phgC)1%Q zk=R;6WTD7&mh1{gNS5>2+l~ z%P!E3$@v6Xy=4Xk0i}qF>t1h0ryocnE9gA7gDB9PNyew4D_`kixeEbE2*Bdsg`}ODbp7eK&EB{zlNN-b8k|V518LGc9UiRrkfsTjw2ai4T z0gI=n+ygs?w=&OcKrgwl5VSZb-p#sA5HG;W;ez@XHiq+nhgry4WEk(;x7PI(B@gMR zH5upoeoFeVjw$oG#U|m+H=yP-s@T)h^oCDnxC7_`N(VSwic%=AF;Jgb(+l`ge z=O#qQ?`(@tn&51O|Gm_Y&S-Qpzqy-4R(2pRUHqId$EgGWo>t}bo4YoJbpJ`!8_5?~ z)#o2S=hfG09DCd)1d5+_6uQW=qEVrBt@SmRu)CoJk*?=JL+>I~|9JtHHt1xqw7x-N$fxb$Xugxue8n+X)^a|=Zh6jc>fpc_8r$b#O77qor6l+$ zQWMn9@O^^AGR%QPC;P+amGak1FyYYfpdV@h1NK*q6GBP(4Vi(}_8c%zCn^igm&lQor zAi5`59dUE>TZu+&g78l@K!z+?IkfZ$`b2=iIxlsmhtcim>ub$wH4IH2U-&hPr&=d& z7?@PhvQYu+FM8l7m#EU%F`38bcqjwvH_3;>gWAF}1nzp@YCZvxCe`!_XANJPABBch zWdy=Rj9vZxa2O6CDtNiLjym*P&VfU5eSGL@3 z((+^Xlbi}xAUMCmtoRWIzwG#D)BV(SrLABExp4q9Pg};fa}xl?Y*0h0(bFZ`$Z@gO z9R4AhRh@$L$E_h($X?JqHb$zLd?_V2Y}T4iZ7Wh)&c(+LTa6Y~kCL2>d3A<-GbO~` zhTEUT&$vZje`4F%WAjo!c?$%l-Ut@@qOr{QRMZ(*&`7zJ^)>PW1@Wy+#|gYQ^e#hV z$Jn8!A%4r$2NX;XBPx}PJ(i!q+6&8k;_`2u38*uaP8(-WJ7e3r55WOlp}uwKAu0mW z5OFPBSo1KanipVzDG7qXPvR9OQh?Nu17l`|?K@+(RH23Jb~;RE zc_7B1z9){_DH=R~>f&VAf|#jT=3xma@0j`S#NA{!TP#^x2Ved8!{u$o0bNy`U1#r*=`Uu$?^__eYwz1BARH8F1_)4PbD=K2+_q!$x&B1s)yT~D`> zAZm=1CC8V9ihU1O3_s3`ZjSN`72fjJUY(eov6URdGk=>;+z9v2sHCtSm{&i@?9-;b zVS7vIv?y>}_p3R+=R#Kpnbp%bTd{tm5<=p8Ty@c{R$j~rXE^ZM%@4%(gK**j?7LpPQMEk$7oq|4+(VvmxWxKzK-5RGVPQ}00LEFUD?3_E$^1y5)?`eGL?paF?VbxuhFupnDEU`eU)79``!pc zX=VC_q~lvKbWM=m6MpODITLxnfvwm_tGl;B=MA2$&Iu^#$^LBd5BQYg>H#3BBdY~Sp&l*aC(@eHKd}LyS&^|9--^V*BvQlv~Vkhxj#9%E1zk4L*6SF_B_4mEZRJ^d$ZS@5*HM@VeW5^TGbbzBW zDcvfY+B(mz#wV+4`Sz3sdBMp{bvj~58Q!-NFC`}UR+(fAZQlCDDHeUx3vz=25G_WY z1==Q+YJJ|3>(4YUraz?c^s9&;OalJb>2!D-`mnr9amwDa_~Sv^bk5%!?>&Cgt&H+2 zJG`j5N6hKvlV*=lA@s~A&zsxw!OeytA7Z=X0F_!fveM$#W1iB%LDIM!wNi896uzH^ zR{5p+j{LWhX|9=-eBc8bp&cmDFR$HGc^7oP-zd4L=j5)IrOn7(C8m6K%K{_Xpxrw% zSW7DLA$RI=a=7B40D?gt)WZenM`Ne0tG<9kFZXxr{uyKowq*5IT8O19PU{I%XVLto z{n$j4HtK(NsOlOabNauXKl%pgdsMyx)Ik>4#+_2d+uCi<2qYWurz%_m>0~p6#nHB0?B$uJMcF}X zgl*1hiewDF!(9L}g5#X;*se>WR3Vho7QA|p0E&XnN6FMbE+eZCGIx7HyS)^(PHs!^ zsbDz152T-Kc4a1f#jj7y+Zl1zbK?^ie9#oAkb{Xt?+F=4N2)Y$>bn#logQBfG6aN> zn9$J?ZYFNCHMl8&H|v1Ib`*Se9-^KB4zp_@Zbx~MWNbLP_A{&DvLHEv2j3L3);95D zMg&xD%#bd46!j2vx9COWQK2*;N@`E34in0H6jBSCm_(uF`d}Y1WDmYbg)zTGC{j^D zri3!C@LC``nkH^tKeTsXa)}0vzo&CeF42gBjym}Wv-{r!!)`Ez_~4y zU6kLkG@aUmS9_+Nru#^S&$Wup?nPu^4ca=e2R>{bRi6q<D06CT%oh> zW+kdXwnalk0<^MPwBgD%=DQ#v)K(emWR$ZZ$XMt09_LcpVeeDK&AmfMe1djRWGrf> zHf(+2=<}l@8P5?N#B z`~rqMz$>7~R^T%vc>lp4F}xx0ay(f@E8GmE%N_>Gg4W8ZQacB?*wphN>q}+rab!Yu zdr@TySgIZnNIaU&I_9C<9SMJ0-F?vn02KxpnN(Ps;QTI;YDqKO5#2Wq>x z1xZ#Kf-G2?7C4j(_2()qPl~kC;7?T`^ho@h^&h{%>+*}lp}JQ@#~#M?a%Cy$K}mbL zRHur|Rx?~qZ(|Y!N8AvHM>4~urGai&-OTn-J=Ou!dSSIKww(i%1cYC)KCRPG)!0S5 z`JqN)z?LZQU%JObKpM&fe?f$x%0u`g_d3!ECj(;gXvq8Zcupl)}?+^qs$wm~Hir~pB@ z-#ipeXqUoUT_c3{xifL#beSI5?Zjvo= zwe&^BdSJN`tz>+unYwSG8>G6JrP_C(@wtXUm)$Q^wyEn@X&-9CHDeKFhC;k?Ytg1Zxt{B0n<3{Gpd6e%35?i zjlqhEfdiX!Jlxo{HRA9_Q?k}9KV|V7F$ni^P6wH+g5kMsbBx)(( z6;TZLx{}P|fN@b`a_SwPv8X(`3G{gz7Z}G=m`;HBtM3CW30tnGrvT>L1-$!8fFcoy zJM{OzIU1&T(?svuaA-vsS- z^|L}&_Z?XkMy+sjZXHMp#X<|keHcQVBt|^aLli%L*s=Rn4nrPAXXV~_Se439!Z^G? zusDQV5PrQFyu`G1I<)Kp+xlsU7SwptvQywlL@q6iUu*M5zj&Q9Bts@mYO2e$OIKGCi7 ztVG6=!^aTPBy+v?L6);igOX>h!Z6->$7Acb2>K|3epJYJ%d78#R?1#TSz~bvU8mVM z>@i#}`G!9VE?2a~G%$~FkA|1oI*HTjstk^u`{HjEJ99LtXZ~g?L$Z4E8W)CFbyg2myu}Fid|ul zeOK5m@mV#ARok7_N6?K5c?uY&yTAS6a{?#p=QVSwhm4N8Zoa^5g8rd*v_|2P*g8Q1 z@$o7IUqZkcy&hk#SF$rGTCVy=}$F=Ri# zvca7@bTk}A1c8x{h`zg6Xe&0d8u6PHRX}5ytUt^gQ}K%2=ur+>KzkQNl4SS=Y>FD7 zBiM3f9^hE!IG@RD+9(}=Ahw?N;J(Dxw(;|vrLIH!s3gh8D$A9;YCW>rbIs8Gc101D zr@xea%jOwBsRYV~GyA$?@y`l?*1}<7E+0ZoaGHA5WmXyhrDfxRSk)szd|rFI+!t+# z+t5rOrhEv3@mjL5CB0$MlN>v~PCgl*n(D?uVa0}-D-4K z-S0VUgy~%OK1gLlK;XBX(h2f8ibl^bWRyC(o2K5}*ei32YnJ0~hPRY&Xm89q_uZrQ z9uKajUik`*payGX@<`dq9=U;_KSzE@_6w-YgzASfW#QPRy2I+1FR=v-X0&H)n6^TA zb`zU;uFax#_JV&!qfj)3+MANc-PS-;o~8X*QgVeLra$1L2vmUP2er@egng;?%r}pZ z68ILYU1op|eSe0gv|hQDa>K6g`Moin$Fh86hR?neGw4!r1mpey$J1h}6zmCHHsr*Z z&1^7Hx~%fDJ5|XS1gkw|HJ3{e^GbyaepD68@y*Hj?60XPCxBlYz{n=_5dN@g{AJS+ z@5~JW*BFWui6evNsr}W%MWM?D9Eezmk_3Lqa`svSPcwCkctYw3CwFHfP&7UB^VaEHudr!3!xWJL01_ z&o{7=fg{`CP|lj_t6<+5U6z!Kw)_wN;25NnBv$GaLCBScoQnqR5^b}W`YKUlgC_Es zr^xn<+@Cqt5a?BPrSy}}{6uere=j>?c=UJ}AyMxsZ~7UXA{9_6lI-EgvgoV(Y&zBL z>y?`)DV@~Xge@%YflS8@Aobc@ZO8DkLM5t+tR4kXqX9HVl)K8_4r)oI`z$z&K`mX9 z-z@#U#)x{l*Jk?Z!>+#^LS-XK6i~F@=eic0w~F}2Dz-p#%WA!?vWms#!L?$%zwZo6 zSw;I}7gx!!s~3GXZGLu}($O-l&s&cqZMxmjZoKi~dW^z(7#cg3<`D8hME~{{8qSfg z`FwLZxO=kmu@YOr^?D3x8vefLx z6$U3mbX9gVui?BWXe~pCE$3JB&OE_KUZ?UwC@O-^?j{V+N-;zU&0klTb*WC2Oo3Ok zM(AIbsZqCaw4`_$|+teNviPnoE`^3@#0wn;&(ptLvfkeu?h3vuXB~S}44K zVmwt@GZe(-o%oK(11>iR9g*B8?gzvzb3S8iP&yASVi+}`be7)akErh~Tddf(W?t@V zqA%4|+qykEH~_Q$gU%dJ9AQA5*VWaGD(f*P3ryK@N7YyPorgA=-XHnQP`8{aQt1EE zOfap`C^yoGzySktz80^V#wL%}2CH*fF%2H9t}58~4K{Y$07Ddms*G1p^;XRnuY@i` zaCdM@GD+#&g;XW@^oqQ^p}p&e$E?a)o1>S|1oMC&9+`%s@$w6;>nYT)p2@OnE7FE3 zT?gSZ+iVfMGLp{;c4kp~CP_mj=j#q{-$Lig<|eby#)FEB{4FXb={X1oD!7m`E8pmS&P8r42FX|0vx6!z_yRw(JKW6m`*HVucIA1;0IeSF<48nnP7 z3PF6Kr=$)ez2Kv>W#Ym!J7{fHR0wMx|a3e$y&PWu^HLLinbqRyFjO}F~{F$xn znuGG2O1#OaO7K>;?8gy84y03gP$_JIPI4N5gu_rU_ro!eq8&Ju9CTo{^2G#e@sS~> zU)9}y;4(!F2-JxzPcTke#TJvq;N!Z%M-YE2oXAu|QrYw_#3#9(+tJVcNp)A?AK z=w}Tyl*hj~ASSU4>Q1ZcemH#t4x32jmfjf;^$61NNXtn3bDUj(%4p*WtL*iP`M3Ck z=jA6s`r;|ycH$0<`;ly+Zq)0MlIlh1UK(BYjxgISr~4RfiffL_Z6tEVAH^bm3B{tI zF$!lH^Mae1GdE zFBzSKE$n{cmldU9oc3auw?KV?)+Cis?HFzeX#;svz0|`MR80D&%;#4f9$38&&om|k z*)wE(GmC^hfK;Qg6zG8MkE?V0qPnG_# z*Or~z@@&{nRlx}H}nFPe5eYb9PCn)Uf4 z>imO$unN%UUz$o6DLbtas^{H{t_6;H0F+1ImF_plqcbZ%Qm$`S_y;h09?1+c zAp9FH9A<5Owi#vsN=_mfry1$A;>f(Hei#l;V9PoQe(LrT$Vp4M$EPmo_V?TWdiZ9W z?QnWv2{IaQ@5V8!WCct-jIY=3;hSsnDSUZJXV)X*=N63!IUGuo?jW=`;lnpOyZp?c zYn#d?PQZsIq(QT-*LZ)>fcv`pyYvXdml<+GV8TuXfa`G68w7R9B*P~70DXtg;lA&S zOQsYE(0ZQsS^xM$7l(-smfY_f^o;u)3|~(Z(6NiurxbioBTb2RrUP_lrQle*3f@+bQ&0 zFuTn^T^&=sV(|*)FE4fN+Z+CpA&g;FL&`TGk>3%^UM1 ziZ^-=jIfVca_RW0|4%6c0f(1WS<|n#eo6xPG#Di287&9@&MUz2WAOUXp?mN0-P7*W zKZ+W-I}XfsRlWN3tPH(W>iA)G$@uH+KR5GpuAc*Mp>PxA;OAKHi0{tLN;*_k^9knJ zuK~5BN&Ar#v_Kii%WqtNnpXg_5{TB=eekarlgEH5VEI@>n62^Hea3q1MTT(HBshkG znBiBqiL%4%o9`;W>zf_X_``Zd3xVgJn&@9m@Q|_@tX*V4CK7aLHG+0fvxRf00fWxD zn(Q)rZ{Av?y=MvE3i^qMUzN-5`U`=7nJx)?O$e4W>8zt3$@0-#$z8D{Ul_I5%YRS5 z;$5NpLN*DUo2!lKx z?Ov6zahQ0hOfBAwNu84K6%ew9nVDYOb#HzflCbbWIAR!;w{vdkF71!1FTydWwl(H# z6x`Up57zzG?I~zR7W?LvdEEs!U*2Ax#Ml}Ts@p!PX#%}R$`>UMPNRO(KmLjJVf`xD zPPNn@x09O$k4$c!?OF4GCilxZxWPjRPWPpRp+iETRp+1I#yU%*`)nW#n+5Zky&g~_ zn9h(V@zmNQmA38PP9D$zXx>5)^dK3%I?-uSMvUDp1e7l<<8Czghy-|jqtcuL}Z zBrb|Vzf!i*nYcujPbjMU5k#b1s`$xc&hyRxx$2!sXYsA1taF|OMV}jrqDff@Hf(&r z_)(BLrO5EZv+Zz~&&}DY}>dzRz{+=?|c81Hk~Fw53DzZ6Uqud6p*zK2UxC! z^iV&Q<>P365)Y#tO`39khm|*R_IP(vgYXrXpS1l0X%OE4A;g;d>j;F1gvNNfe!j!U z?^+zpNmVcgU9;m5Of9JL9R)!=E$PC~Lcsqms%)y7gPZq8I0-6?2r&Rr>NQ^oo}o@D zUP;v7X~J2_Bh;(tbK7sr<>Z4N$7co89yI^QehGhww)74|DA?okxf34XgV53}^-|0?wB zFFzUZ&*SL5{JAoe%pfrvdO#S>d6DB3;Orgqg5Z3>)0AI*wm^I^tVeG0&EIFK)9BAz zVEfk>0#YH8OS>0%jWJ|Tdhr>aM2KMaUSy|2w#yk)&5W0%CdAW6_Cd@u%O-0$? zY69OGva27xe z8xCjSKYbAd=)}hHw3?^tC^$*}M>?GV`A&-mA)0%)e|1(waAs`KM1|_=%M33S1ix^N zaOH3gM=@{G$8ZJ`f{sB$2n?;2Z3{dU=U{lGT9q7-<3Ep2dYN~2rXczxp>4~V1>vMN zu3p~DfIq2aNv-?rewaw1VF6&lsj9$^l{L3l7q@9X=!sxBxe#m;8MXN>l3}+y;2)$y zq?4>s@cohipd}k(k0~EbcJ&D!s?@xq>IluIWBPK#!{XA+bma$;e=wr=1m;_A&eqwL zKTBCE-K=14ylXf8PG03Q&wa*L9j6NKn{yn_FHn)Z%d9t+sryaj3Zs9OfO+uUCjQMJ z798v-?k}I7bk+y%%@y%Q@*P*`1gM$69A}KfRv$Wqh=ZxhT;` z)>4;R4^{zVFB&2I4n5 z$qDkOh;>j=i>Aw&K|?yCU@Ik$d6 z4MN%k&(GXiRZK$JA;xn`J8;Hxo*@YY`;m6l|6_PlU%=g(Bx!oWiNKD-ZtPLsakJk@$Ll2dhCsAdB+ zJr2ejn2LGeG5{D*6bOb$a7?3`_GdRUVHCn%V)0)-i+fl>_&bGMvl7`~^-g$Pk8=im zKERXx+Ffm2#kIXAXQ+_wV;S|2%(u`>WT?ILmdebDe8_U&44f z%pC2fAgm8Ufv~^o6j(B^&}fcbQdq5)!{4kQQvl@82cAi=93-b1ey9eN#3Kntc6hJ>Er$ubzYIz&$p{Pq9*6Oe5~dcpEzF zL;|O4>&YF4L*SDFMU0ZZ|Hbr8UqXKy7Uq%;2#@-e!!)d~1L-jMVbWAqm7cNS6y{{A zi)Qu2{;9boyV3wk$}|DI_g_;T5zRa0qR(Sx%i>#%GKg^tP-UljEzJMou8sEqya}7Y z_1i(pBtl^r(O*xqjNI1jZnxMEQX)CuP2CHt-#tgL`Kk;LGg#@6wQn0|_bLbtN{!D?r; zF-wJ4#wxm*aK2%aaUrGgKd0x2p@PVhc=Kyo&p(|!NIaZBA2nkRFw53x!6)$Tk))f+ zJ8a6>7l)ULvGPWKv--!8?FI! zS_n<(r-_iRHbl3~nKI=wy1Q5Y5c{FVmu<^1ZaW*eamENV!?77Fea17-c5Qrrj13?e z8YdGB-_INLDfvTAb@F`}d)gCtL3Gzr+vy;G%i8GuJq#S9r6}7G{Sg)Sj;|ga8YiWX zV`4?ka3_`qj!C@+Iv##2nqhvS_=-Z&`r{8nI0n0ZuE`VJ>9Ck0ULE0)urO+lw4_zs(pwrE~pw3^o zZflV5;Aojm=aZ#340ghE9?}DYcOmFEV~forV*oi1zwrdd++cjO=(C0JhJ+b^^daV| z6Pq4j4G_6EorXCm2NmaeE7z0;$&mPGT+Lt`}`962s?~WxY6Ad&q@>7 z+#K@7P=Qz@aDv*+mffKZ2}jXjk+n+RY>)wZc%HU8XpKr}HVQpt*i@y%$HXYk32~;) zh9{X=o?7YqrsDmrv`a;1%tl_z@!YGrhorT)B~wtH)skfLYqh10A!ah4-Ltgb=}kRief3>`R*FL@ehzIe@o#_kd`0v zJHVyADtz_Ww<)*Yt5LJ&=k6fCGD;1USng`IeTw9-^+yKI9P`rTh&$p?TlYzc^5!OB zVh?=wY#ZZ5clk=r=q}aQw5}GltWsx{_t86maQtbfbqfwIx9DPqJC@}-i#R6Psm*qsxU#Hjo zs`=m;yXo_C7$y3mg@Uvjz9w3?&4 zLT+KElzN8@npe|?EROb%_dz3qNJU6rwxzV^;=<)7?JhtYDQ!$SUN;O>*UH8vFiu?f zJNCUxGeaYIH)Q{+qPenBTjaE!g;saKHK@$z24l;h_duRSZFi~?rEZu#MODF*5hkDx z-^c;wYxqrQ2kaisoxvkxJ2lHxY&2IT@A@kUc*gDl5ZTF@``5kG8AwZ6%Z$8^Wvp__ zDhOc$RVXfg(tvCQgxoyMc@NlI=PR z8_$UrVvfz(^UUYSqhUapg5#L3Ox#dWfriV_USX5FWonZ)5rz;*A$pNwVCe+yQAPiI z6B(}>Q%Dzj?fwxb`*9K|EH|Dja*&B%LE*ZC%W3+dLd@^2>4W4U5J}AE;E&IB8_#-v zY1Kr0AAaTJS@a3$^^~-n#+p9|UsU047P=*sbPWMV3E4nv+${xpa3=auNb+ypm|Cv> zb=to+3woGa^zhAJSxSUHB~21QjubG07otyk%5>i~cmG9RN1ot&($zc#r5T}n^N z969MYjJoevt!}|G4qsGgnsS%SjQ9`JH2-lqKovSB`>O?ERFX+vg&m_%E=u zn4MCBBZt4u2mA#Tf)lvcBwQ^(|2^uMF1jp#bz2mahk21ECzWu?6agAF&}vhoO2a4IX!n- z%*v(DJhR$r=_4Ocm>$!P2iF?aj9}tkn!30&wR^2_XFbaTmfKt{s?9}~)Qx0fOu5+W zIeaxj;LTU)PTC@i`->0En&0%d=yv^i#p*r>DC`&iYigm?{}qxF&}?p}J%O_1?#MZ7 zvY-$-Z?@ETx*c`ITzjhH$Aj#KerKK;diM+z5P1ECQyP7tVt(h#3>FI1kTJ1S7zi z=Nt6=a65VAMGHuLrAG|kJHD0eed2w2Zk()_{LbNtWI3X;$4ZkEXu=5V@}LmXNZ=5k z3fZbDS3)c==V0S)Qb=QNhq@C8!*h;yDTXl(6D5k&2~3>W50pvxJ&9C@c+|ru)$Z?i ziaG2*sCxVc*QiZu;N%I+7IfQjTJt-jUH$LHd&DH+dxDKdn10SFtBJ0TP+G&fPZI`P z8&L6EfC=DDl~ekTeml@t^pwW?l~sM^e{o+z1BnpeM|grumR_DynJvC=?#ghAz%DZ- zrK1(^~&`L2T=&$zH0AuEN zk-5T{2|#Zj=L_P2HwCdwT^M#^Iee)kH3bP^KB^EleZ3-;B})7K`Go!2GO0!~#M+zB z=0u*y0JfLA9_HziH*y`3Dne(I{AoX_2Gd81V>JOL@MY~n5HkWtTrxOUzzwo?b$EeklMbb8_!4H4!X?oh2wW4`X1y( z!z>0C3}n48qOYvzR|y?{9Pysa`8)ze`QphS>dYx~#J5Utqc>Bu`H9g0{66BFU=yu- zzf16HYv|-zbEXU+JM2!(kM32k5gxUfM%q>eecEo}LaufIH0lFms(uv$J9ZMMC+_4= z-k+(z?ocNEP;C`a5ZjMfihkcOkHdu*g!l`F4pTJbT6YhJ%B3%O*l5@Oa6TP27%fsC z{fXg=;At|>aADw;nUb;0mjwx`_OLxi$pUf4la8%_oa`AMjpS;JvRkT7HuTkbrA!$H z{kj=e@;X*D-zTCMVu+Ib@fa*WZZS`KSxRO2rGb|8>oM?v-DNWTv61rn``Pr zG67NyY=getcejx~;-SuAtdLwSZ?Q{271R0GB{QZGX5+J;I!08BaBPz)j=sq z*qNO`BMY(jG9B+&%fWC3jdU-Iv8piR`i{X#kLq)mI^C`-|G@CzyCe*@`{$rmZPUy?9 zYh$sFFtbJdkyJ$v3hFy$g1z>1@RlHUFy6nJ=IGL5?B%?_8c9_Ro6xgBy^rz@G=<|> zZhUov5tU0ve?(;W$`fVJ9&IdcVzB>Jxv;|1@2|#lQTMrjCIJZMDfg1r8qbBuV}&#h z0`=U#`Lzf%q4Pz1O7Au)nD10TuU!kN3*ZqmDIozwOX*``S^dUVZaSLy%;*#vaxj}- ziX1E4{}ksf@0s@@ME9bIv$G%bYy>mmV)5Xj%Lm;E60Gbd^f1MOIsJ0f*X3Wrt|E>i z)uuvZBFq&K(P3LW3B2VdRbyb!@UgtW)|K2`rQ z{P|RFm%F}8&Ko*UBNZxf-@<_3hFdfR8+5#f36{#nl4u=Vwd$GPmxVYN+Um8~C<-vk zj&ey*_enZU;(v@C(30*xp2-)l`V|*Qr@p(IYJ#z83if%1W%gUvg4uE@QNSy0;0s{ep{ZZ#%it|iW#xg_I~k;MxuYWrd8u^ zwTI8dml<>l)v<$PxkjFgcC#wZ2ha?KO9s3~FBmQz$D}=a#sZy!G2`Y0f5|J2RA>WZE3A_0;0k}tPH-ULGex5T752J~--0b?;4L+%zd%A; zC_xBGSH83cSOhG~thpzJG*yJT{2q0e9t z9d8KVHThY?kbEGbCa8Mn!(qGm$8l``NPf1)Rc)UUas5xRv3F}FQ^!ez<(QN9x_R zXQhAa7{D3&5KcZRFv2J?>*_aZs@=yVF>s*$7)n#YG(2`fo+uW40@mltzrLh@c7Ua${ZLjuxXd|PH`H-R; z7DO>VBf_pwvLLQ-0`J)<8L4#NP2A-+mLI{zV3T@i{veKlK=dvH^K2h_-$NrG;Zidr z8!9g@ji>0IL6f9~*sqD-`)le|KN_B-(JNkKtCOHhhv_X{2F_KI<0a7xS9ALtJa(M3 zUilT)3?bn$?!WL^YUEdn(oWrF!S6rRgT>f`+>Td9HaM!pC2*a|>@Zwlcf z%|%UR9KUQ*wX9IhKp0;n7|%)|md zi08592(I{=HplC7+jt~bNNKE%t^f{zy#NE8$}O%WdE7V!X&M5%n?C z0Cyu@70j!UfrzNDBKkE=Smj%epOdXu`1B0I=J_?3swAbd5?`NG`ks-LTA3}!Nvq?? zOa@}cb6{Wr=a->V1@td{c1--6dTNVZs-)K9uLE(kxBYbrRF z)3`^-Xg?RZa}E0z%66u`A+@@2&@_T;4!8cuj-r|5`bC~ED*7J1$|}M?>h-oF@n?7F zbQN<(Cz44fEIE4QFI@*HWTaS< zwA3j`D2ZF2 zql~dCfok|yeC-HEiIUT+!nrh*5+Vwa5-w*=Y^XXoMg^w%jzX#K2wZ|lf^C9+PC-bm zZ@5DH;%srnnw+3?;s!d1Ipe|A5QHD-Eb-Lq*nAgr_0i|nt0mQ-aE8fV;UsI{f~kht z6%mfQgB592HV7dXf&5|13>u1JoDpTxUi3qx&q;;ur+E+OWS+*`m&ai@(DQbL{vMrGS7I9&fJDIa)2O##GWea$VgR>T(yVMzDUc3_NBsp5PZG? zP&u>>pQUvO8{01qyG$Rl%R|Y@_ytzrM($+=Ua&+i$XKrG80Zq@xWX3=$|0`0>48h@ zGJ4>AC;3%TsEXiv%zAz7fzggvPEJ)T*a==j_zt`krx2yrA^g-ta7|zPVBgtXAlCp+ z1Xm)fsn{v+2U98?@KK}dDZDPQdvE;!Zv>Kc2cV1e&YIMH>Z&~MGUC%$npO~N*d`UUn?Km<#LmuMG0fmP~8#NOE{@(Wq#dEc5m}Yioz;) z;d#*>ji`@nVs6E-kcSe0P4<&2+dOus&|g|g^nLctH*Vkcyz-S|_CQ7pvGc&bhdNgt zNsW+b8bF|pm~-lHZ36akv5!bh1jA>C;8Vg(FMz5cuKMu!L{VA*XAd9AaSc!*o%3$> zl0`-U$UO*`w4TsOfW@6RQq?J@YzSvG8FXR5VzWQUcVgB%`(V>1kGH%6t?^KV3jC&?^YYu7)*%vwU)nD;`dF;Yh zc9I~76&iJO+4M2N5ObEiMF_YHcFEY~Ume}QNW5M_*|>+>w^iY)eQ@W!Z0?m57P~Z* zAxbNdwZQ^;htbZkWHB`N$6+gogG9qo`vp3FC3-If{+yNEVy)@h7bj4iZ()?)VUGLm zZWz^PdSm&<*h-np452?Rfj;n-9L2cT%OTJyjNIuV`Ubo|PjQ%;ReiXXLiNy$BmR(n zc|6I{33^6+`&2CD+dC1D8=qhSX$a#}^}mM8mqw@y3VvK>+D}D$V04e>n@!F|FZkOmQsqxUV!Y-`=COCaqT6wM2b5?cQ9hcm53U!m^TfEHkB zoDs#5N-*;PNYF%0h#i^{LTqL_)AT9xPN~huk>lc4`wv~X0;0INej`sxB^&+&Y3{Mh z$F@#0Tg>mga_bz}Z{xA+$swGiPS$82C~9UdtOV;$r_lSRTpJAvv=Rdi^pFXpEgSHU z%I(Wj+i#^}ycx}O?E9c%>qX2kK$?)*UFrY8ve78+BTrfe#Q<;!UAlx3TD2$ej%eAP z=P8Hy@O#>|^G8apZUF_HWK*N?jKF4-e>(=-Yye%Cp{MshVFm@`oCAV=^Ma(}S*i0J zxC3oZIQInrZM619P}F{AX4amLcHZ24zjDVARMB-2na}*d6ZX}x!*wN9uXvY*2z!A# zNUz8{k?>~72SLE4vPrh;G3Hx z=QKc|vg?S=>A7=QiwE(&IOKi{M}X0OVue;4%k@eky>~FU$y%_y2Wc03^lg8&d+=40 zxx#FKi0SxlCIY|J5+M1dIXRls*hUmEKnKr zup$c?-1Fvl8J5jhpXAX0XBV&x?t%_QTp|V9@Y0C`;Us{!^2EgiIm+I#P7lg!V|yHS zWQC_x#Ug|Sqa6*!Q)Am7yM}U64DjeKKj^RG!FdwZx35fgJ6=yOum_iz3{mzuR;2Xr zuytBxO2B?SR10#3l?P$fm+prKN15M6(!_w63-j??e#Br;Bis!@ECW;~ZBDT1imu5? zstpY=Fr>}^efEWr-jb^d|*3mS8z_h<$dJdgYtK46scVX=$et~AS zedka`B**50QXP5pPd)6_#$lalnBlSZXwIn&O!x#$BGushKeBVAohlPYg2xNpmP_Fg~H8I@Mq8q z4JC1V{a&x$fj*?(wFU3OG%nevD7FflkLBEzFU~!>xvPshvN@kS+va+(#syPWw3^7) zlAKOyhr^JT03ZN_*ske7noMF(8sN=ZvHocFR+!-^SpfK9HM->vTvTv|yY66uwC*`5 z8xFeq0DzO;%y1zCG_K|d>Xx(|^)DY*3@zkt^Qx6dNH8@?SNIY&Qs{9-m2|fO66@^V zvj>3XWMZw}={T3(PisYduvhL{50rGV#8S0#@4#CNfbEWZxBA>E2B? zS*rrp(;PwVl3G~*k9y3XJ-_CB?oSh+m&{;_$>N`&ku{9^mLbq?MRdIkQq2TjDLJMf z-D8C_3Jk=#MbJc6SHH?3nUWl%P)zygAxYB4y+8wI_p-`a#3#6F`T*$F_-W!X$o&i8 zz`LY z?WvB7Y}}6=FYSc15|~4g+<=!%-<=L4c9sAOFjfILnniTiRE#gLvKI(#ws7Lq$1fbH zOGWu(*%H6MIt(x(HM048sB`Agf?o3gjqtR$(i^uD#s8F8}BfR4S#+W7xJz3bX))UJgMmMxY*!i0@#ooY>tj zcsG0hC?mQ!^R3t{RULAlo z&qEX*ZXF;MMN8c*)tA%ph*DXjCDBbQ_xcV~ zrDN0rMYbZa1!TKoPp~I>FGzK%2OcdmRmym)-}t?YC+NPU>w_=m5fk}+RxX!R^23EMD7QNU}vU{nb=@Vi)V`BM$iTHPGVrT9qonX>wAk9KQF<`fhJl z`Cvn`6%!B=k_Gr)_1!@Py2_rkU-hr$i|8%r@8eE)o~bB8yOsGyeE3K=+QvFB>eg{! zVV`#%>s%8TG)WB)p2e~B*f1Yw#YOe(G0AYI7_V@*!K-9Jjn#6ZTA18?6yyFa(xzsQ zSH&F2Tn~1O?~zTX<`}t5p*IN~Mc6E$bmRz+sxLVEs+V*#r?6H8&ZyG-{^Hy?MW-`E z$W(nW`x)NMe%w>4!e>rNem#S#nOkBM*rdXrkS;r=ASbwXhqZ+?VXgxbSa0U>f*SWK zE-~;LTbBRwGRIR5iB8JKXYpGBc;%z14WOmHMlsWTRFEO3ee9&PID3V2xqA#DFlbSZ zXe(t<1kMyrdME{5U~n_wYd!8T70rGv@z!Zrl8Qnh%%0Ct_g3U6l=ace+*5k5^TdT! zy9#BC5JFT<#LF&gQg-k^s=%FSbg|ktz2A_dTcvGOwd!qKPBAx-w^pHg);#6Gs z_Y{I|Mft3w5aja?0B9u%v1$QFOnS=OSoANS{i@lYc$rIR&+1OW#Z-ZE3`2A5VR9@| z_eNyqc8`+Ww4evy$qb*pB{aj0ae2u-&(Fmr^$PDEWpjAZ=rasdya^0l0f1jNsrW2$ zwIW|`Zm~(set~6wFc_PYCWo@Cu)I>d#daw0mHhYbKOF1jvzRE>7;g)3#~-6>hco!m zwMf143F0kVVFc_003Bazq*^q3?L%vse1E@5(&e&LIc}!Pp2V}UMz|MAMTC)f^OTVm z7KO!HobE_!;C-JUs$dZJ#sHapTu$k;{%veH@aj(-Ggqr0oRc0jn0&*VyLRzJp(Oj8 z>X=dZ432_+4LoIjQxr4X-QZyDb5POPbfU3>FFT~M1n4ZiW58UiT>3)o6;#uf(>fJ( z$s@T)5o&ZUH(rY|u3)6c}-e@$S>>QD@`k=ZoC1T>~D~EwX z_OEidz!v!IkcwAhWilt5I~op)`Y5ee!#YL<8IfwBdTnX8_@M7re&jE3y4jA8J(0_D z+f3a6m==6n074~{5!8808hYxWiybTcsBChEO`^>170H3L3GEGblhAMIzT;~me-C92 zWmDmV)P%%|XYMt9^U7TyE}ml%ozb*U=YU-}${#&3HScy|Q^>>ig9-IU&3k>n)~qHQ zH&fYo>6;ja%`ka{fz`n8`)Q0W z_as?5CfbBTk2a!gq1G`=_NeyU?#kIu?sIIp$~u%RLXRrqdqxgBUx+w%-5FT+Pl|m; zCgOiOcg6>9t0?!bxCId)GYcH}M1Lg^3@IRC6@X|n$;h&wX#Q2CH*cQ0t|H|J1Uj>W z`#2^1zR^&OGwWUR@{v6rfj6_nUx)W9rP$X=wSZ=T#Tck<@wv#R`3i| znjB6YzknB$>>JE2@vdS*B-7;F!_Q8|5W=dFWg~G=Q`XFuqjK6W{7Nn0^nyVunu}h`%{{Z`4sX&f=^cujDhw?M zk?=R^HQKfeMenhF2%%`R&sxi~gl=EME}W2nPx#EmcV)i7&HX4>wyCLB;V_{Ir*adu z=9;Q2!!FP5)Vu|9TLm8(?cfFVi0;}&{Nfd}ItFTzH-eon+{Wf2hTB+k0GO~H+GXb6 z)DU`k>}F?z3IxL58ZoiVt_$LH8*RZ?LK5h56|O24HwK)6Coet&zi~?Yz>C0JpZD z?U~uHi#J$)z3gn$c_p!=$|0BiHCcL|pgtR)P&j>lfo0&j&>jY z-B@?d&kevmDpxN$%q<}PbOKXeDlvddDQh~q^?FFIk`86sK@HA&-7dz%K{YA|KyK^9 zYuU9kj-6K0+@!o~T5gSlUHhEFC%)7qfxlQAZjT233N{i>3t0Oc*f3^jhEJxS$9uYC zOqZt?`?9n}#+VUPKbKC8Y{M1IDJa+&r{49wi05cWgV(o3gT3a z$=5tQ)a~r&&!|KiOf&I{p}q>rl>{z7{LxK=nD~YjoJ#OrBzeri+i<@(YdQy$q%7-x zE;@f*P=uRdlm>sFTYF6mkY@fu-B_7c;cr0LN?7dcdN-G#j43^J4M%uT!Q2DxD7eL- zF~nSA45u*Lq=(PuUl7(fk&;jn*0=ij{^vp;XQz%_=)%FlC+KU@LO-KT>hfb^F zi&Vs%Wb!l&?GkuABH|gh*Zv_mC45>$IMa5BSd&~D2V>KkLp`9l6X|3Ocr`?<2^~Xi z)42IU*oY#1`^SHFFr|ofjZh4}t-15(0^nza_*WpRv(!Z_9{7Xj*8L9NHSqyjo9`5m zYbc2tQ_EVW2*Sy{2sP|fOJiVO#Z4=M>6eZjT-+-LvX-b z{wK%z=WqT$JmAdxsP<>fasReybO@5|+`i__KlcRC(RfDZ-+b53nO^MM^@aCuYaLhU zzc3Y1hW*C-5B&5GU$y7}mFBX$Gn`5F4?o@DE91@nCTmRazhNEYi{Mtf^fiGtMjH{} zUx7$@QJ7|D5n95LJhNlv9!P-~SC^ zMWG_3r!}EcYvTN0NJxHI|Bq8+-}9m${E;gQ1Q@!^M|_VWmXLnpObEw6u1rb`Yh-*( zi_~qM{8TqOIC2t0Ji21uC7&FUV&frx ztMp#Cx_{fsCCEc=`dlyZ&!HJ9bEq61t(D|$Jd_~sXeL>T64|+nW?@HyZyFtP=`IpJ zT;H(fmEYiT!8>gBMfW)f2yJjqlN@y(l!~-}E^z%P_wKC*lVRS9exlw{q%UpBlXIF=iZ*~Ij@^{Yl_Q?#{iCN>}Zee`wn89*9Ql2#jR%#%&@Lk*)W zwN95N`*Q7^>q>x(>r@Zkw87DO@s7^?B_6-_$J;XW25m44Dp1iq81Z# zz#PBUf*0geU)$hEMnKb#G2--pG6$U-A3h_qxX}*k$GG+ve@^)Zph9nUIVSWUI0i^Y zSi3MR&f;=<+ld}I3a^nj$?5B|Q~uc89!Pxd-MHc!ALdu~Zv4Ufzrm3rk?aQqDAi@A zB)h?y)0r(^tC1q$e@qXL;RHZQ)FQqdrq_r>$Y1L>kP8W7#9CN~VpcDpt#S{Talxw;Ptr%l6$23BnBh+Ys7w3#q;hO1G? zzFe<{+<-jXTaRo6{Q#7ygMXt;t!)Gp5JUL1t>%|}$Uj_}FO}v}dDa{~hwneqg3<}# zwWzJuxc^RX8E6Rc%s*j2*UyG`1OiQ-Fh(kOA|sS8Qt)b&DR$4lc4|4ja%7kbsK*k8}vyB50Hu8KGSP)4ARkwMgzcEcNQUwF;eNt?^|8j5w-=f z)8wuQc_k-F)ZEy``??zk3P{@f5v8t+9TnN@sX(?JXgPwsfY$}3ev-1C$!4TO2w4i} zRO-WdNu-7T{-1xaeCtZEgnOb^gYsHy^)zHt{65m3U(t$aaNg`5=-m3Ao$_DEvUZ;9 zNIxW`VX_Hyd^+0V=`%@kN&frHYio%-i8;y5lx1Km`gt=4{Xa9RHP>jSj>_DcLvd{) zV?Fe~C~tqgM1CWhnXV})X?1OmKX2cqGguKV<`}!58*H2Cx6}1i(vfExEg7tkgleb% zX8T|YW6jNFr!G5vOWFXO1Ah)6yO`%ja?vgE0)E+24uf6)&2TfyFE=#*&_c)oZ62l^ zV403w&X-D@{Ku;VKyiG6rJI~o;Sdbj@=-?-Q+$tY?e*h-WjOSBHtU$_#s4D@8vys; z3uf)>8woP(R>V}0yxD@k5ZIi_W4V9ShyDDFo=I=r=um#>V3dq?RRo-K{64^;_CHVT zzn}c$IsFr}|Ce`__, sessions become particularly beneficial when running programs that require iterative calls between classical and quantum resources, where a large number of jobs are submitted sequentially. This is the case, for example, when training a variational algorithm such as VQE or QAOA, or in device characterization experiments. Runtime sessions can be used in conjunction with Qiskit Runtime primitives. Primitive program interfaces vary based on the type of task that you want to run on the quantum computer and the corresponding data that you want returned as a result. After identifying the appropriate primitive for your program, you can use Qiskit to prepare inputs, such as circuits, observables (for Estimator), and customizable options to optimize your job. For more information, see the `Primitives `__ topic. @@ -38,74 +36,107 @@ A quantum processor still executes one job at a time. Therefore, jobs that belon .. note:: * Internal systems jobs such as calibration have priority over session jobs. -Iterations and batching --------------------------- - -Sessions can be used for iterative or batch execution. - -Iterative -+++++++++++++++++++++ +Maximum session timeout +++++++++++++++++++++++++++++ -Any session job submitted within the five-minute interactive timeout, also known as Time to live (TTL), is processed immediately. This allows some time for variational algorithms, such as VQE, to perform classical post-processing. +When a session is started, it is assigned a *maximum session timeout* +value. You can set this value by using the ``max_time`` parameter, which +can be greater than the program's ``max_execution_time``. For +instructions, see `Run a primitive in a session `__. -- The quantum device is locked to the session user unless the TTL is reached. -- Post-processing could be done anywhere, such as a personal computer, cloud service, or an HPC environment. +If you do not specify a timeout value, it is set to the system limit. -.. image:: images/iterative.png +To find the maximum session timeout value for a session, follow the instructions in `Determine session details `__. -Batch -+++++++++++++++++++++ -Ideal for running experiments closely together to avoid device drifts, that is, to maintain device characterization. - -- Suitable for batching many jobs together. -- Jobs that fit within the maximum session time run back-to-back on hardware. +.. _ttl: -.. note:: - When batching, jobs are not guaranteed to run in the order they are submitted. +Interactive timeout value ++++++++++++++++++++++++++++++ -.. image:: images/batch.png +Every session has an *interactive timeout value* (ITTL, or interactive time to live). If there are no session jobs queued within the +ITTL window, the session is temporarily deactivated and normal job +selection resumes. A deactivated session can be resumed if it has not +reached its maximum timeout value. The session is resumed when a +subsequent session job starts. Once a session is deactivated, its next +job waits in the queue like other jobs. -.. _active: +After a session is deactivated, the next job in the queue is selected to +run. This newly selected job (which can belong to a different user) can +run as a singleton, but it can also start a different session. In other +words, a deactivated session does not block the creation of other +sessions. Jobs from this new session would then take priority until it +is deactivated or closed, at which point normal job selection resumes. -How long a session stays active --------------------------------- +To find the interactive timeout value for a session, follow the instructions in `Determine session details `__. -The length of time a session is active is controlled by the *maximum session timeout* (``max_time``) value and the *interactive* timeout value (TTL). The ``max_time`` timer starts when the session becomes active. That is, when the first job runs, not when it is queued. It does not stop if a session becomes inactive. The TTL timer starts each time a session job finishes. +.. _ends: -Maximum session timeout -++++++++++++++++++++++++++++ +What happens when a session ends +------------------------------------- -When a session is started, it is assigned a *maximum session timeout* value. You can set this value by using the ``max_time`` parameter, which can be greater than the program's ``max_execution_time``. For instructions, see `Run a primitive in a session `__. +A session ends by reaching its maximum timeout value, when it is `closed `__, or when it is canceled by using the `session.cancel()` method. What happens to unfinished session jobs when the session ends depends on how it ended: -If you do not specify a timeout value, it is the smaller of these values: +.. note:: + Previously, `session.close()` **canceled** the session. Starting with `qiskit-ibm-runtime` 0.13, `session.close()` **closes** the session. The `session.cancel()` method was added in `qiskit-ibm-runtime` 0.13. + +If the maximum timeout value was reached: + - Any jobs that are already running continue to run. + - Any queued jobs remaining in the session are put into a failed state. + - No further jobs can be submitted to the session. + - The session cannot be reopened. + +If the maximum timeout value has not been reached: + +- When using `qiskit-ibm-runtime` 0.13 or later releases: + - If a session is closed: + - Session status becomes "In progress, not accepting new jobs". + - New job submissions to the session are rejected. + - Queued or running jobs continue to run. + - The session cannot be reopened. + - If a session is canceled: + - Session status becomes "Closed." + - Running jobs continue to run. + - Queued jobs are put into a failed state. + - The session cannot be reopened. + +- When using Qiskit Runtime releases before 0.13: + - Any jobs that are already running continue to run. + - Any queued jobs remaining in the session are put into a failed state. + - No further jobs can be submitted to the session. + - The session cannot be reopened. + +Different ways of using sessions +---------------------------------- - * The system limit - * The ``max_execution_time`` defined by the program +Sessions can be used for iterative or batch execution. -See `What is the maximum execution time for a Qiskit Runtime job? `__ to determine the system limit and the ``max_execution_time`` for primitive programs. +Iterative ++++++++++++++++++++++ -.. _ttl: +Any session job submitted within the five-minute interactive timeout, also known as interactive time to live (ITTL), is processed immediately. This allows some time for variational algorithms, such as VQE, to perform classical post-processing. -Interactive timeout value -+++++++++++++++++++++++++++++ +- The quantum device is locked to the session user unless the TTL is reached. +- Post-processing could be done anywhere, such as a personal computer, cloud service, or an HPC environment. -Every session has an *interactive timeout value*, or time to live (TTL), of five minutes, which cannot be changed. If there are no session jobs queued within the TTL window, the session is temporarily deactivated and normal job selection resumes. A deactivated session can be resumed if it has not reached its maximum timeout value. The session is resumed when a subsequent session job starts. Once a session is deactivated, its next job waits in the queue like other jobs. +.. image:: images/iterative.png -After a session is deactivated, the next job in the queue is selected to run. This newly selected job (which can belong to a different user) can run as a singleton, but it can also start a different session. In other words, a deactivated session does not block the creation of other sessions. Jobs from this new session would then take priority until it is deactivated or closed, at which point normal job selection resumes. +.. note:: + There might be a limit imposed on the ITTL value depending on whether your hub is Premium, Open, and so on. -.. _ends: +Batch ++++++++++++++++++++++ -What happens when a session ends -------------------------------------- +Ideal for running experiments closely together to avoid device drifts, that is, to maintain device characterization. -A session ends by reaching its maximum timeout value or when it is manually closed by the user. Do not close a session until all jobs **complete**. See `Close a session `__ for details. After a session is closed, the following occurs: +- Suitable for batching many jobs together. +- Jobs that fit within the maximum session time run back-to-back on hardware. -* Any queued jobs remaining in the session (whether they are queued or not) are put into a failed state. -* No further jobs can be submitted to the session. -* The session cannot be reopened. +.. note:: + When batching, jobs are not guaranteed to run in the order they are submitted. +.. image:: images/batch.png Sessions and reservations ------------------------- @@ -114,6 +145,16 @@ IBM Quantum Premium users can access both reservations and sessions on specific .. image:: images/jobs-failing.png +Summary +--------- + +- Jobs within an active session take priority over other queued jobs. +- A session becomes active when its first job starts running. +- A session stays active until one of the following happens: + - Its maximum timeout value is reached. In this case all queued jobs are canceled, but running jobs will finish. + - Its interactive timeout value is reached. In this case the session is deactivated but can be resumed if another session job starts running. + - The session is closed or cancelled. This can be done using the corresponding methods or upon exiting a session context. +- Sessions can be used for iterative or batch execution. Next steps ------------ diff --git a/qiskit_ibm_runtime/VERSION.txt b/qiskit_ibm_runtime/VERSION.txt index 34a83616b..c317a9189 100644 --- a/qiskit_ibm_runtime/VERSION.txt +++ b/qiskit_ibm_runtime/VERSION.txt @@ -1 +1 @@ -0.12.1 +0.13.1 diff --git a/qiskit_ibm_runtime/__init__.py b/qiskit_ibm_runtime/__init__.py index 98a187fe6..64a3c8eb6 100644 --- a/qiskit_ibm_runtime/__init__.py +++ b/qiskit_ibm_runtime/__init__.py @@ -66,8 +66,6 @@ circuits=[psi], observables=[H1], parameter_values=[theta] ) print(f"Estimator results: {job.result()}") - # Close the session only if all jobs are finished and you don't need to run more in the session. - session.close() Backend data ------------ diff --git a/qiskit_ibm_runtime/accounts/account.py b/qiskit_ibm_runtime/accounts/account.py index b28fd52e7..2e5ed6bbb 100644 --- a/qiskit_ibm_runtime/accounts/account.py +++ b/qiskit_ibm_runtime/accounts/account.py @@ -12,6 +12,7 @@ """Account related classes and functions.""" +from abc import abstractmethod import logging from typing import Optional, Literal from urllib.parse import urlparse @@ -22,7 +23,6 @@ from .exceptions import InvalidAccountError, CloudResourceNameResolutionError from ..api.auth import QuantumAuth, CloudAuth - from ..utils import resolve_crn AccountType = Optional[Literal["cloud", "legacy"]] @@ -34,13 +34,11 @@ class Account: - """Class that represents an account.""" + """Class that represents an account. This is an abstract class.""" def __init__( self, - channel: ChannelType, token: str, - url: Optional[str] = None, instance: Optional[str] = None, proxies: Optional[ProxyConfiguration] = None, verify: Optional[bool] = True, @@ -57,12 +55,9 @@ def __init__( verify: Whether to verify server's TLS certificate. channel_strategy: Error mitigation strategy. """ - resolved_url = url or ( - IBM_QUANTUM_API_URL if channel == "ibm_quantum" else IBM_CLOUD_API_URL - ) - self.channel = channel + self.channel: str = None + self.url: str = None self.token = token - self.url = resolved_url self.instance = instance self.proxies = proxies self.verify = verify @@ -78,56 +73,65 @@ def to_saved_format(self) -> dict: @classmethod def from_saved_format(cls, data: dict) -> "Account": """Creates an account instance from data saved on disk.""" + channel = data.get("channel") proxies = data.get("proxies") - return cls( - channel=data.get("channel"), - url=data.get("url"), - token=data.get("token"), - instance=data.get("instance"), - proxies=ProxyConfiguration(**proxies) if proxies else None, - verify=data.get("verify", True), - channel_strategy=data.get("channel_strategy"), + proxies = ProxyConfiguration(**proxies) if proxies else None + url = data.get("url") + token = data.get("token") + instance = data.get("instance") + verify = data.get("verify", True) + channel_strategy = data.get("channel_strategy") + return cls.create_account( + channel=channel, + url=url, + token=token, + instance=instance, + proxies=proxies, + verify=verify, + channel_strategy=channel_strategy, ) + @classmethod + def create_account( + cls, + channel: str, + token: str, + url: Optional[str] = None, + instance: Optional[str] = None, + proxies: Optional[ProxyConfiguration] = None, + verify: Optional[bool] = True, + channel_strategy: Optional[str] = None, + ) -> "Account": + """Creates an account for a specific channel.""" + if channel == "ibm_quantum": + return QuantumAccount( + url=url, + token=token, + instance=instance, + proxies=proxies, + verify=verify, + channel_strategy=channel_strategy, + ) + elif channel == "ibm_cloud": + return CloudAccount( + url=url, + token=token, + instance=instance, + proxies=proxies, + verify=verify, + channel_strategy=channel_strategy, + ) + else: + raise InvalidAccountError( + f"Invalid `channel` value. Expected one of " + f"{['ibm_cloud', 'ibm_quantum']}, got '{channel}'." + ) + def resolve_crn(self) -> None: """Resolves the corresponding unique Cloud Resource Name (CRN) for the given non-unique service instance name and updates the ``instance`` attribute accordingly. - - No-op if ``channel`` attribute is set to ``ibm_quantum``. - No-op if ``instance`` attribute is set to a Cloud Resource Name (CRN). - - Raises: - CloudResourceNameResolutionError: if CRN value cannot be resolved. - """ - if self.channel == "ibm_cloud": - crn = resolve_crn( - channel=self.channel, - url=self.url, - token=self.token, - instance=self.instance, - ) - if len(crn) == 0: - raise CloudResourceNameResolutionError( - f"Failed to resolve CRN value for the provided service name {self.instance}." - ) - if len(crn) > 1: - # handle edge-case where multiple service instances with the same name exist - logger.warning( - "Multiple CRN values found for service name %s: %s. Using %s.", - self.instance, - crn, - crn[0], - ) - - # overwrite with CRN value - self.instance = crn[0] - - def get_auth_handler(self) -> AuthBase: - """Returns the respective authentication handler.""" - if self.channel == "ibm_cloud": - return CloudAuth(api_key=self.token, crn=self.instance) - - return QuantumAuth(access_token=self.token) + Relevant for "ibm_cloud" channel only.""" + pass def __eq__(self, other: object) -> bool: if not isinstance(other, Account): @@ -156,7 +160,7 @@ def validate(self) -> "Account": self._assert_valid_channel(self.channel) self._assert_valid_token(self.token) self._assert_valid_url(self.url) - self._assert_valid_instance(self.channel, self.instance) + self._assert_valid_instance(self.instance) self._assert_valid_proxies(self.proxies) self._assert_valid_channel_strategy(self.channel_strategy) return self @@ -178,7 +182,7 @@ def _assert_valid_channel(channel: ChannelType) -> None: if not (channel in ["ibm_cloud", "ibm_quantum"]): raise InvalidAccountError( f"Invalid `channel` value. Expected one of " - f"{['ibm_cloud', 'ibm_quantum']}, got '{channel}'." + f"['ibm_cloud', 'ibm_quantum'], got '{channel}'." ) @staticmethod @@ -204,18 +208,121 @@ def _assert_valid_proxies(config: ProxyConfiguration) -> None: config.validate() @staticmethod - def _assert_valid_instance(channel: ChannelType, instance: str) -> None: + @abstractmethod + def _assert_valid_instance(instance: str) -> None: + """Assert that the instance name is valid for the given account type.""" + pass + + +class QuantumAccount(Account): + """Class that represents an account with channel 'ibm_quantum.'""" + + def __init__( + self, + token: str, + url: Optional[str] = None, + instance: Optional[str] = None, + proxies: Optional[ProxyConfiguration] = None, + verify: Optional[bool] = True, + channel_strategy: Optional[str] = None, + ): + """Account constructor. + + Args: + token: Account token to use. + url: Authentication URL. + instance: Service instance to use. + proxies: Proxy configuration. + verify: Whether to verify server's TLS certificate. + channel_strategy: Error mitigation strategy. + """ + super().__init__(token, instance, proxies, verify, channel_strategy) + resolved_url = url or IBM_QUANTUM_API_URL + self.channel = "ibm_quantum" + self.url = resolved_url + + def get_auth_handler(self) -> AuthBase: + """Returns the Quantum authentication handler.""" + return QuantumAuth(access_token=self.token) + + @staticmethod + def _assert_valid_instance(instance: str) -> None: """Assert that the instance name is valid for the given account type.""" - if channel == "ibm_cloud": - if not (isinstance(instance, str) and len(instance) > 0): + if instance is not None: + try: + from_instance_format(instance) + except: raise InvalidAccountError( - f"Invalid `instance` value. Expected a non-empty string, got '{instance}'." + f"Invalid `instance` value. Expected hub/group/project format, got {instance}" ) - if channel == "ibm_quantum": - if instance is not None: - try: - from_instance_format(instance) - except: - raise InvalidAccountError( - f"Invalid `instance` value. Expected hub/group/project format, got {instance}" - ) + + +class CloudAccount(Account): + """Class that represents an account with channel 'ibm_cloud'.""" + + def __init__( + self, + token: str, + url: Optional[str] = None, + instance: Optional[str] = None, + proxies: Optional[ProxyConfiguration] = None, + verify: Optional[bool] = True, + channel_strategy: Optional[str] = None, + ): + """Account constructor. + + Args: + token: Account token to use. + url: Authentication URL. + instance: Service instance to use. + proxies: Proxy configuration. + verify: Whether to verify server's TLS certificate. + channel_strategy: Error mitigation strategy. + """ + super().__init__(token, instance, proxies, verify, channel_strategy) + resolved_url = url or IBM_CLOUD_API_URL + self.channel = "ibm_cloud" + self.url = resolved_url + + def get_auth_handler(self) -> AuthBase: + """Returns the Cloud authentication handler.""" + return CloudAuth(api_key=self.token, crn=self.instance) + + def resolve_crn(self) -> None: + """Resolves the corresponding unique Cloud Resource Name (CRN) for the given non-unique service + instance name and updates the ``instance`` attribute accordingly. + + No-op if ``instance`` attribute is set to a Cloud Resource Name (CRN). + + Raises: + CloudResourceNameResolutionError: if CRN value cannot be resolved. + """ + crn = resolve_crn( + channel="ibm_cloud", + url=self.url, + token=self.token, + instance=self.instance, + ) + if len(crn) == 0: + raise CloudResourceNameResolutionError( + f"Failed to resolve CRN value for the provided service name {self.instance}." + ) + if len(crn) > 1: + # handle edge-case where multiple service instances with the same name exist + logger.warning( + "Multiple CRN values found for service name %s: %s. Using %s.", + self.instance, + crn, + crn[0], + ) + + # overwrite with CRN value + self.instance = crn[0] + + @staticmethod + def _assert_valid_instance(instance: str) -> None: + """Assert that the instance name is valid for the given account type.""" + if not (isinstance(instance, str) and len(instance) > 0): + raise InvalidAccountError( + f"Invalid `instance` value. Expected a non-empty string, got '{instance}'." + ) diff --git a/qiskit_ibm_runtime/accounts/management.py b/qiskit_ibm_runtime/accounts/management.py index 6e80c9e03..34eab1dd8 100644 --- a/qiskit_ibm_runtime/accounts/management.py +++ b/qiskit_ibm_runtime/accounts/management.py @@ -26,8 +26,6 @@ ) _QISKITRC_CONFIG_FILE = os.path.join(os.path.expanduser("~"), ".qiskit", "qiskitrc") _DEFAULT_ACCOUNT_NAME = "default" -_DEFAULT_ACCOUNT_NAME_LEGACY = "default-legacy" -_DEFAULT_ACCOUNT_NAME_CLOUD = "default-cloud" _DEFAULT_ACCOUNT_NAME_IBM_QUANTUM = "default-ibm-quantum" _DEFAULT_ACCOUNT_NAME_IBM_CLOUD = "default-ibm-cloud" _DEFAULT_CHANNEL_TYPE: ChannelType = "ibm_cloud" @@ -50,28 +48,30 @@ def save( verify: Optional[bool] = None, overwrite: Optional[bool] = False, channel_strategy: Optional[str] = None, + set_as_default: Optional[bool] = None, ) -> None: """Save account on disk.""" - cls.migrate(filename=filename) channel = channel or os.getenv("QISKIT_IBM_CHANNEL") or _DEFAULT_CHANNEL_TYPE name = name or cls._get_default_account_name(channel) filename = filename if filename else _DEFAULT_ACCOUNT_CONFIG_JSON_FILE filename = os.path.expanduser(filename) + config = Account.create_account( + channel=channel, + token=token, + url=url, + instance=instance, + proxies=proxies, + verify=verify, + channel_strategy=channel_strategy, + ) return save_config( filename=filename, name=name, overwrite=overwrite, - config=Account( - token=token, - url=url, - instance=instance, - channel=channel, - proxies=proxies, - verify=verify, - channel_strategy=channel_strategy, - ) + config=config # avoid storing invalid accounts .validate().to_saved_format(), + set_as_default=set_as_default, ) @staticmethod @@ -84,7 +84,6 @@ def list( """List all accounts in a given filename, or in the default account file.""" filename = filename if filename else _DEFAULT_ACCOUNT_CONFIG_JSON_FILE filename = os.path.expanduser(filename) - AccountManager.migrate(filename) def _matching_name(account_name: str) -> bool: return name is None or name == account_name @@ -139,8 +138,23 @@ def get( Args: filename: Full path of the file from which to get the account. - name: Account name. Takes precedence if `auth` is also specified. + name: Account name. channel: Channel type. + Order of precedence for selecting the account: + 1. If name is specified, get account with that name + 2. If the environment variables define an account, get that one + 3. If the channel parameter is defined, + a. get the account of this channel type defined as "is_default_account" + b. get the account of this channel type with default name + c. get any account of this channel type + 4. If the channel is defined in "QISKIT_IBM_CHANNEL" + a. get the account of this channel type defined as "is_default_account" + b. get the account of this channel type with default name + c. get any account of this channel type + 5. If a default account is defined in the json file, get that account + 6. Get any account that is defined in the json file with + preference for _DEFAULT_CHANNEL_TYPE. + Returns: Account information. @@ -150,7 +164,6 @@ def get( """ filename = filename if filename else _DEFAULT_ACCOUNT_CONFIG_JSON_FILE filename = os.path.expanduser(filename) - cls.migrate(filename) if name: saved_account = read_config(filename=filename, name=name) if not saved_account: @@ -162,18 +175,20 @@ def get( if env_account is not None: return env_account - if channel: - saved_account = read_config( - filename=filename, - name=cls._get_default_account_name(channel=channel), - ) - if saved_account is None: - if os.path.isfile(_QISKITRC_CONFIG_FILE): - return cls._from_qiskitrc_file() - raise AccountNotFoundError(f"No default {channel} account saved.") + all_config = read_config(filename=filename) + # Get the default account for the given channel. + # If channel == None, get the default account, for any channel, if it exists + saved_account = cls._get_default_account(all_config, channel) + + if saved_account is not None: return Account.from_saved_format(saved_account) - all_config = read_config(filename=filename) + # Get the default account from the channel defined in the environment variable + account = cls._get_default_account(all_config, channel=channel_) + if account is not None: + return Account.from_saved_format(account) + + # check for any account for channel_type in _CHANNEL_TYPES: account_name = cls._get_default_account_name(channel=channel_type) if account_name in all_config: @@ -194,54 +209,12 @@ def delete( """Delete account from disk.""" filename = filename if filename else _DEFAULT_ACCOUNT_CONFIG_JSON_FILE filename = os.path.expanduser(filename) - cls.migrate(filename=filename) name = name or cls._get_default_account_name(channel) return delete_config( filename=filename, name=name, ) - @classmethod - def migrate(cls, filename: Optional[str] = None) -> None: - """Migrate accounts on disk by removing `auth` and adding `channel`.""" - filename = filename if filename else _DEFAULT_ACCOUNT_CONFIG_JSON_FILE - filename = os.path.expanduser(filename) - data = read_config(filename=filename) - for key, value in data.items(): - if key == _DEFAULT_ACCOUNT_NAME_CLOUD: - value.pop("auth", None) - value.update(channel="ibm_cloud") - delete_config(filename=filename, name=key) - save_config( - filename=filename, - name=_DEFAULT_ACCOUNT_NAME_IBM_CLOUD, - config=value, - overwrite=False, - ) - elif key == _DEFAULT_ACCOUNT_NAME_LEGACY: - value.pop("auth", None) - value.update(channel="ibm_quantum") - delete_config(filename=filename, name=key) - save_config( - filename=filename, - name=_DEFAULT_ACCOUNT_NAME_IBM_QUANTUM, - config=value, - overwrite=False, - ) - else: - if isinstance(value, dict) and "auth" in value: - if value["auth"] == "cloud": - value.update(channel="ibm_cloud") - elif value["auth"] == "legacy": - value.update(channel="ibm_quantum") - value.pop("auth", None) - save_config( - filename=filename, - name=key, - config=value, - overwrite=True, - ) - @classmethod def _from_env_variables(cls, channel: Optional[ChannelType]) -> Optional[Account]: """Read account from environment variable.""" @@ -249,13 +222,41 @@ def _from_env_variables(cls, channel: Optional[ChannelType]) -> Optional[Account url = os.getenv("QISKIT_IBM_URL") if not (token and url): return None - return Account( + return Account.create_account( token=token, url=url, instance=os.getenv("QISKIT_IBM_INSTANCE"), channel=channel, ) + @classmethod + def _get_default_account( + cls, all_config: dict, channel: Optional[str] = None + ) -> Optional[dict]: + default_channel_account = None + any_channel_account = None + + for account_name in all_config: + account = all_config[account_name] + if channel: + if account.get("channel") == channel and account.get("is_default_account"): + return account + if account.get( + "channel" + ) == channel and account_name == cls._get_default_account_name(channel): + default_channel_account = account + if account.get("channel") == channel: + any_channel_account = account + else: + if account.get("is_default_account"): + return account + + if default_channel_account: + return default_channel_account + elif any_channel_account: + return any_channel_account + return None + @classmethod def _get_default_account_name(cls, channel: ChannelType) -> str: return ( @@ -277,7 +278,7 @@ def _from_qiskitrc_file(cls) -> Optional[Account]: filename=_DEFAULT_ACCOUNT_CONFIG_JSON_FILE, name=_DEFAULT_ACCOUNT_NAME_IBM_QUANTUM, overwrite=False, - config=Account( + config=Account.create_account( token=qiskitrc_data.get("token", None), url=qiskitrc_data.get("url", None), instance=qiskitrc_data.get("default_provider", None), diff --git a/qiskit_ibm_runtime/accounts/storage.py b/qiskit_ibm_runtime/accounts/storage.py index db463de27..256432997 100644 --- a/qiskit_ibm_runtime/accounts/storage.py +++ b/qiskit_ibm_runtime/accounts/storage.py @@ -22,7 +22,9 @@ logger = logging.getLogger(__name__) -def save_config(filename: str, name: str, config: dict, overwrite: bool) -> None: +def save_config( + filename: str, name: str, config: dict, overwrite: bool, set_as_default: Optional[bool] = None +) -> None: """Save configuration data in a JSON file under the given name.""" logger.debug("Save configuration data for '%s' in '%s'", name, filename) _ensure_file_exists(filename) @@ -35,8 +37,24 @@ def save_config(filename: str, name: str, config: dict, overwrite: bool) -> None f"Named account ({name}) already exists. " f"Set overwrite=True to overwrite." ) + data[name] = config + + # if set_as_default, but another account is defined as default, user must specify overwrite to change + # the default account. + if set_as_default: + data[name]["is_default_account"] = True + for account_name in data: + account = data[account_name] + if account_name != name and account.get("is_default_account"): + if overwrite: + del account["is_default_account"] + else: + raise AccountAlreadyExistsError( + f"default_account ({name}) already exists. " + f"Set overwrite=True to overwrite." + ) + with open(filename, mode="w", encoding="utf-8") as json_out: - data[name] = config json.dump(data, json_out, sort_keys=True, indent=4) diff --git a/qiskit_ibm_runtime/api/clients/runtime.py b/qiskit_ibm_runtime/api/clients/runtime.py index 0176cd849..9222248e7 100644 --- a/qiskit_ibm_runtime/api/clients/runtime.py +++ b/qiskit_ibm_runtime/api/clients/runtime.py @@ -337,21 +337,42 @@ def job_metadata(self, job_id: str) -> Dict[str, Any]: """ return self._api.program_job(job_id).metadata() - def close_session(self, session_id: str) -> None: - """Close the runtime session. + def cancel_session(self, session_id: str) -> None: + """Close all jobs in the runtime session. Args: session_id: Session ID. """ + self._api.runtime_session(session_id=session_id).cancel() + + def close_session(self, session_id: str) -> None: + """Update session so jobs can no longer be submitted.""" self._api.runtime_session(session_id=session_id).close() - def list_backends(self, hgp: Optional[str] = None) -> List[str]: + def session_details(self, session_id: str) -> Dict[str, Any]: + """Get session details. + + Args: + session_id: Session ID. + + Returns: + Session details. + """ + return self._api.runtime_session(session_id=session_id).details() + + def list_backends( + self, hgp: Optional[str] = None, channel_strategy: Optional[str] = None + ) -> List[str]: """Return IBM backends available for this service instance. + Args: + hgp: Filter by hub/group/project. + channel_strategy: Filter by channel strategy. + Returns: IBM backends available for this service instance. """ - return self._api.backends(hgp=hgp)["devices"] + return self._api.backends(hgp=hgp, channel_strategy=channel_strategy)["devices"] def backend_configuration(self, backend_name: str) -> Dict[str, Any]: """Return the configuration of the IBM backend. @@ -390,9 +411,7 @@ def backend_properties( Raises: NotImplementedError: If `datetime` is specified. """ - if datetime: - raise NotImplementedError("'datetime' is not supported with cloud runtime.") - return self._api.backend(backend_name).properties() + return self._api.backend(backend_name).properties(datetime=datetime) def backend_pulse_defaults(self, backend_name: str) -> Dict: """Return the pulse defaults of the IBM backend. diff --git a/qiskit_ibm_runtime/api/rest/base.py b/qiskit_ibm_runtime/api/rest/base.py new file mode 100644 index 000000000..04e8ab0e6 --- /dev/null +++ b/qiskit_ibm_runtime/api/rest/base.py @@ -0,0 +1,57 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2021. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +"""Base REST adapter.""" + +from ..session import RetrySession + + +class RestAdapterBase: + """Base class for REST adapters.""" + + URL_MAP = {} # type: ignore[var-annotated] + """Mapping between the internal name of an endpoint and the actual URL.""" + + _HEADER_JSON_CONTENT = {"Content-Type": "application/json"} + + def __init__(self, session: RetrySession, prefix_url: str = "") -> None: + """RestAdapterBase constructor. + + Args: + session: Session to be used in the adapter. + prefix_url: String to be prepend to all URLs. + """ + self.session = session + self.prefix_url = prefix_url + + def get_url(self, identifier: str) -> str: + """Return the resolved URL for the specified identifier. + + Args: + identifier: Internal identifier of the endpoint. + + Returns: + The resolved URL of the endpoint (relative to the session base URL). + """ + return "{}{}".format(self.prefix_url, self.URL_MAP[identifier]) + + def get_prefixed_url(self, prefix: str, identifier: str) -> str: + """Return an adjusted URL for the specified identifier. + + Args: + prefix: string to be prepended to the URL. + identifier: Internal identifier of the endpoint. + + Returns: + The resolved facade URL of the endpoint. + """ + return "{}{}{}".format(prefix, self.prefix_url, self.URL_MAP[identifier]) diff --git a/qiskit_ibm_runtime/api/rest/cloud_backend.py b/qiskit_ibm_runtime/api/rest/cloud_backend.py index 237ceef06..d15f2fc47 100644 --- a/qiskit_ibm_runtime/api/rest/cloud_backend.py +++ b/qiskit_ibm_runtime/api/rest/cloud_backend.py @@ -12,7 +12,8 @@ """IBM Cloud Backend REST adapter.""" -from typing import Dict, Any +from typing import Dict, Any, Optional +from datetime import datetime as python_datetime from qiskit_ibm_provider.api.rest.base import RestAdapterBase from ..session import RetrySession @@ -48,14 +49,19 @@ def configuration(self) -> Dict[str, Any]: url = self.get_url("configuration") return self.session.get(url).json() - def properties(self) -> Dict[str, Any]: + def properties(self, datetime: Optional[python_datetime] = None) -> Dict[str, Any]: """Return backend properties. Returns: JSON response of backend properties. """ url = self.get_url("properties") - response = self.session.get(url).json() + + params = {} + if datetime: + params["updated_before"] = datetime.isoformat() + + response = self.session.get(url, params=params).json() # Adjust name of the backend. if response: response["backend_name"] = self.backend_name diff --git a/qiskit_ibm_runtime/api/rest/runtime.py b/qiskit_ibm_runtime/api/rest/runtime.py index 86c97bb69..df264c7a2 100644 --- a/qiskit_ibm_runtime/api/rest/runtime.py +++ b/qiskit_ibm_runtime/api/rest/runtime.py @@ -19,8 +19,8 @@ from qiskit_ibm_provider.api.rest.base import RestAdapterBase from qiskit_ibm_provider.api.rest.program_job import ProgramJob -from qiskit_ibm_provider.api.rest.runtime_session import RuntimeSession from qiskit_ibm_provider.utils import local_to_utc +from .runtime_session import RuntimeSession from .program import Program from ...utils import RuntimeEncoder @@ -271,17 +271,25 @@ def backend(self, backend_name: str) -> CloudBackend: return CloudBackend(self.session, backend_name) def backends( - self, hgp: Optional[str] = None, timeout: Optional[float] = None + self, + hgp: Optional[str] = None, + timeout: Optional[float] = None, + channel_strategy: Optional[str] = None, ) -> Dict[str, List[str]]: """Return a list of IBM backends. Args: + hgp: The service instance to use, only for ``ibm_quantum`` channel, in h/g/p format. timeout: Number of seconds to wait for the request. + channel_strategy: Error mitigation strategy. Returns: JSON response. """ url = self.get_url("backends") + params = {} if hgp: - return self.session.get(url, params={"provider": hgp}).json() - return self.session.get(url, timeout=timeout).json() + params["provider"] = hgp + if channel_strategy: + params["channel_strategy"] = channel_strategy + return self.session.get(url, params=params, timeout=timeout).json() diff --git a/qiskit_ibm_runtime/api/rest/runtime_session.py b/qiskit_ibm_runtime/api/rest/runtime_session.py new file mode 100644 index 000000000..fb4b3b944 --- /dev/null +++ b/qiskit_ibm_runtime/api/rest/runtime_session.py @@ -0,0 +1,63 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2022. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +"""Runtime Session REST adapter.""" + +from typing import Dict, Any +from .base import RestAdapterBase +from ..session import RetrySession +from ..exceptions import RequestsApiError +from ...exceptions import IBMRuntimeError + + +class RuntimeSession(RestAdapterBase): + """Rest adapter for session related endpoints.""" + + URL_MAP = { + "self": "", + "close": "/close", + } + + def __init__(self, session: RetrySession, session_id: str, url_prefix: str = "") -> None: + """Job constructor. + + Args: + session: RetrySession to be used in the adapter. + session_id: Job ID of the first job in a runtime session. + url_prefix: Prefix to use in the URL. + """ + super().__init__(session, "{}/sessions/{}".format(url_prefix, session_id)) + + def cancel(self) -> None: + """Cancel all jobs in the session.""" + url = self.get_url("close") + self.session.delete(url) + + def close(self) -> None: + """Set accepting_jobs flag to false, so no more jobs can be submitted.""" + payload = {"accepting_jobs": False} + url = self.get_url("self") + try: + self.session.patch(url, json=payload) + except RequestsApiError as ex: + if ex.status_code == 404: + pass + else: + raise IBMRuntimeError(f"Error closing session: {ex}") + + def details(self) -> Dict[str, Any]: + """Return the details of this session.""" + try: + return self.session.get(self.get_url("self")).json() + # return None if API is not supported + except: # pylint: disable=bare-except + return None diff --git a/qiskit_ibm_runtime/base_primitive.py b/qiskit_ibm_runtime/base_primitive.py index 50f08c023..3adc658b4 100644 --- a/qiskit_ibm_runtime/base_primitive.py +++ b/qiskit_ibm_runtime/base_primitive.py @@ -27,7 +27,6 @@ from .session import get_cm_session from .constants import DEFAULT_DECODERS from .qiskit_runtime_service import QiskitRuntimeService -from .utils.deprecation import issue_deprecation_msg # pylint: disable=unused-import,cyclic-import from .session import Session @@ -69,7 +68,6 @@ def __init__( # The base class, however, uses a `_run_options` which is an instance of # qiskit.providers.Options. We largely ignore this _run_options because we use # a nested dictionary to categorize options. - self._session: Optional[Session] = None self._service: QiskitRuntimeService = None self._backend: Optional[IBMBackend] = None @@ -80,38 +78,8 @@ def __init__( self._backend = self._service.backend( name=self._session.backend(), instance=self._session._instance ) - elif isinstance(session, IBMBackend): - issue_deprecation_msg( - msg="Passing a backend instance as the ``session`` parameter is deprecated", - version="0.10.0", - remedy="Please pass it as the ``backend`` parameter instead.", - ) - self._service = session.service - self._backend = session - elif isinstance(session, str): - issue_deprecation_msg( - msg="Passing a backend name as the ``session`` parameter is deprecated", - version="0.10.0", - remedy="Please pass it as the ``backend`` parameter instead.", - ) - self._service = ( - QiskitRuntimeService() - if QiskitRuntimeService.global_service is None - else QiskitRuntimeService.global_service - ) - self._backend = self._service.backend(session) - elif isinstance(backend, Session): - issue_deprecation_msg( - msg="``session`` is no longer the first parameter when initializing " - "a Qiskit Runtime primitive", - version="0.10.0", - remedy="Please use ``session=session`` instead.", - ) - self._session = backend - self._service = self._session.service - self._backend = self._service.backend( - name=self._session.backend(), instance=self._session._instance - ) + elif session is not None: + raise ValueError("session must be of type Session or None") elif isinstance(backend, IBMBackend): self._service = backend.service self._backend = backend diff --git a/qiskit_ibm_runtime/estimator.py b/qiskit_ibm_runtime/estimator.py index a579c6d0e..a22d1c911 100644 --- a/qiskit_ibm_runtime/estimator.py +++ b/qiskit_ibm_runtime/estimator.py @@ -16,6 +16,7 @@ import os from typing import Optional, Dict, Sequence, Any, Union, Mapping import logging +import typing import numpy as np from numpy.typing import ArrayLike @@ -28,7 +29,6 @@ from qiskit.circuit import Parameter from qiskit.primitives.base.base_primitive import _isreal -# TODO import _circuit_key from terra once 0.23 is released from .runtime_job import RuntimeJob from .ibm_backend import IBMBackend from .options import Options @@ -39,6 +39,9 @@ # pylint: disable=unused-import,cyclic-import from .session import Session +if typing.TYPE_CHECKING: + from qiskit.opflow import PauliSumOp + logger = logging.getLogger(__name__) @@ -103,9 +106,6 @@ class Estimator(BasePrimitive, BaseEstimator): parameter_values=[theta1]*2 ) print(psi1_H23.result()) - # Close the session only if all jobs are finished - # and you don't need to run more in the session - session.close() """ _PROGRAM_ID = "estimator" diff --git a/qiskit_ibm_runtime/exceptions.py b/qiskit_ibm_runtime/exceptions.py index 9137b4307..9b4b1ee1c 100644 --- a/qiskit_ibm_runtime/exceptions.py +++ b/qiskit_ibm_runtime/exceptions.py @@ -13,6 +13,7 @@ """Exceptions related to the IBM Runtime service.""" from qiskit.exceptions import QiskitError +from qiskit.providers.exceptions import JobTimeoutError, JobError class IBMError(QiskitError): @@ -69,7 +70,7 @@ class RuntimeProgramNotFound(IBMRuntimeError): pass -class RuntimeJobFailureError(IBMRuntimeError): +class RuntimeJobFailureError(JobError): """Error raised when a runtime job failed.""" pass @@ -87,7 +88,7 @@ class RuntimeInvalidStateError(IBMRuntimeError): pass -class RuntimeJobTimeoutError(IBMRuntimeError): +class RuntimeJobTimeoutError(JobTimeoutError): """Error raised when waiting for job times out.""" pass diff --git a/qiskit_ibm_runtime/ibm_backend.py b/qiskit_ibm_runtime/ibm_backend.py index fcf3f2536..a3da57099 100644 --- a/qiskit_ibm_runtime/ibm_backend.py +++ b/qiskit_ibm_runtime/ibm_backend.py @@ -228,7 +228,7 @@ def _get_properties(self, datetime: Optional[python_datetime] = None) -> None: """Gets backend properties and decodes it""" if datetime: datetime = local_to_utc(datetime) - if not self._properties: + if datetime or not self._properties: api_properties = self._api_client.backend_properties(self.name, datetime=datetime) if api_properties: backend_properties = properties_from_server_data(api_properties) @@ -241,9 +241,9 @@ def _get_defaults(self) -> None: if api_defaults: self._defaults = defaults_from_server_data(api_defaults) - def _convert_to_target(self) -> None: + def _convert_to_target(self, refresh: bool = False) -> None: """Converts backend configuration, properties and defaults to Target object""" - if not self._target: + if refresh or not self._target: self._target = convert_to_target( configuration=self._configuration, properties=self._properties, @@ -256,9 +256,6 @@ def _default_options(cls) -> Options: return Options( shots=4000, memory=False, - qubit_lo_freq=None, - meas_lo_freq=None, - schedule_los=None, meas_level=MeasLevel.CLASSIFIED, meas_return=MeasReturnType.AVERAGE, memory_slots=None, @@ -330,7 +327,7 @@ def target_history(self, datetime: Optional[python_datetime] = None) -> Target: """ self._get_properties(datetime=datetime) self._get_defaults() - self._convert_to_target() + self._convert_to_target(refresh=True) return self._target def properties( @@ -343,7 +340,7 @@ def properties( properties of the backend. The schema for backend properties can be found in - `Qiskit/ibm-quantum-schemas + `Qiskit/ibm-quantum-schemas/backend_properties `_. Args: @@ -374,8 +371,6 @@ def properties( if datetime: if not isinstance(datetime, python_datetime): raise TypeError("'{}' is not of type 'datetime'.") - if isinstance(self._api_client, RuntimeClient): - raise NotImplementedError("'datetime' is not supported by cloud runtime.") datetime = local_to_utc(datetime) if datetime or refresh or self._properties is None: api_properties = self._api_client.backend_properties(self.name, datetime=datetime) @@ -415,7 +410,7 @@ def defaults(self, refresh: bool = False) -> Optional[PulseDefaults]: """Return the pulse defaults for the backend. The schema for default pulse configuration can be found in - `Qiskit/ibm-quantum-schemas + `Qiskit/ibm-quantum-schemas/default_pulse_configuration `_. Args: @@ -443,7 +438,7 @@ def configuration( as its name, number of qubits, basis gates, coupling map, quantum volume, etc. The schema for backend configuration can be found in - `Qiskit/ibm-quantum-schemas + `Qiskit/ibm-quantum-schemas/backend_configuration `_. Returns: diff --git a/qiskit_ibm_runtime/options/environment_options.py b/qiskit_ibm_runtime/options/environment_options.py index becd2cdb4..ebb183adf 100644 --- a/qiskit_ibm_runtime/options/environment_options.py +++ b/qiskit_ibm_runtime/options/environment_options.py @@ -15,8 +15,6 @@ from typing import Optional, Callable, List, Literal, get_args from dataclasses import dataclass, field -from .utils import _flexible - LogLevelType = Literal[ "DEBUG", "INFO", @@ -26,7 +24,6 @@ ] -@_flexible @dataclass class EnvironmentOptions: """Options related to the execution environment. diff --git a/qiskit_ibm_runtime/options/execution_options.py b/qiskit_ibm_runtime/options/execution_options.py index 6dc063daa..aaf4fede6 100644 --- a/qiskit_ibm_runtime/options/execution_options.py +++ b/qiskit_ibm_runtime/options/execution_options.py @@ -16,7 +16,6 @@ from typing import Literal, get_args, Optional from numbers import Integral -from .utils import _flexible ExecutionSupportedOptions = Literal[ "shots", @@ -27,7 +26,6 @@ ] -@_flexible @dataclass class ExecutionOptions: """Execution options. diff --git a/qiskit_ibm_runtime/options/options.py b/qiskit_ibm_runtime/options/options.py index 75d4b428b..0e29d0532 100644 --- a/qiskit_ibm_runtime/options/options.py +++ b/qiskit_ibm_runtime/options/options.py @@ -19,7 +19,7 @@ from qiskit.transpiler import CouplingMap -from .utils import _flexible, Dict, _remove_dict_none_values +from .utils import Dict, _to_obj, _remove_dict_none_values from .environment_options import EnvironmentOptions from .execution_options import ExecutionOptions from .simulator_options import SimulatorOptions @@ -31,7 +31,6 @@ DDSequenceType = Literal[None, "XX", "XpXm", "XY4"] -@_flexible @dataclass class Options: """Options for the primitives. @@ -64,12 +63,16 @@ class Options: `_. for more information about the error mitigation methods used at each level. - max_execution_time: Maximum execution time in seconds. If - a job exceeds this time limit, it is forcibly cancelled. If ``None``, the - maximum execution time of the primitive is used. - This value must be in between 300 seconds and the - `system imposed maximum - `_. + max_execution_time: Maximum execution time in seconds, which is based + on system execution time (not wall clock time). System execution time is + the amount of time that the system is dedicated to processing your job. + If a job exceeds this time limit, it is forcibly cancelled. + Simulator jobs continue to use wall clock time. + + Refer to the + `Max execution time documentation + `_. + for more information. dynamical_decoupling: Optional, specify a dynamical decoupling sequence to use. Allowed values are ``"XX"``, ``"XpXm"``, ``"XY4"``. @@ -99,7 +102,6 @@ class Options: _MAX_OPTIMIZATION_LEVEL = 3 _MAX_RESILIENCE_LEVEL_ESTIMATOR = 3 _MAX_RESILIENCE_LEVEL_SAMPLER = 1 - _MIN_EXECUTION_TIME = 300 _MAX_EXECUTION_TIME = 8 * 60 * 60 # 8 hours for real device optimization_level: Optional[int] = None @@ -122,6 +124,14 @@ class Options: "twirling": TwirlingOptions, } + def __post_init__(self): # type: ignore + """Convert dictionary fields to object.""" + obj_fields = getattr(self, "_obj_fields", {}) + for key in list(obj_fields): + if hasattr(self, key): + orig_val = getattr(self, key) + setattr(self, key, _to_obj(obj_fields[key], orig_val)) + @staticmethod def _get_program_inputs(options: dict) -> dict: """Convert the input options to program compatible inputs. @@ -230,20 +240,44 @@ def validate_options(options: dict) -> None: ResilienceOptions.validate_resilience_options(options.get("resilience")) TranspilationOptions.validate_transpilation_options(options.get("transpilation")) execution_time = options.get("max_execution_time") - if not execution_time is None: - if ( - execution_time < Options._MIN_EXECUTION_TIME - or execution_time > Options._MAX_EXECUTION_TIME - ): + if execution_time is not None: + if execution_time > Options._MAX_EXECUTION_TIME: raise ValueError( - f"max_execution_time must be between " - f"{Options._MIN_EXECUTION_TIME} and {Options._MAX_EXECUTION_TIME} seconds." + f"max_execution_time must be below " f"{Options._MAX_EXECUTION_TIME} seconds." ) EnvironmentOptions.validate_environment_options(options.get("environment")) ExecutionOptions.validate_execution_options(options.get("execution")) SimulatorOptions.validate_simulator_options(options.get("simulator")) + @staticmethod + def _remove_none_values(options: dict) -> dict: + """Remove `None` values from the options dictionary.""" + new_options = {} + for key, value in options.items(): + if value is not None: + if isinstance(value, dict): + new_suboptions = {} + for subkey, subvalue in value.items(): + if subvalue is not None: + new_suboptions[subkey] = subvalue + new_options[key] = new_suboptions + else: + new_options[key] = value + + return new_options + + @staticmethod + def _set_default_resilience_options(options: dict) -> dict: + """Set default resilience options for resilience level 2.""" + if options["resilience_level"] == 2: + if not options["resilience"]["noise_factors"]: + options["resilience"]["noise_factors"] = (1, 3, 5) + if not options["resilience"]["extrapolator"]: + options["resilience"]["extrapolator"] = "LinearExtrapolator" + + return options + @staticmethod def _get_runtime_options(options: dict) -> dict: """Extract runtime options. diff --git a/qiskit_ibm_runtime/options/resilience_options.py b/qiskit_ibm_runtime/options/resilience_options.py index 6ace12515..1c66e6645 100644 --- a/qiskit_ibm_runtime/options/resilience_options.py +++ b/qiskit_ibm_runtime/options/resilience_options.py @@ -15,19 +15,13 @@ from typing import Sequence, Literal, get_args, Union from dataclasses import dataclass -from .utils import _flexible -from ..utils.deprecation import issue_deprecation_msg, deprecate_arguments - ResilienceSupportedOptions = Literal[ "noise_amplifier", "noise_factors", "extrapolator", ] NoiseAmplifierType = Literal[ - "TwoQubitAmplifier", - "GlobalFoldingAmplifier", "LocalFoldingAmplifier", - "CxAmplifier", ] ExtrapolatorType = Literal[ "LinearExtrapolator", @@ -48,7 +42,6 @@ ] -@_flexible @dataclass class ResilienceOptions: """Resilience options. @@ -59,10 +52,9 @@ class ResilienceOptions: Only applicable for ``resilience_level=2``. Default: (1, 3, 5) if resilience level is 2. Otherwise ``None``. - noise_amplifier (DEPRECATED): A noise amplification strategy. One of ``"TwoQubitAmplifier"``, - ``"GlobalFoldingAmplifier"``, ``"LocalFoldingAmplifier"``, ``"CxAmplifier"``. - Only applicable for ``resilience_level=2``. - Default: "TwoQubitAmplifier" if resilience level is 2. Otherwise ``None``. + noise_amplifier (DEPRECATED): A noise amplification strategy. Currently only + ``"LocalFoldingAmplifier"`` is supported Only applicable for ``resilience_level=2``. + Default: "LocalFoldingAmplifier". extrapolator (DEPRECATED): An extrapolation strategy. One of ``"LinearExtrapolator"``, ``"QuadraticExtrapolator"``, ``"CubicExtrapolator"``, ``"QuarticExtrapolator"``. @@ -139,42 +131,19 @@ def validate_resilience_options(resilience_options: dict) -> None: ValueError: if extrapolator == "CubicExtrapolator" and number of noise_factors < 4. TypeError: if an input value has an invalid type. """ - noise_amplifier = resilience_options.get("noise_amplifier") - if noise_amplifier is not None: - issue_deprecation_msg( - msg="The 'noise_amplifier' resilience option is deprecated", - version="0.12.0", - period="1 month", - remedy="After the deprecation period, only local folding amplification " - "will be supported. " - "Refer to https://github.com/qiskit-community/prototype-zne " - "for global folding amplification in ZNE.", - ) - if noise_amplifier not in get_args(NoiseAmplifierType): - raise ValueError( - f"Unsupported value {noise_amplifier} for noise_amplifier. " - f"Supported values are {get_args(NoiseAmplifierType)}" - ) - - if resilience_options.get("noise_factors", None) is not None: - deprecate_arguments( - deprecated="noise_factors", - version="0.13.0", - remedy="Please use 'zne_noise_factors' instead.", + noise_amplifier = resilience_options.get("noise_amplifier") or "LocalFoldingAmplifier" + if noise_amplifier not in get_args(NoiseAmplifierType): + raise ValueError( + f"Unsupported value {noise_amplifier} for noise_amplifier. " + f"Supported values are {get_args(NoiseAmplifierType)}" ) extrapolator = resilience_options.get("extrapolator") - if extrapolator is not None: - deprecate_arguments( - deprecated="extrapolator", - version="0.13.0", - remedy="Please use 'zne_extrapolator' instead.", + if extrapolator and extrapolator not in get_args(ExtrapolatorType): + raise ValueError( + f"Unsupported value {extrapolator} for extrapolator. " + f"Supported values are {get_args(ExtrapolatorType)}" ) - if extrapolator not in get_args(ExtrapolatorType): - raise ValueError( - f"Unsupported value {extrapolator} for extrapolator. " - f"Supported values are {get_args(ExtrapolatorType)}" - ) if ( extrapolator == "QuarticExtrapolator" diff --git a/qiskit_ibm_runtime/options/simulator_options.py b/qiskit_ibm_runtime/options/simulator_options.py index 85847282e..5506e4845 100644 --- a/qiskit_ibm_runtime/options/simulator_options.py +++ b/qiskit_ibm_runtime/options/simulator_options.py @@ -19,10 +19,7 @@ from qiskit.exceptions import MissingOptionalLibraryError from qiskit.providers import BackendV1, BackendV2 from qiskit.utils import optionals - -from qiskit.transpiler import CouplingMap - -from .utils import _flexible +from qiskit.transpiler import CouplingMap # pylint: disable=unused-import if TYPE_CHECKING: import qiskit_aer @@ -35,7 +32,6 @@ ] -@_flexible @dataclass() class SimulatorOptions: """Simulator options. @@ -78,6 +74,9 @@ def set_backend(self, backend: Union[BackendV1, BackendV2]) -> None: Args: backend: backend to be set. + + Raises: + MissingOptionalLibraryError if qiskit-aer is not found. """ if not optionals.HAS_AER: raise MissingOptionalLibraryError( diff --git a/qiskit_ibm_runtime/options/transpilation_options.py b/qiskit_ibm_runtime/options/transpilation_options.py index 90200f8ea..fb3e96ae6 100644 --- a/qiskit_ibm_runtime/options/transpilation_options.py +++ b/qiskit_ibm_runtime/options/transpilation_options.py @@ -15,7 +15,6 @@ from typing import Optional, List, Union, Literal, get_args from dataclasses import dataclass -from .utils import _flexible TranspilationSupportedOptions = Literal[ "skip_transpilation", @@ -39,7 +38,6 @@ ] -@_flexible @dataclass class TranspilationOptions: """Transpilation options. diff --git a/qiskit_ibm_runtime/options/twirling_options.py b/qiskit_ibm_runtime/options/twirling_options.py index 13fbc9af4..5d14507f2 100644 --- a/qiskit_ibm_runtime/options/twirling_options.py +++ b/qiskit_ibm_runtime/options/twirling_options.py @@ -15,8 +15,6 @@ from typing import Literal, get_args from dataclasses import dataclass -from .utils import _flexible - TwirlingStrategyType = Literal[ None, @@ -27,7 +25,6 @@ ] -@_flexible @dataclass class TwirlingOptions: """Twirling options. diff --git a/qiskit_ibm_runtime/options/utils.py b/qiskit_ibm_runtime/options/utils.py index 58e8a98da..0968c0ace 100644 --- a/qiskit_ibm_runtime/options/utils.py +++ b/qiskit_ibm_runtime/options/utils.py @@ -13,7 +13,6 @@ """Utility functions for options.""" from typing import Optional -from dataclasses import fields, field, make_dataclass from ..ibm_backend import IBMBackend @@ -76,49 +75,6 @@ def _to_obj(cls_, data): # type: ignore ) -def _post_init(self): # type: ignore - """Convert dictionary fields to object.""" - - obj_fields = getattr(self, "_obj_fields", {}) - for key in obj_fields.keys(): - if hasattr(self, key): - orig_val = getattr(self, key) - setattr(self, key, _to_obj(obj_fields[key], orig_val)) - - -def _flexible(cls): # type: ignore - """Decorator used to allow a flexible dataclass. - - This is used to dynamically create a new dataclass with the - arbitrary kwargs input converted to fields. It also converts - input dictionary to objects based on the _obj_fields attribute. - """ - - def __new__(cls, *_, **kwargs): # type: ignore - all_fields = [] - orig_field_names = set() - - for fld in fields(cls): - all_fields.append((fld.name, fld.type, fld)) - orig_field_names.add(fld.name) - - for key, val in kwargs.items(): - if key not in orig_field_names: - all_fields.append((key, type(val), field(default=None))) - - new_cls = make_dataclass( - cls.__name__, - all_fields, - bases=(cls,), - namespace={"__post_init__": _post_init}, - ) - obj = object.__new__(new_cls) - return obj - - cls.__new__ = __new__ - return cls - - class Dict: """Fake Dict type. diff --git a/qiskit_ibm_runtime/program/program_backend.py b/qiskit_ibm_runtime/program/program_backend.py index 33e1de425..6225dfdce 100644 --- a/qiskit_ibm_runtime/program/program_backend.py +++ b/qiskit_ibm_runtime/program/program_backend.py @@ -16,7 +16,6 @@ from typing import Union, List, Dict from abc import abstractmethod, ABC -from qiskit.pulse import Schedule from qiskit.providers.backend import BackendV1 as Backend from qiskit.providers.job import JobV1 as Job from qiskit.circuit import QuantumCircuit @@ -35,7 +34,7 @@ class ProgramBackend(Backend, ABC): @abstractmethod def run( self, - circuits: Union[QuantumCircuit, Schedule, List[Union[QuantumCircuit, Schedule]]], + circuits: Union[QuantumCircuit, List[QuantumCircuit]], **run_config: Dict, ) -> Job: """Run on the backend. @@ -47,8 +46,8 @@ def run( Args: circuits: An individual or a - list of :class:`~qiskit.circuits.QuantumCircuit` or - :class:`~qiskit.pulse.Schedule` objects to run on the backend. + list of :class:`~qiskit.circuits.QuantumCircuit` + to run on the backend. **run_config: Extra arguments used to configure the run. Returns: diff --git a/qiskit_ibm_runtime/qiskit_runtime_service.py b/qiskit_ibm_runtime/qiskit_runtime_service.py index a1ea28103..b6799de2a 100644 --- a/qiskit_ibm_runtime/qiskit_runtime_service.py +++ b/qiskit_ibm_runtime/qiskit_runtime_service.py @@ -34,7 +34,7 @@ from qiskit_ibm_provider.utils.backend_decoder import configuration_from_server_data from qiskit_ibm_runtime import ibm_backend -from .accounts import AccountManager, Account, AccountType, ChannelType +from .accounts import AccountManager, Account, ChannelType from .api.clients import AuthClient, VersionClient from .api.clients.runtime import RuntimeClient from .api.exceptions import RequestsApiError @@ -99,9 +99,6 @@ class QiskitRuntimeService(Provider): circuits=[psi], observables=[H1], parameter_values=[theta] ) print(f"Estimator results: {job.result()}") - # Close the session only if all jobs are finished - # and you don't need to run more in the session. - session.close() The example above uses the dedicated :class:`~qiskit_ibm_runtime.Sampler` and :class:`~qiskit_ibm_runtime.Estimator` classes. You can also @@ -139,6 +136,7 @@ def __init__( - Account with the input `name`, if specified. - Default account for the `channel` type, if `channel` is specified but `token` is not. - Account defined by the input `channel` and `token`, if specified. + - Account defined by the `default_channel` if defined in filename - Account defined by the environment variables, if defined. - Default account for the ``ibm_cloud`` account, if one is available. - Default account for the ``ibm_quantum`` account, if one is available. @@ -218,6 +216,10 @@ def __init__( for backend_name in hgp.backends: if backend_name not in self._backends: self._backends[backend_name] = None + self._current_instance = self._account.instance + if not self._current_instance: + self._current_instance = self._get_hgp().name + logger.info("Default instance: %s", self._current_instance) QiskitRuntimeService.global_service = self # TODO - it'd be nice to allow some kind of autocomplete, but `service.ibmq_foo` @@ -230,7 +232,6 @@ def _discover_account( url: Optional[str] = None, instance: Optional[str] = None, channel: Optional[ChannelType] = None, - auth: Optional[AccountType] = None, filename: Optional[str] = None, name: Optional[str] = None, proxies: Optional[ProxyConfiguration] = None, @@ -250,29 +251,26 @@ def _discover_account( ) if name: if filename: - if any([auth, channel, token, url]): + if any([channel, token, url]): logger.warning( - "Loading account from file %s with name %s. Any input 'auth', " + "Loading account from file %s with name %s. Any input " "'channel', 'token' or 'url' are ignored.", filename, name, ) else: - if any([auth, channel, token, url]): + if any([channel, token, url]): logger.warning( - "Loading account with name %s. Any input 'auth', " + "Loading account with name %s. Any input " "'channel', 'token' or 'url' are ignored.", name, ) account = AccountManager.get(filename=filename, name=name) - elif auth or channel: - if auth and auth not in ["legacy", "cloud"]: - raise ValueError("'auth' can only be 'cloud' or 'legacy'") + elif channel: if channel and channel not in ["ibm_cloud", "ibm_quantum"]: raise ValueError("'channel' can only be 'ibm_cloud' or 'ibm_quantum'") - channel = channel or self._get_channel_for_auth(auth=auth) if token: - account = Account( + account = Account.create_account( channel=channel, token=token, url=url, @@ -288,9 +286,10 @@ def _discover_account( elif any([token, url]): # Let's not infer based on these attributes as they may change in the future. raise ValueError( - "'channel' or 'auth' is required if 'token', or 'url' is specified but 'name' is not." + "'channel' is required if 'token', or 'url' is specified but 'name' is not." ) + # channel is not defined yet, get it from the AccountManager if account is None: account = AccountManager.get(filename=filename) @@ -302,8 +301,7 @@ def _discover_account( account.verify = verify # resolve CRN if needed - if account.channel == "ibm_cloud": - self._resolve_crn(account) + self._resolve_crn(account) # ensure account is valid, fail early if not account.validate() @@ -317,7 +315,7 @@ def _discover_cloud_backends(self) -> Dict[str, "ibm_backend.IBMBackend"]: A dict of the remote backend instances, keyed by backend name. """ ret = OrderedDict() # type: ignore[var-annotated] - backends_list = self._api_client.list_backends() + backends_list = self._api_client.list_backends(channel_strategy=self._channel_strategy) for backend_name in backends_list: raw_config = self._api_client.backend_configuration(backend_name=backend_name) config = configuration_from_server_data( @@ -681,13 +679,6 @@ def delete_account( """ return AccountManager.delete(filename=filename, name=name, channel=channel) - @staticmethod - def _get_channel_for_auth(auth: str) -> str: - """Returns channel type based on auth""" - if auth == "legacy": - return "ibm_quantum" - return "ibm_cloud" - @staticmethod def save_account( token: Optional[str] = None, @@ -700,6 +691,7 @@ def save_account( verify: Optional[bool] = None, overwrite: Optional[bool] = False, channel_strategy: Optional[str] = None, + set_as_default: Optional[bool] = None, ) -> None: """Save the account to disk for future use. @@ -720,6 +712,8 @@ def save_account( verify: Verify the server's TLS certificate. overwrite: ``True`` if the existing account is to be overwritten. channel_strategy: Error mitigation strategy. + set_as_default: If ``True``, the account is saved in filename, + as the default account. """ AccountManager.save( @@ -733,6 +727,7 @@ def save_account( verify=verify, overwrite=overwrite, channel_strategy=channel_strategy, + set_as_default=set_as_default, ) @staticmethod @@ -976,15 +971,6 @@ def run( RuntimeProgramNotFound: If the program cannot be found. IBMRuntimeError: An error occurred running the program. """ - # TODO: Remove this after 3 months - if program_id in ["hello-world", "vqe", "qaoa"]: - raise IBMInputValueError( - "The hello-world, vqe, and qaoa programs have been retired in the " - "Qiskit Runtime service. Please visit https://qiskit.org/ecosystem/ibm-runtime " - "for an introduction on Sessions and Primitives, and to access " - "tutorials on how to execute VQE and QAOA using Qiskit Runtime Primitives." - ) - qrt_options: RuntimeOptions = options if options is None: qrt_options = RuntimeOptions() @@ -1003,6 +989,9 @@ def run( # Find the right hgp hgp = self._get_hgp(instance=qrt_options.instance, backend_name=qrt_options.backend) hgp_name = hgp.name + if hgp_name != self._current_instance: + self._current_instance = hgp_name + logger.info("Instance selected: %s", self._current_instance) backend = self.backend(name=qrt_options.backend, instance=hgp_name) status = backend.status() if status.operational is True and status.status_msg != "active": @@ -1027,6 +1016,12 @@ def run( if self._channel_strategy == "default" else self._channel_strategy, ) + if self._channel == "ibm_quantum": + messages = response.get("messages") + if messages: + warning_message = messages[0].get("data") + warnings.warn(warning_message) + except RequestsApiError as ex: if ex.status_code == 404: raise RuntimeProgramNotFound(f"Program not found: {ex.message}") from None @@ -1510,15 +1505,6 @@ def instances(self) -> List[str]: return list(self._hgps.keys()) return [] - @property - def auth(self) -> str: - """Return the authentication type used. - - Returns: - The authentication type used. - """ - return "cloud" if self._channel == "ibm_cloud" else "legacy" - @property def channel(self) -> str: """Return the channel type used. diff --git a/qiskit_ibm_runtime/runtime_job.py b/qiskit_ibm_runtime/runtime_job.py index b6d9eeb8d..2e359d5e2 100644 --- a/qiskit_ibm_runtime/runtime_job.py +++ b/qiskit_ibm_runtime/runtime_job.py @@ -46,7 +46,6 @@ from .api.exceptions import RequestsApiError from .api.client_parameters import ClientParameters from .utils.utils import CallableStr -from .utils.deprecation import issue_deprecation_msg logger = logging.getLogger(__name__) @@ -388,11 +387,6 @@ def metrics(self) -> Dict[str, Any]: IBMRuntimeError: If a network error occurred. """ try: - issue_deprecation_msg( - msg="The 'bss.seconds' attribute is deprecated", - version="0.11.1", - remedy="Use the 'usage.seconds' attribute instead.", - ) return self._api_client.job_metadata(self.job_id()) except RequestsApiError as err: raise IBMRuntimeError(f"Failed to get job metadata: {err}") from None @@ -458,12 +452,15 @@ def _set_status(self, job_response: Dict) -> None: """ try: reason = job_response["state"].get("reason") + reason_code = job_response["state"].get("reason_code") if reason: # TODO remove this in https://github.com/Qiskit/qiskit-ibm-runtime/issues/989 if reason.upper() == "RAN TOO LONG": self._reason = reason.upper() else: self._reason = reason + if reason_code: + self._reason = f"Error code {reason_code}; {self._reason}" self._status = self._status_from_job_response(job_response) except KeyError: raise IBMError(f"Unknown status: {job_response['state']['status']}") @@ -489,6 +486,7 @@ def _error_msg_from_job_response(self, response: Dict) -> str: Error message. """ status = response["state"]["status"].upper() + job_result_raw = self._download_external_result( self._api_client.job_results(job_id=self.job_id()) ) @@ -666,9 +664,9 @@ def usage_estimation(self) -> Dict[str, Any]: """Return the usage estimation infromation for this job. Returns: - ``quantum_seconds`` which is the estimated quantum time + ``quantum_seconds`` which is the estimated system execution time of the job in seconds. Quantum time represents the time that - the QPU complex is occupied exclusively by the job. + the system is dedicated to processing your job. """ if not self._usage_estimation: response = self._api_client.job_get(job_id=self.job_id()) diff --git a/qiskit_ibm_runtime/runtime_options.py b/qiskit_ibm_runtime/runtime_options.py index d795ec143..eea530628 100644 --- a/qiskit_ibm_runtime/runtime_options.py +++ b/qiskit_ibm_runtime/runtime_options.py @@ -58,8 +58,11 @@ def __init__( access to the target backend is randomly selected. job_tags: Tags to be assigned to the job. The tags can subsequently be used as a filter in the :meth:`jobs()` function call. - max_execution_time: Maximum execution time in seconds. If - a job exceeds this time limit, it is forcibly cancelled. + max_execution_time: Maximum execution time in seconds, which is based + on system execution time (not wall clock time). System execution time is the + amount of time that the system is dedicated to processing your job. If a job exceeds + this time limit, it is forcibly cancelled. Simulator jobs continue to use wall + clock time. session_time: Length of session in seconds. """ self.backend = backend diff --git a/qiskit_ibm_runtime/runtime_session.py b/qiskit_ibm_runtime/runtime_session.py deleted file mode 100644 index 737928bfd..000000000 --- a/qiskit_ibm_runtime/runtime_session.py +++ /dev/null @@ -1,146 +0,0 @@ -# This code is part of Qiskit. -# -# (C) Copyright IBM 2022. -# -# This code is licensed under the Apache License, Version 2.0. You may -# obtain a copy of this license in the LICENSE.txt file in the root directory -# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. -# -# Any modifications or derivative works of this code must retain this -# copyright notice, and modified files need to carry a notice indicating -# that they have been altered from the originals. - -"""Qiskit runtime session.""" - -from typing import Dict, Any, Optional, Type, Union -from types import TracebackType -import copy -from functools import wraps -from dataclasses import asdict - -from qiskit_ibm_runtime import qiskit_runtime_service # pylint: disable=unused-import -from .runtime_job import RuntimeJob -from .runtime_program import ParameterNamespace -from .runtime_options import RuntimeOptions -from .program.result_decoder import ResultDecoder - - -def _active_session(func): # type: ignore - """Decorator used to ensure the session is active.""" - - @wraps(func) - def _wrapper(self, *args, **kwargs): # type: ignore - if not self._active: - raise RuntimeError("The session is closed.") - return func(self, *args, **kwargs) - - return _wrapper - - -class RuntimeSession: - """Runtime Session""" - - def __init__( - self, - service: "qiskit_runtime_service.QiskitRuntimeService", - program_id: str, - inputs: Union[Dict, ParameterNamespace], - options: Optional[Union[RuntimeOptions, Dict]] = None, - max_time: Optional[int] = None, - ): - """RuntimeSession constructor. - Args: - service: Runtime service. - program_id: Program ID. - inputs: Initial program inputs. - options: Runtime options. - max_time: (DEPRECATED) Maximum amount of time, a runtime session can - be open before being forcibly closed. - """ - self._service = service - self._program_id = program_id - self._options: dict = options - if isinstance(options, RuntimeOptions): - self._options = asdict(options) - if max_time: - self._options["session_time"] = max_time - self._initial_inputs = inputs - self._initial_job: Optional[RuntimeJob] = None - self._job: Optional[RuntimeJob] = None - self._session_id: Optional[str] = None - self._active = True - self._start_session = True - - @_active_session - def write(self, **kwargs: Dict) -> None: - """Write to the session.""" - if self._session_id is None: - inputs = copy.copy(self._initial_inputs) - else: - inputs = {} - inputs.update(kwargs) - if self._session_id is None: - self._start_session = True - self._initial_job = self._run(inputs=inputs) - self._job = self._initial_job - self._session_id = self._job.job_id() - else: - self._start_session = False - self._options["session_time"] = None - self._job = self._run(inputs=inputs) - - def _run(self, inputs: Union[Dict, ParameterNamespace]) -> RuntimeJob: - """Run a program""" - return self._service.run( - program_id=self._program_id, - options=self._options, - inputs=inputs, - session_id=self._session_id, - start_session=self._start_session, - ) - - @_active_session - def read( - self, - timeout: Optional[float] = None, - decoder: Optional[Type[ResultDecoder]] = None, - ) -> Any: - """Read from the session. - Args: - timeout: Number of seconds to wait for job. - decoder: A :class:`ResultDecoder` subclass used to decode job results. - Returns: - Data returned from the session. - """ - return self._job.result(timeout=timeout, decoder=decoder) - - def info(self) -> Dict: - """Return session information. - Returns: - Session information. - """ - out = {} - if self._options: - out["backend"] = self._options["backend"] or "unknown" # type: ignore - if self._job: - out["job_id"] = self._job.job_id() - out["job_status"] = self._job.status() - out["backend"] = self._job.backend() - return out - - def close(self) -> None: - """Close the session.""" - self._active = False - if self._session_id: - self._service._api_client.close_session(self._session_id) - - def __enter__(self) -> "RuntimeSession": - return self - - def __exit__( - self, - exc_type: Optional[Type[BaseException]], - exc_val: Optional[BaseException], - exc_tb: Optional[TracebackType], - ) -> None: - self.close() diff --git a/qiskit_ibm_runtime/sampler.py b/qiskit_ibm_runtime/sampler.py index 81a69ca5a..0b19bcc5c 100644 --- a/qiskit_ibm_runtime/sampler.py +++ b/qiskit_ibm_runtime/sampler.py @@ -60,10 +60,6 @@ class Sampler(BasePrimitive, BaseSampler): print(f"Job result: {job.result()}") # You can run more jobs inside the session - - # Close the session only if all jobs are finished - # and you don't need to run more in the session. - session.close() """ def __init__( diff --git a/qiskit_ibm_runtime/session.py b/qiskit_ibm_runtime/session.py index 60054d61d..36f7a0f62 100644 --- a/qiskit_ibm_runtime/session.py +++ b/qiskit_ibm_runtime/session.py @@ -12,7 +12,7 @@ """Qiskit Runtime flexible session.""" -from typing import Dict, Optional, Type, Union, Callable +from typing import Dict, Optional, Type, Union, Callable, Any from types import TracebackType from functools import wraps from contextvars import ContextVar @@ -24,8 +24,6 @@ from .runtime_program import ParameterNamespace from .program.result_decoder import ResultDecoder from .ibm_backend import IBMBackend -from .utils.deprecation import issue_deprecation_msg -from .exceptions import IBMInputValueError def _active_session(func): # type: ignore @@ -64,9 +62,6 @@ class Session: job = sampler.run(ReferenceCircuits.bell()) print(f"Sampler job ID: {job.job_id()}") print(f"Sampler job result: {job.result()}") - # Close the session only if all jobs are finished and - # you don't need to run more in the session. - session.close() """ @@ -90,7 +85,7 @@ def __init__( max_time: (EXPERIMENTAL setting, can break between releases without warning) Maximum amount of time, a runtime session can be open before being forcibly closed. Can be specified as seconds (int) or a string like "2h 30m 40s". - This value must be in between 300 seconds and the + This value must be less than the `system imposed maximum `_. @@ -144,36 +139,19 @@ def run( inputs: Program input parameters. These input values are passed to the runtime program. options: Runtime options that control the execution environment. - See :class:`qiskit_ibm_runtime.RuntimeOptions` for all available options, - EXCEPT ``backend``, which should be specified during session initialization. + See :class:`qiskit_ibm_runtime.RuntimeOptions` for all available options. callback: Callback function to be invoked for any interim results and final result. Returns: Submitted job. - - Raises: - IBMInputValueError: If a backend is passed in through options that does not match - the current session backend. """ options = options or {} if "instance" not in options: options["instance"] = self._instance - if "backend" in options: - issue_deprecation_msg( - "'backend' is no longer a supported option within a session", - "0.9", - "Instead, specify a backend when creating a Session instance.", - 3, - ) - if self._backend and options["backend"] != self._backend: - raise IBMInputValueError( - f"The backend '{options['backend']}' is different from", - f"the session backend '{self._backend}'", - ) - else: - options["backend"] = self._backend + + options["backend"] = self._backend if not self._session_id: # TODO: What happens if session max time != first job max time? @@ -198,8 +176,16 @@ def run( return job + def cancel(self) -> None: + """Cancel all pending jobs in a session.""" + self._active = False + if self._session_id: + self._service._api_client.cancel_session(self._session_id) + def close(self) -> None: - """Close the session.""" + """Close the session so new jobs will no longer be accepted, but existing + queued or running jobs will run to completion. The session will be terminated once there + are no more pending jobs.""" self._active = False if self._session_id: self._service._api_client.close_session(self._session_id) @@ -212,6 +198,68 @@ def backend(self) -> Optional[str]: """ return self._backend + def status(self) -> Optional[str]: + """Return current session status. + + Returns: + The current status of the session, including: + Pending: Session is created but not active. + It will become active when the next job of this session is dequeued. + In progress, accepting new jobs: session is active and accepting new jobs. + In progress, not accepting new jobs: session is active and not accepting new jobs. + Closed: max_time expired or session was explicitly closed. + None: status details are not available. + """ + details = self.details() + if details: + state = details["state"] + accepting_jobs = details["accepting_jobs"] + if state in ["open", "inactive"]: + return "Pending" + if state == "active" and accepting_jobs: + return "In progress, accepting new jobs" + if state == "active" and not accepting_jobs: + return "In progress, not accepting new jobs" + return state.capitalize() + + return None + + def details(self) -> Optional[Dict[str, Any]]: + """Return session details. + + Returns: + A dictionary with the sessions details, including: + id: id of the session. + backend_name: backend used for the session. + interactive_timeout: The maximum idle time (in seconds) between jobs that + is allowed to occur before the session is deactivated. + max_time: Maximum allowed time (in seconds) for the session, subject to plan limits. + active_timeout: The maximum time (in seconds) a session can stay active. + state: State of the session - open, active, inactive, or closed. + accepting_jobs: Whether or not the session is accepting jobs. + last_job_started: Timestamp of when the last job in the session started. + last_job_completed: Timestamp of when the last job in the session completed. + started_at: Timestamp of when the session was started. + closed_at: Timestamp of when the session was closed. + """ + if self._session_id: + response = self._service._api_client.session_details(self._session_id) + if response: + return { + "id": response.get("id"), + "backend_name": response.get("backend_name"), + "interactive_timeout": response.get("interactive_ttl"), + "max_time": response.get("max_ttl"), + "active_timeout": response.get("active_ttl"), + "state": response.get("state"), + "accepting_jobs": response.get("accepting_jobs"), + "last_job_started": response.get("last_job_started"), + "last_job_completed": response.get("last_job_completed"), + "started_at": response.get("started_at"), + "closed_at": response.get("closed_at"), + } + return None + @property def session_id(self) -> str: """Return the session ID. @@ -230,6 +278,30 @@ def service(self) -> QiskitRuntimeService: """ return self._service + @classmethod + def from_id( + cls, + session_id: str, + service: Optional[QiskitRuntimeService] = None, + backend: Optional[Union[str, IBMBackend]] = None, + ) -> "Session": + """Construct a Session object with a given session_id + + Args: + session_id: the id of the session to be created. This can be an already + existing session id. + service: instance of the ``QiskitRuntimeService`` class. + backend: instance of :class:`qiskit_ibm_runtime.IBMBackend` class or + string name of backend. + + Returns: + A new Session with the given ``session_id`` + + """ + session = cls(service, backend) + session._session_id = session_id + return session + def __enter__(self) -> "Session": set_cm_session(self) return self @@ -241,6 +313,7 @@ def __exit__( exc_tb: Optional[TracebackType], ) -> None: set_cm_session(None) + self.close() # Default session diff --git a/qiskit_ibm_runtime/utils/backend_converter.py b/qiskit_ibm_runtime/utils/backend_converter.py index 241db1b3b..d889b11b6 100644 --- a/qiskit_ibm_runtime/utils/backend_converter.py +++ b/qiskit_ibm_runtime/utils/backend_converter.py @@ -127,7 +127,7 @@ def convert_to_target( target.granularity = configuration.timing_constraints.get("granularity") target.min_length = configuration.timing_constraints.get("min_length") target.pulse_alignment = configuration.timing_constraints.get("pulse_alignment") - target.aquire_alignment = configuration.timing_constraints.get("acquire_alignment") + target.acquire_alignment = configuration.timing_constraints.get("acquire_alignment") # If pulse defaults exists use that as the source of truth if defaults is not None: faulty_qubits = set() diff --git a/qiskit_ibm_runtime/utils/json.py b/qiskit_ibm_runtime/utils/json.py index d5f51935b..de477f982 100644 --- a/qiskit_ibm_runtime/utils/json.py +++ b/qiskit_ibm_runtime/utils/json.py @@ -207,6 +207,8 @@ def default(self, obj: Any) -> Any: # pylint: disable=arguments-differ return {"__type__": "ndarray", "__value__": obj.tolist()} value = _serialize_and_encode(obj, np.save, allow_pickle=False) return {"__type__": "ndarray", "__value__": value} + if isinstance(obj, np.int64): + return obj.item() if isinstance(obj, set): return {"__type__": "set", "__value__": list(obj)} if isinstance(obj, Result): diff --git a/qiskit_ibm_runtime/utils/qctrl.py b/qiskit_ibm_runtime/utils/qctrl.py index bc8338e47..f3acd1ec2 100644 --- a/qiskit_ibm_runtime/utils/qctrl.py +++ b/qiskit_ibm_runtime/utils/qctrl.py @@ -32,14 +32,10 @@ def validate(options: Dict[str, Any]) -> None: # Default validation otherwise. TranspilationOptions.validate_transpilation_options(options.get("transpilation")) execution_time = options.get("max_execution_time") - if not execution_time is None: - if ( - execution_time < Options._MIN_EXECUTION_TIME - or execution_time > Options._MAX_EXECUTION_TIME - ): + if execution_time is not None: + if execution_time > Options._MAX_EXECUTION_TIME: raise ValueError( - f"max_execution_time must be between " - f"{Options._MIN_EXECUTION_TIME} and {Options._MAX_EXECUTION_TIME} seconds." + f"max_execution_time must be below " f"{Options._MAX_EXECUTION_TIME} seconds." ) EnvironmentOptions.validate_environment_options(options.get("environment")) @@ -61,7 +57,7 @@ def _raise_if_error_in_options(options: Dict[str, Any]) -> None: arguments={}, ) - optimization_level = options.get("optimization_level", 1) + optimization_level = options.get("optimization_level", 3) _check_argument( optimization_level > 0, description="Q-CTRL Primitives do not support optimization level 0. Please\ diff --git a/releasenotes/notes/0.11/job-cost-estimation-d0ba83dbc95c3f67.yaml b/releasenotes/notes/0.11/job-cost-estimation-d0ba83dbc95c3f67.yaml index 39396bdbd..be82253c9 100644 --- a/releasenotes/notes/0.11/job-cost-estimation-d0ba83dbc95c3f67.yaml +++ b/releasenotes/notes/0.11/job-cost-estimation-d0ba83dbc95c3f67.yaml @@ -2,5 +2,5 @@ features: - | Added a new property, :meth:`~qiskit_ibm_runtime.RuntimeJob.usage_estimation` - that returns the estimated running time, ``quantum_seconds``. Quantum time - represents the time that the QPU complex is occupied exclusively by the job. + that returns the estimated system execution time, ``quantum_seconds``. System execution time + represents the amount of time that the system is dedicated to processing your job. diff --git a/releasenotes/notes/0.12/backend-properties-datetime-0fe6a364c0a291d2.yaml b/releasenotes/notes/0.12/backend-properties-datetime-0fe6a364c0a291d2.yaml new file mode 100644 index 000000000..72c922864 --- /dev/null +++ b/releasenotes/notes/0.12/backend-properties-datetime-0fe6a364c0a291d2.yaml @@ -0,0 +1,6 @@ +--- +fixes: + - | + Retrieving backend properties with :meth:`~qiskit_ibm_runtime.IBMBackend.properties` + now supports passing a ``datetime`` parameter to retrieve properties from a past date. + diff --git a/releasenotes/notes/0.12/channel-strategy-backend-filter-a4fe5248d9aea9c1.yaml b/releasenotes/notes/0.12/channel-strategy-backend-filter-a4fe5248d9aea9c1.yaml new file mode 100644 index 000000000..58c7cf712 --- /dev/null +++ b/releasenotes/notes/0.12/channel-strategy-backend-filter-a4fe5248d9aea9c1.yaml @@ -0,0 +1,6 @@ +--- +features: + - | + If using a ``channel_strategy``, only backends that support that ``channel_strategy`` + will be accessible to the user. + diff --git a/releasenotes/notes/data-tracking-updates-97327c62c51b5891.yaml b/releasenotes/notes/0.12/data-tracking-updates-97327c62c51b5891.yaml similarity index 100% rename from releasenotes/notes/data-tracking-updates-97327c62c51b5891.yaml rename to releasenotes/notes/0.12/data-tracking-updates-97327c62c51b5891.yaml diff --git a/releasenotes/notes/default-channel-strategy-6899049ad4a7321b.yaml b/releasenotes/notes/0.12/default-channel-strategy-6899049ad4a7321b.yaml similarity index 100% rename from releasenotes/notes/default-channel-strategy-6899049ad4a7321b.yaml rename to releasenotes/notes/0.12/default-channel-strategy-6899049ad4a7321b.yaml diff --git a/releasenotes/notes/0.12/default-resilience-options-7929458af000314f.yaml b/releasenotes/notes/0.12/default-resilience-options-7929458af000314f.yaml new file mode 100644 index 000000000..74c9e9dc9 --- /dev/null +++ b/releasenotes/notes/0.12/default-resilience-options-7929458af000314f.yaml @@ -0,0 +1,10 @@ +--- +fixes: + - | + The ``noise_factors`` and ``extrapolator`` options in :class:`qiskit_ibm_runtime.options.ResilienceOptions` + will now default to ``None`` unless ``resilience_level`` is set to 2. + Only options relevant to the resilience level will be set, so when using ``resilience_level`` + 2, ``noise_factors`` will still default to ``(1, 3, 5)`` and ``extrapolator`` will default to + ``LinearExtrapolator``. Additionally, options with a value of ``None`` will no longer be sent to + the server. + diff --git a/releasenotes/notes/0.12/default_account-13d86d50f5b1d972.yaml b/releasenotes/notes/0.12/default_account-13d86d50f5b1d972.yaml new file mode 100644 index 000000000..b84f81d21 --- /dev/null +++ b/releasenotes/notes/0.12/default_account-13d86d50f5b1d972.yaml @@ -0,0 +1,6 @@ +--- +features: + - | + Added the option to define a default account in the account json file. + To select an account as default, define ``set_as_default=True`` in + ``QiskitRuntimeService.save_account()``. diff --git a/releasenotes/notes/0.12/error-codes-82a392efad5963da.yaml b/releasenotes/notes/0.12/error-codes-82a392efad5963da.yaml new file mode 100644 index 000000000..5f95903a0 --- /dev/null +++ b/releasenotes/notes/0.12/error-codes-82a392efad5963da.yaml @@ -0,0 +1,5 @@ +--- +upgrade: + - | + Job error messages now include the error code. Error codes can be found in + https://docs.quantum-computing.ibm.com/errors. \ No newline at end of file diff --git a/releasenotes/notes/error-message-case-31b4b2b7a5a2f624.yaml b/releasenotes/notes/0.12/error-message-case-31b4b2b7a5a2f624.yaml similarity index 100% rename from releasenotes/notes/error-message-case-31b4b2b7a5a2f624.yaml rename to releasenotes/notes/0.12/error-message-case-31b4b2b7a5a2f624.yaml diff --git a/releasenotes/notes/0.12/from_id-23fc85f3fbf01e0b.yaml b/releasenotes/notes/0.12/from_id-23fc85f3fbf01e0b.yaml new file mode 100644 index 000000000..7e61ea5a5 --- /dev/null +++ b/releasenotes/notes/0.12/from_id-23fc85f3fbf01e0b.yaml @@ -0,0 +1,6 @@ +--- +features: + - | + Added new method ``Session.from_id`` which creates a new session with a given id. + + diff --git a/releasenotes/notes/0.12/job-quota-warning-0512f30571897f53.yaml b/releasenotes/notes/0.12/job-quota-warning-0512f30571897f53.yaml new file mode 100644 index 000000000..a484c733b --- /dev/null +++ b/releasenotes/notes/0.12/job-quota-warning-0512f30571897f53.yaml @@ -0,0 +1,7 @@ +--- +features: + - | + There will now be a warning if a user submits a job that is predicted to + exceed their system execution time monthly quota of 10 minutes. + This only applies to jobs run on real hardware in the instance ``ibm-q/open/main``. + If the job does end up exceeding the quota, it will be canceled. diff --git a/releasenotes/notes/0.12/max-execution-time-definition-196cb6297693c0f2.yaml b/releasenotes/notes/0.12/max-execution-time-definition-196cb6297693c0f2.yaml new file mode 100644 index 000000000..70f55273e --- /dev/null +++ b/releasenotes/notes/0.12/max-execution-time-definition-196cb6297693c0f2.yaml @@ -0,0 +1,7 @@ +--- +fixes: + - | + The `max_execution_time` option is now based on system execution time instead of wall clock time. + System execution time is the amount of time that the system is dedicated to processing your job. + If a job exceeds this time limit, it is forcibly cancelled. + Simulator jobs continue to use wall clock time. diff --git a/releasenotes/notes/q-ctrl-validation-08d249f1e84a43a5.yaml b/releasenotes/notes/0.12/q-ctrl-validation-08d249f1e84a43a5.yaml similarity index 100% rename from releasenotes/notes/q-ctrl-validation-08d249f1e84a43a5.yaml rename to releasenotes/notes/0.12/q-ctrl-validation-08d249f1e84a43a5.yaml diff --git a/releasenotes/notes/0.13/expose-session-details-c4a44316d30dad33.yaml b/releasenotes/notes/0.13/expose-session-details-c4a44316d30dad33.yaml new file mode 100644 index 000000000..6e525c509 --- /dev/null +++ b/releasenotes/notes/0.13/expose-session-details-c4a44316d30dad33.yaml @@ -0,0 +1,10 @@ +--- +features: + - | + Added a new method, :meth:`~qiskit_ibm_runtime.Session.details` that returns information + about a session, including: maximum session time, active time remaining, the current state, + and whether or not the session is accepting jobs. + + Also added :meth:`~qiskit_ibm_runtime.Session.status`, which returns the current status of + the session. + diff --git a/releasenotes/notes/0.13/fix_np_int64-864b605a88f57419.yaml b/releasenotes/notes/0.13/fix_np_int64-864b605a88f57419.yaml new file mode 100644 index 000000000..106c68469 --- /dev/null +++ b/releasenotes/notes/0.13/fix_np_int64-864b605a88f57419.yaml @@ -0,0 +1,5 @@ +--- +fixes: + - | + Fixed a bug where ``shots`` passed in as a numpy type were not being + serialized correctly. diff --git a/releasenotes/notes/0.13/log-instance-selected-a18c4791418b5e0d.yaml b/releasenotes/notes/0.13/log-instance-selected-a18c4791418b5e0d.yaml new file mode 100644 index 000000000..3d78f85d1 --- /dev/null +++ b/releasenotes/notes/0.13/log-instance-selected-a18c4791418b5e0d.yaml @@ -0,0 +1,7 @@ +--- +features: + - | + At initialization, if not passed in directly, the default ``instance`` selected by the provider + will be logged at the "INFO" level. When running a job, if the backend selected is not in + the default instance but in a different instance the user also has access to, that instance + will also be logged. diff --git a/releasenotes/notes/0.13/session-accepting-jobs-d7ef6b60c0f5527b.yaml b/releasenotes/notes/0.13/session-accepting-jobs-d7ef6b60c0f5527b.yaml new file mode 100644 index 000000000..1a3421a49 --- /dev/null +++ b/releasenotes/notes/0.13/session-accepting-jobs-d7ef6b60c0f5527b.yaml @@ -0,0 +1,13 @@ +--- +upgrade: + - | + :meth:`qiskit_ibm_runtime.Session.close` has been updated to mark a ``Session`` as no longer + accepting new jobs. The session won't accept more jobs but it will continue to run any + queued jobs until they are done or the max time expires. This will also happen + automatically when the session context manager is exited. When a session that is not accepting + jobs has run out of jobs to run, it's immediately closed, freeing up the backend to run more jobs rather + than wait for the interactive timeout. + + The old close method behavior has been moved to a new method, + :meth:`qiskit_ibm_runtime.Session.cancel`, where all queued jobs within a session are + cancelled and terminated. diff --git a/releasenotes/notes/0.13/target-history-date-bug-7d6dad84fc5b3d2e.yaml b/releasenotes/notes/0.13/target-history-date-bug-7d6dad84fc5b3d2e.yaml new file mode 100644 index 000000000..5c26b3065 --- /dev/null +++ b/releasenotes/notes/0.13/target-history-date-bug-7d6dad84fc5b3d2e.yaml @@ -0,0 +1,6 @@ +--- +fixes: + - | + Fixed a bug in :meth:`~qiskit_ibm_runtime.IBMBackend.target_history` where + the datetime parameter was not being used to retrieve backend properties from the + specified date. diff --git a/releasenotes/notes/remove_kwargs_options-9024d3ec6572a53e.yaml b/releasenotes/notes/remove_kwargs_options-9024d3ec6572a53e.yaml new file mode 100644 index 000000000..e40a4f40c --- /dev/null +++ b/releasenotes/notes/remove_kwargs_options-9024d3ec6572a53e.yaml @@ -0,0 +1,4 @@ +--- +features: + - | + Arbitrary keys and values are no longer allowed in ``Options``. diff --git a/requirements-dev.txt b/requirements-dev.txt index ecbaf24d0..8387babf6 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -18,7 +18,7 @@ ddt>=1.2.0,!=1.4.0,!=1.4.3 # Documentation nbsphinx -Sphinx>=5 +Sphinx>=6 sphinx-tabs>=1.1.11 sphinx-automodapi sphinx-autodoc-typehints<=1.19.2 @@ -26,4 +26,4 @@ sphinx-design>=0.4.0 sphinx-intl jupyter-sphinx reno>=2.11.0 -qiskit-sphinx-theme~=1.12.0 +qiskit-sphinx-theme~=1.16.0 diff --git a/test/account.py b/test/account.py index 1987eb591..65da49c73 100644 --- a/test/account.py +++ b/test/account.py @@ -152,6 +152,7 @@ def get_account_config_contents( instance=None, verify=None, proxies=None, + set_default=None, ): """Generate qiskitrc content""" if instance is None: @@ -177,4 +178,6 @@ def get_account_config_contents( out[name]["verify"] = verify if proxies is not None: out[name]["proxies"] = proxies + if set_default: + out[name]["is_default_account"] = True return out diff --git a/test/integration/test_account.py b/test/integration/test_account.py index 902bd1c0d..ffe8929e2 100644 --- a/test/integration/test_account.py +++ b/test/integration/test_account.py @@ -80,3 +80,16 @@ def test_resolve_crn_for_invalid_service_instance_name(self): token=self.dependencies.token, instance=service_instance_name, ) + + def test_logging_instance_at_init(self): + """Test instance is logged at initialization if instance not passed in.""" + if self.dependencies.channel == "ibm_cloud": + self.skipTest("Not supported on ibm_cloud") + + with self.assertLogs("qiskit_ibm_runtime", "INFO") as logs: + QiskitRuntimeService( + channel="ibm_quantum", + url=self.dependencies.url, + token=self.dependencies.token, + ) + self.assertIn("instance", logs.output[0]) diff --git a/test/integration/test_backend.py b/test/integration/test_backend.py index 0c51f1ca7..63a77d496 100644 --- a/test/integration/test_backend.py +++ b/test/integration/test_backend.py @@ -13,6 +13,7 @@ """Tests for backend functions using real runtime service.""" from unittest import SkipTest +from datetime import datetime, timedelta import copy from qiskit.transpiler.target import Target @@ -94,6 +95,14 @@ def test_backend_target(self): self.assertIsNotNone(backend.target) self.assertIsInstance(backend.target, Target) + @production_only + def test_backend_target_history(self): + """Check retrieving backend target_history.""" + backend = self.backend + with self.subTest(backend=backend.name): + self.assertIsNotNone(backend.target_history()) + self.assertIsNotNone(backend.target_history(datetime=datetime.now() - timedelta(30))) + def test_backend_max_circuits(self): """Check if the max_circuits property is set.""" backend = self.backend @@ -130,7 +139,11 @@ def test_backend_properties(self): with self.subTest(backend=backend.name): if backend.simulator: raise SkipTest("Skip since simulator does not have properties.") - self.assertIsNotNone(backend.properties()) + properties = backend.properties() + properties_today = backend.properties(datetime=datetime.today()) + self.assertIsNotNone(properties) + self.assertIsNotNone(properties_today) + self.assertEqual(properties.backend_version, properties_today.backend_version) @production_only def test_backend_pulse_defaults(self): diff --git a/test/integration/test_estimator.py b/test/integration/test_estimator.py index c3b533d22..48cca7087 100644 --- a/test/integration/test_estimator.py +++ b/test/integration/test_estimator.py @@ -22,7 +22,6 @@ from qiskit.primitives import BaseEstimator, EstimatorResult from qiskit_ibm_runtime import Estimator, Session -from qiskit_ibm_runtime.exceptions import RuntimeJobFailureError from ..decorators import run_integration_test from ..ibm_test_case import IBMIntegrationTestCase @@ -105,7 +104,6 @@ def test_estimator_session(self, service): self.assertIsInstance(result5, EstimatorResult) self.assertEqual(len(result5.values), len(circuits5)) self.assertEqual(len(result5.metadata), len(circuits5)) - session.close() @run_integration_test def test_estimator_callback(self, service): @@ -132,7 +130,6 @@ def _callback(job_id_, result_): self.assertTrue((result.values == ws_result_values).all()) self.assertEqual(len(job_ids), 1) self.assertEqual(job.job_id(), job_ids.pop()) - session.close() @run_integration_test def test_estimator_coeffs(self, service): @@ -182,25 +179,10 @@ def test_estimator_coeffs(self, service): chsh1_runtime = job1.result() chsh2_runtime = job2.result() - session.close() self.assertTrue(np.allclose(chsh1_terra.values, chsh1_runtime.values, rtol=0.3)) self.assertTrue(np.allclose(chsh2_terra.values, chsh2_runtime.values, rtol=0.3)) - @run_integration_test - def test_estimator_error_messages(self, service): - """Test that the correct error message is displayed""" - circuit = QuantumCircuit(2, 2) - circuit.h(0) - with Session(service, self.backend) as session: - estimator = Estimator(session=session) - job = estimator.run(circuits=circuit, observables="II") - with self.assertRaises(RuntimeJobFailureError) as err: - job.result() - self.assertIn("register name", str(err.exception)) - self.assertFalse("python -m uvicorn server.main" in str(err.exception)) - self.assertIn("register name", str(job.error_message())) - @run_integration_test def test_estimator_no_session(self, service): """Test estimator primitive without a session.""" diff --git a/test/integration/test_job.py b/test/integration/test_job.py index 8c63a8d8d..444d420f2 100644 --- a/test/integration/test_job.py +++ b/test/integration/test_job.py @@ -130,12 +130,6 @@ def test_run_program_override_max_execution_time(self, service): job.wait_for_final_state() self.assertEqual(job._api_client.job_get(job.job_id())["cost"], job_max_execution_time) - @run_integration_test - def test_invalid_max_execution_time_fails(self, service): - """Test that program fails when max_execution_time is less than 300.""" - with self.assertRaises(ValueError): - self._run_program(service, max_execution_time=299) - @run_integration_test @production_only def test_cancel_job_queued(self, service): @@ -284,6 +278,7 @@ def test_job_creation_date(self, service): for rjob in rjobs: self.assertTrue(rjob.creation_date) + @unittest.skip("Skipping until primitives add more logging") @run_integration_test def test_job_logs(self, service): """Test job logs.""" diff --git a/test/integration/test_options.py b/test/integration/test_options.py index eb51247e2..00db91774 100644 --- a/test/integration/test_options.py +++ b/test/integration/test_options.py @@ -70,7 +70,7 @@ def test_noise_model(self, service): @run_integration_test def test_simulator_transpile(self, service): """Test simulator transpile options.""" - backend = service.backends(simulator=True)[0] + backend = service.backend("ibmq_qasm_simulator") self.log.info("Using backend %s", backend.name) circ = QuantumCircuit(2, 2) @@ -109,7 +109,7 @@ def test_unsupported_input_combinations(self, service): obs = SparsePauliOp.from_list([("I", 1)]) options = Options() options.resilience_level = 3 - backend = service.backends(simulator=True)[0] + backend = service.backend("ibmq_qasm_simulator") with Session(service=service, backend=backend) as session: with self.assertRaises(ValueError) as exc: inst = Estimator(session=session, options=options) @@ -122,7 +122,7 @@ def test_default_resilience_settings(self, service): circ = QuantumCircuit(1) obs = SparsePauliOp.from_list([("I", 1)]) options = Options(resilience_level=2) - backend = service.backends(simulator=True)[0] + backend = service.backend("ibmq_qasm_simulator") with Session(service=service, backend=backend) as session: inst = Estimator(session=session, options=options) job = inst.run(circ, observables=obs) @@ -135,8 +135,8 @@ def test_default_resilience_settings(self, service): with Session(service=service, backend=backend) as session: inst = Estimator(session=session, options=options) job = inst.run(circ, observables=obs) - self.assertIsNone(job.inputs["resilience_settings"]["noise_factors"]) - self.assertIsNone(job.inputs["resilience_settings"]["extrapolator"]) + self.assertNotIn("noise_factors", job.inputs["resilience_settings"]) + self.assertNotIn("extrapolator", job.inputs["resilience_settings"]) @production_only @run_integration_test @@ -152,7 +152,7 @@ def test_all_resilience_levels(self, service): psi1 = RealAmplitudes(num_qubits=2, reps=2) h_1 = SparsePauliOp.from_list([("II", 1), ("IZ", 2), ("XI", 3)]) - backend = service.backends(simulator=True)[0] + backend = service.backend("ibmq_qasm_simulator") options = Options() options.simulator.coupling_map = [[0, 1], [1, 0]] diff --git a/test/integration/test_sampler.py b/test/integration/test_sampler.py index 0e26ab3e0..835b17493 100644 --- a/test/integration/test_sampler.py +++ b/test/integration/test_sampler.py @@ -70,7 +70,6 @@ def test_sampler_non_parameterized_circuits(self, service): for i in range(len(circuits3)): self.assertAlmostEqual(result3.quasi_dists[i][3], 0.5, delta=0.1) self.assertAlmostEqual(result3.quasi_dists[i][0], 0.5, delta=0.1) - session.close() @run_integration_test def test_sampler_primitive_parameterized_circuits(self, service): @@ -98,7 +97,6 @@ def test_sampler_primitive_parameterized_circuits(self, service): self.assertIsInstance(result, SamplerResult) self.assertEqual(len(result.quasi_dists), len(circuits0)) self.assertEqual(len(result.metadata), len(circuits0)) - session.close() @run_integration_test def test_sampler_skip_transpile(self, service): @@ -114,7 +112,6 @@ def test_sampler_skip_transpile(self, service): sampler.run(circuits=circ, skip_transpilation=True).result() # If transpilation not skipped the error would be something about cannot expand. self.assertIn("invalid instructions", err.exception.message) - session.close() @run_integration_test def test_sampler_optimization_level(self, service): @@ -129,7 +126,6 @@ def test_sampler_optimization_level(self, service): ) self.assertAlmostEqual(result.quasi_dists[0][3], 0.5, delta=0.1) self.assertAlmostEqual(result.quasi_dists[0][0], 0.5, delta=0.1) - session.close() @run_integration_test def test_sampler_callback(self, service): @@ -154,21 +150,6 @@ def _callback(job_id_, result_): self.assertEqual(result.quasi_dists, ws_result_quasi) self.assertEqual(len(job_ids), 1) self.assertEqual(job.job_id(), job_ids.pop()) - session.close() - - @run_integration_test - def test_sampler_error_messages(self, service): - """Test that the correct error message is displayed""" - circuit = QuantumCircuit(2, 2) - circuit.h(0) - with Session(service, self.backend) as session: - sampler = Sampler(session=session) - job = sampler.run(circuits=circuit) - with self.assertRaises(RuntimeJobFailureError) as err: - job.result() - self.assertIn("No counts for experiment", str(err.exception)) - self.assertFalse("python -m uvicorn server.main" in err.exception.message) - self.assertIn("No counts for experiment", str(job.error_message())) @run_integration_test def test_sampler_no_session(self, service): diff --git a/test/integration/test_session.py b/test/integration/test_session.py index cc3277bda..6b6bdb285 100644 --- a/test/integration/test_session.py +++ b/test/integration/test_session.py @@ -84,3 +84,16 @@ def test_using_correct_instance(self, service): job = sampler.run(ReferenceCircuits.bell(), shots=400) self.assertEqual(instance, backend._instance) self.assertEqual(instance, job.backend()._instance) + + @run_integration_test + def test_session_from_id(self, service): + """Test creating a session from a given id""" + backend = service.backend("ibmq_qasm_simulator") + with Session(service, backend=backend) as session: + sampler = Sampler(session=session) + job = sampler.run(ReferenceCircuits.bell(), shots=400) + session_id = job.session_id + new_session = Session.from_id(backend=backend, session_id=session_id) + sampler = Sampler(session=new_session) + job = sampler.run(ReferenceCircuits.bell(), shots=400) + self.assertEqual(session_id, job.session_id) diff --git a/test/unit/mock/fake_runtime_client.py b/test/unit/mock/fake_runtime_client.py index ab5daa13c..1f2c16fb8 100644 --- a/test/unit/mock/fake_runtime_client.py +++ b/test/unit/mock/fake_runtime_client.py @@ -20,6 +20,8 @@ from concurrent.futures import ThreadPoolExecutor from typing import Optional, Dict, Any, List +from qiskit.providers.exceptions import QiskitBackendNotFoundError + from qiskit_ibm_provider.utils.hgp import from_instance_format from qiskit_ibm_runtime.api.exceptions import RequestsApiError from qiskit_ibm_runtime.utils import RuntimeEncoder @@ -514,7 +516,10 @@ def _get_job(self, job_id: str, exclude_params: bool = None) -> Any: raise RequestsApiError("Job not found", status_code=404) return self._jobs[job_id] - def list_backends(self, hgp: Optional[str] = None) -> List[str]: + # pylint: disable=unused-argument + def list_backends( + self, hgp: Optional[str] = None, channel_strategy: Optional[str] = None + ) -> List[str]: """Return IBM backends available for this service instance.""" return [back.name for back in self._backends if back.has_access(hgp)] @@ -540,4 +545,4 @@ def _find_backend(self, backend_name): for back in self._backends: if back.name == backend_name: return back - raise ValueError(f"Backend {backend_name} not found") + raise QiskitBackendNotFoundError(f"Backend {backend_name} not found") diff --git a/test/unit/test_account.py b/test/unit/test_account.py index dce40f931..409166714 100644 --- a/test/unit/test_account.py +++ b/test/unit/test_account.py @@ -29,8 +29,6 @@ ) from qiskit_ibm_runtime.accounts.account import IBM_CLOUD_API_URL, IBM_QUANTUM_API_URL from qiskit_ibm_runtime.accounts.management import ( - _DEFAULT_ACCOUNT_NAME_LEGACY, - _DEFAULT_ACCOUNT_NAME_CLOUD, _DEFAULT_ACCOUNT_NAME_IBM_QUANTUM, _DEFAULT_ACCOUNT_NAME_IBM_CLOUD, ) @@ -44,14 +42,14 @@ custom_envs, ) -_TEST_IBM_QUANTUM_ACCOUNT = Account( +_TEST_IBM_QUANTUM_ACCOUNT = Account.create_account( channel="ibm_quantum", token="token-x", url="https://auth.quantum-computing.ibm.com/api", instance="ibm-q/open/main", ) -_TEST_IBM_CLOUD_ACCOUNT = Account( +_TEST_IBM_CLOUD_ACCOUNT = Account.create_account( channel="ibm_cloud", token="token-y", url="https://cloud.ibm.com", @@ -61,25 +59,6 @@ ), ) -_TEST_LEGACY_ACCOUNT = { - "auth": "legacy", - "token": "token-x", - "url": "https://auth.quantum-computing.ibm.com/api", - "instance": "ibm-q/open/main", -} - -_TEST_CLOUD_ACCOUNT = { - "auth": "cloud", - "token": "token-y", - "url": "https://cloud.ibm.com", - "instance": "crn:v1:bluemix:public:quantum-computing:us-east:a/...::", - "proxies": { - "username_ntlm": "bla", - "password_ntlm": "blub", - "urls": {"https": "127.0.0.1"}, - }, -} - _TEST_FILENAME = "/tmp/temp_qiskit_account.json" @@ -101,7 +80,7 @@ def test_invalid_channel(self): with self.assertRaises(InvalidAccountError) as err: invalid_channel: Any = "phantom" - Account( + Account.create_account( channel=invalid_channel, token=self.dummy_token, url=self.dummy_ibm_cloud_url, @@ -115,7 +94,7 @@ def test_invalid_token(self): for token in invalid_tokens: with self.subTest(token=token): with self.assertRaises(InvalidAccountError) as err: - Account( + Account.create_account( channel="ibm_cloud", token=token, url=self.dummy_ibm_cloud_url, @@ -131,7 +110,7 @@ def test_invalid_url(self): for params in subtests: with self.subTest(params=params): with self.assertRaises(InvalidAccountError) as err: - Account(**params, token=self.dummy_token).validate() + Account.create_account(**params, token=self.dummy_token).validate() self.assertIn("Invalid `url` value.", str(err.exception)) def test_invalid_instance(self): @@ -145,7 +124,7 @@ def test_invalid_instance(self): for params in subtests: with self.subTest(params=params): with self.assertRaises(InvalidAccountError) as err: - Account( + Account.create_account( **params, token=self.dummy_token, url=self.dummy_ibm_cloud_url ).validate() self.assertIn("Invalid `instance` value.", str(err.exception)) @@ -158,7 +137,7 @@ def test_invalid_channel_strategy(self): for params in subtests: with self.subTest(params=params): with self.assertRaises(InvalidAccountError) as err: - Account( + Account.create_account( **params, token=self.dummy_token, url=self.dummy_ibm_cloud_url, @@ -183,7 +162,7 @@ def test_invalid_proxy_config(self): for params in subtests: with self.subTest(params=params): with self.assertRaises(ValueError) as err: - Account( + Account.create_account( **params, channel="ibm_quantum", token=self.dummy_token, @@ -229,138 +208,6 @@ def test_save_without_overwrite(self): overwrite=False, ) - # TODO remove test when removing auth parameter - @temporary_account_config_file(contents={_DEFAULT_ACCOUNT_NAME_CLOUD: _TEST_CLOUD_ACCOUNT}) - @no_envs(["QISKIT_IBM_TOKEN"]) - def test_save_channel_ibm_cloud_over_auth_cloud_without_overwrite(self): - """Test to overwrite an existing auth "cloud" account with channel "ibm_cloud" - and without setting overwrite=True.""" - with self.assertRaises(AccountAlreadyExistsError): - AccountManager.save( - token=_TEST_IBM_CLOUD_ACCOUNT.token, - url=_TEST_IBM_CLOUD_ACCOUNT.url, - instance=_TEST_IBM_CLOUD_ACCOUNT.instance, - channel="ibm_cloud", - name=None, - overwrite=False, - ) - - # TODO remove test when removing auth parameter - @temporary_account_config_file(contents={_DEFAULT_ACCOUNT_NAME_LEGACY: _TEST_LEGACY_ACCOUNT}) - @no_envs(["QISKIT_IBM_TOKEN"]) - def test_save_channel_ibm_quantum_over_auth_legacy_without_overwrite(self): - """Test to overwrite an existing auth "legacy" account with channel "ibm_quantum" - and without setting overwrite=True.""" - with self.assertRaises(AccountAlreadyExistsError): - AccountManager.save( - token=_TEST_IBM_QUANTUM_ACCOUNT.token, - url=_TEST_IBM_QUANTUM_ACCOUNT.url, - instance=_TEST_IBM_QUANTUM_ACCOUNT.instance, - channel="ibm_quantum", - name=None, - overwrite=False, - ) - - # TODO remove test when removing auth parameter - @temporary_account_config_file(contents={_DEFAULT_ACCOUNT_NAME_LEGACY: _TEST_LEGACY_ACCOUNT}) - @no_envs(["QISKIT_IBM_TOKEN"]) - def test_save_channel_ibm_quantum_over_auth_legacy_with_overwrite(self): - """Test to overwrite an existing auth "legacy" account with channel "ibm_quantum" - and with setting overwrite=True.""" - AccountManager.save( - token=_TEST_IBM_QUANTUM_ACCOUNT.token, - url=_TEST_IBM_QUANTUM_ACCOUNT.url, - instance=_TEST_IBM_QUANTUM_ACCOUNT.instance, - channel="ibm_quantum", - name=None, - overwrite=True, - ) - self.assertEqual(_TEST_IBM_QUANTUM_ACCOUNT, AccountManager.get(channel="ibm_quantum")) - - # TODO remove test when removing auth parameter - @temporary_account_config_file(contents={_DEFAULT_ACCOUNT_NAME_CLOUD: _TEST_CLOUD_ACCOUNT}) - @no_envs(["QISKIT_IBM_TOKEN"]) - def test_save_channel_ibm_cloud_over_auth_cloud_with_overwrite(self): - """Test to overwrite an existing auth "cloud" account with channel "ibm_cloud" - and with setting overwrite=True.""" - AccountManager.save( - token=_TEST_IBM_CLOUD_ACCOUNT.token, - url=_TEST_IBM_CLOUD_ACCOUNT.url, - instance=_TEST_IBM_CLOUD_ACCOUNT.instance, - channel="ibm_cloud", - proxies=_TEST_IBM_CLOUD_ACCOUNT.proxies, - name=None, - overwrite=True, - channel_strategy="q-ctrl", - ) - self.assertEqual(_TEST_IBM_CLOUD_ACCOUNT, AccountManager.get(channel="ibm_cloud")) - - # TODO remove test when removing auth parameter - @temporary_account_config_file(contents={"personal-account": _TEST_CLOUD_ACCOUNT}) - def test_save_channel_ibm_cloud_with_name_over_auth_cloud_with_overwrite(self): - """Test to overwrite an existing named auth "cloud" account with channel "ibm_cloud" - and with setting overwrite=True.""" - AccountManager.save( - token=_TEST_IBM_CLOUD_ACCOUNT.token, - url=_TEST_IBM_CLOUD_ACCOUNT.url, - instance=_TEST_IBM_CLOUD_ACCOUNT.instance, - channel="ibm_cloud", - proxies=_TEST_IBM_CLOUD_ACCOUNT.proxies, - name="personal-account", - overwrite=True, - ) - self.assertEqual(_TEST_IBM_CLOUD_ACCOUNT, AccountManager.get(name="personal-account")) - - # TODO remove test when removing auth parameter - @temporary_account_config_file(contents={"personal-account": _TEST_CLOUD_ACCOUNT}) - def test_save_channel_ibm_cloud_with_name_over_auth_cloud_without_overwrite(self): - """Test to overwrite an existing named auth "cloud" account with channel "ibm_cloud" - and without setting overwrite=True.""" - with self.assertRaises(AccountAlreadyExistsError): - AccountManager.save( - token=_TEST_IBM_CLOUD_ACCOUNT.token, - url=_TEST_IBM_CLOUD_ACCOUNT.url, - instance=_TEST_IBM_CLOUD_ACCOUNT.instance, - channel="ibm_cloud", - proxies=_TEST_IBM_CLOUD_ACCOUNT.proxies, - name="personal-account", - overwrite=False, - ) - - # TODO remove test when removing auth parameter - @temporary_account_config_file(contents={"personal-account": _TEST_LEGACY_ACCOUNT}) - def test_save_channel_ibm_quantum_with_name_over_auth_legacy_with_overwrite(self): - """Test to overwrite an existing named auth "legacy" account with channel "ibm_quantum" - and with setting overwrite=True.""" - AccountManager.save( - token=_TEST_IBM_QUANTUM_ACCOUNT.token, - url=_TEST_IBM_QUANTUM_ACCOUNT.url, - instance=_TEST_IBM_QUANTUM_ACCOUNT.instance, - channel="ibm_quantum", - proxies=_TEST_IBM_QUANTUM_ACCOUNT.proxies, - name="personal-account", - overwrite=True, - ) - self.assertEqual(_TEST_IBM_QUANTUM_ACCOUNT, AccountManager.get(name="personal-account")) - - # TODO remove test when removing auth parameter - @temporary_account_config_file(contents={"personal-account": _TEST_LEGACY_ACCOUNT}) - def test_save_channel_ibm_quantum_with_name_over_auth_legacy_without_overwrite( - self, - ): - """Test to overwrite an existing named auth "legacy" account with channel "ibm_quantum" - and without setting overwrite=True.""" - with self.assertRaises(AccountAlreadyExistsError): - AccountManager.save( - token=_TEST_IBM_QUANTUM_ACCOUNT.token, - url=_TEST_IBM_QUANTUM_ACCOUNT.url, - instance=_TEST_IBM_QUANTUM_ACCOUNT.instance, - channel="ibm_quantum", - proxies=_TEST_IBM_QUANTUM_ACCOUNT.proxies, - name="personal-account", - overwrite=False, - ) - @temporary_account_config_file(contents={"conflict": _TEST_IBM_CLOUD_ACCOUNT.to_saved_format()}) def test_get_none(self): """Test to get an account with an invalid name.""" @@ -457,30 +304,15 @@ def test_list(self): self.assertEqual(accounts["key1"], _TEST_IBM_CLOUD_ACCOUNT) self.assertTrue(accounts["key2"], _TEST_IBM_QUANTUM_ACCOUNT) - with temporary_account_config_file( - contents={ - _DEFAULT_ACCOUNT_NAME_CLOUD: _TEST_CLOUD_ACCOUNT, - _DEFAULT_ACCOUNT_NAME_LEGACY: _TEST_CLOUD_ACCOUNT, - } - ), self.subTest("non-empty list of auth accounts"): - accounts = AccountManager.list() - - self.assertEqual(len(accounts), 2) - self.assertEqual(accounts[_DEFAULT_ACCOUNT_NAME_IBM_CLOUD], _TEST_IBM_CLOUD_ACCOUNT) - self.assertTrue(accounts[_DEFAULT_ACCOUNT_NAME_IBM_QUANTUM], _TEST_IBM_QUANTUM_ACCOUNT) - - with temporary_account_config_file(contents={}), self.subTest("empty list of accounts"): - self.assertEqual(len(AccountManager.list()), 0) - with temporary_account_config_file( contents={ "key1": _TEST_IBM_CLOUD_ACCOUNT.to_saved_format(), "key2": _TEST_IBM_QUANTUM_ACCOUNT.to_saved_format(), - _DEFAULT_ACCOUNT_NAME_IBM_CLOUD: Account( - "ibm_cloud", "token-ibm-cloud", instance="crn:123" + _DEFAULT_ACCOUNT_NAME_IBM_CLOUD: Account.create_account( + channel="ibm_cloud", token="token-ibm-cloud", instance="crn:123" ).to_saved_format(), - _DEFAULT_ACCOUNT_NAME_IBM_QUANTUM: Account( - "ibm_quantum", "token-ibm-quantum" + _DEFAULT_ACCOUNT_NAME_IBM_QUANTUM: Account.create_account( + channel="ibm_quantum", token="token-ibm-quantum" ).to_saved_format(), } ), self.subTest("filtered list of accounts"): @@ -504,35 +336,6 @@ def test_list(self): self.assertEqual(len(accounts), 1) self.assertListEqual(accounts, ["key1"]) - # TODO remove test when removing auth parameter - with temporary_account_config_file( - contents={ - "key1": _TEST_CLOUD_ACCOUNT, - "key2": _TEST_LEGACY_ACCOUNT, - _DEFAULT_ACCOUNT_NAME_CLOUD: _TEST_CLOUD_ACCOUNT, - _DEFAULT_ACCOUNT_NAME_LEGACY: _TEST_LEGACY_ACCOUNT, - } - ), self.subTest("filtered list of auth accounts"): - accounts = list(AccountManager.list(channel="ibm_cloud").keys()) - self.assertEqual(len(accounts), 2) - self.assertListEqual(accounts, [_DEFAULT_ACCOUNT_NAME_IBM_CLOUD, "key1"]) - - accounts = list(AccountManager.list(channel="ibm_quantum").keys()) - self.assertEqual(len(accounts), 2) - self.assertListEqual(accounts, [_DEFAULT_ACCOUNT_NAME_IBM_QUANTUM, "key2"]) - - accounts = list(AccountManager.list(channel="ibm_cloud", default=True).keys()) - self.assertEqual(len(accounts), 1) - self.assertListEqual(accounts, [_DEFAULT_ACCOUNT_NAME_IBM_CLOUD]) - - accounts = list(AccountManager.list(channel="ibm_cloud", default=False).keys()) - self.assertEqual(len(accounts), 1) - self.assertListEqual(accounts, ["key1"]) - - accounts = list(AccountManager.list(name="key1").keys()) - self.assertEqual(len(accounts), 1) - self.assertListEqual(accounts, ["key1"]) - @temporary_account_config_file( contents={ "key1": _TEST_IBM_CLOUD_ACCOUNT.to_saved_format(), @@ -555,32 +358,10 @@ def test_delete(self): self.assertTrue(len(AccountManager.list()) == 0) - @temporary_account_config_file( - contents={ - "key1": _TEST_CLOUD_ACCOUNT, - _DEFAULT_ACCOUNT_NAME_LEGACY: _TEST_LEGACY_ACCOUNT, - _DEFAULT_ACCOUNT_NAME_CLOUD: _TEST_CLOUD_ACCOUNT, - } - ) - def test_delete_auth(self): - """Test delete accounts already saved using auth.""" - - with self.subTest("delete named account"): - self.assertTrue(AccountManager.delete(name="key1")) - self.assertFalse(AccountManager.delete(name="key1")) - - with self.subTest("delete default auth='legacy' account using channel"): - self.assertTrue(AccountManager.delete(channel="ibm_quantum")) - - with self.subTest("delete default auth='cloud' account using channel"): - self.assertTrue(AccountManager.delete()) - - self.assertTrue(len(AccountManager.list()) == 0) - def test_delete_filename(self): """Test delete accounts with filename parameter.""" - filename = "~/account_to_delete.json" + filename = _TEST_FILENAME name = "key1" channel = "ibm_quantum" AccountManager.save(channel=channel, filename=filename, name=name, token="temp_token") @@ -606,6 +387,180 @@ def test_account_with_filename(self): ) self.assertEqual(account.token, dummy_token) + @temporary_account_config_file() + def test_default_env_channel(self): + """Test that if QISKIT_IBM_CHANNEL is set in the environment, this channel will be used""" + token = uuid.uuid4().hex + # unset default_channel in the environment + with temporary_account_config_file(token=token), no_envs("QISKIT_IBM_CHANNEL"): + service = FakeRuntimeService() + self.assertEqual(service.channel, "ibm_cloud") + + # set channel to default channel in the environment + subtests = ["ibm_quantum", "ibm_cloud"] + for channel in subtests: + channel_env = {"QISKIT_IBM_CHANNEL": channel} + with temporary_account_config_file(channel=channel, token=token), custom_envs( + channel_env + ): + service = FakeRuntimeService() + self.assertEqual(service.channel, channel) + + def test_save_default_account(self): + """Test that if a default_account is defined in the qiskit-ibm.json file, + this account will be used""" + AccountManager.save( + filename=_TEST_FILENAME, + name=_DEFAULT_ACCOUNT_NAME_IBM_CLOUD, + token=_TEST_IBM_CLOUD_ACCOUNT.token, + url=_TEST_IBM_CLOUD_ACCOUNT.url, + instance=_TEST_IBM_CLOUD_ACCOUNT.instance, + channel="ibm_cloud", + overwrite=True, + set_as_default=True, + ) + AccountManager.save( + filename=_TEST_FILENAME, + name=_DEFAULT_ACCOUNT_NAME_IBM_QUANTUM, + token=_TEST_IBM_QUANTUM_ACCOUNT.token, + url=_TEST_IBM_QUANTUM_ACCOUNT.url, + instance=_TEST_IBM_QUANTUM_ACCOUNT.instance, + channel="ibm_quantum", + overwrite=True, + ) + + with no_envs("QISKIT_IBM_CHANNEL"), no_envs("QISKIT_IBM_TOKEN"): + account = AccountManager.get(filename=_TEST_FILENAME) + self.assertEqual(account.channel, "ibm_cloud") + self.assertEqual(account.token, _TEST_IBM_CLOUD_ACCOUNT.token) + + AccountManager.save( + filename=_TEST_FILENAME, + name=_DEFAULT_ACCOUNT_NAME_IBM_QUANTUM, + token=_TEST_IBM_QUANTUM_ACCOUNT.token, + url=_TEST_IBM_QUANTUM_ACCOUNT.url, + instance=_TEST_IBM_QUANTUM_ACCOUNT.instance, + channel="ibm_quantum", + overwrite=True, + set_as_default=True, + ) + with no_envs("QISKIT_IBM_CHANNEL"), no_envs("QISKIT_IBM_TOKEN"): + account = AccountManager.get(filename=_TEST_FILENAME) + self.assertEqual(account.channel, "ibm_quantum") + self.assertEqual(account.token, _TEST_IBM_QUANTUM_ACCOUNT.token) + + @temporary_account_config_file() + def test_set_channel_precedence(self): + """Test the precedence of the various methods to set the account: + account name > env_variables > channel parameter default account + > default account > default account from default channel""" + cloud_token = uuid.uuid4().hex + default_token = uuid.uuid4().hex + preferred_token = uuid.uuid4().hex + any_token = uuid.uuid4().hex + channel_env = {"QISKIT_IBM_CHANNEL": "ibm_cloud"} + contents = { + _DEFAULT_ACCOUNT_NAME_IBM_CLOUD: { + "channel": "ibm_cloud", + "token": cloud_token, + "instance": "some_instance", + }, + _DEFAULT_ACCOUNT_NAME_IBM_QUANTUM: { + "channel": "ibm_quantum", + "token": default_token, + }, + "preferred-ibm-quantum": { + "channel": "ibm_quantum", + "token": preferred_token, + "is_default_account": True, + }, + "any-quantum": { + "channel": "ibm_quantum", + "token": any_token, + }, + } + + # 'name' parameter + with temporary_account_config_file(contents=contents), custom_envs(channel_env), no_envs( + "QISKIT_IBM_TOKEN" + ): + service = FakeRuntimeService(name="any-quantum") + self.assertEqual(service.channel, "ibm_quantum") + self.assertEqual(service._account.token, any_token) + + # No name or channel params, no env vars, get the account specified as "is_default_account" + with temporary_account_config_file(contents=contents), no_envs( + "QISKIT_IBM_CHANNEL" + ), no_envs("QISKIT_IBM_TOKEN"): + service = FakeRuntimeService() + self.assertEqual(service.channel, "ibm_quantum") + self.assertEqual(service._account.token, preferred_token) + + # parameter 'channel' is specified, it overrides channel in env + # account specified as "is_default_account" + with temporary_account_config_file(contents=contents), custom_envs(channel_env), no_envs( + "QISKIT_IBM_TOKEN" + ): + service = FakeRuntimeService(channel="ibm_quantum") + self.assertEqual(service.channel, "ibm_quantum") + self.assertEqual(service._account.token, preferred_token) + + # account with default name for the channel + contents["preferred-ibm-quantum"]["is_default_account"] = False + with temporary_account_config_file(contents=contents), custom_envs(channel_env), no_envs( + "QISKIT_IBM_TOKEN" + ): + service = FakeRuntimeService(channel="ibm_quantum") + self.assertEqual(service.channel, "ibm_quantum") + self.assertEqual(service._account.token, default_token) + + # any account for this channel + del contents["default-ibm-quantum"] + # channel_env = {"QISKIT_IBM_CHANNEL": "ibm_quantum"} + with temporary_account_config_file(contents=contents), custom_envs(channel_env), no_envs( + "QISKIT_IBM_TOKEN" + ): + service = FakeRuntimeService(channel="ibm_quantum") + self.assertEqual(service.channel, "ibm_quantum") + self.assertEqual(service._account.token, any_token) + + # no channel param, get account that is specified as "is_default_account" + # for channel from env + contents["preferred-ibm-quantum"]["is_default_account"] = True + with temporary_account_config_file(contents=contents), custom_envs(channel_env), no_envs( + "QISKIT_IBM_TOKEN" + ): + service = FakeRuntimeService() + self.assertEqual(service.channel, "ibm_quantum") + self.assertEqual(service._account.token, preferred_token) + + # no channel param, account with default name for the channel from env + del contents["preferred-ibm-quantum"]["is_default_account"] + contents["default-ibm-quantum"] = { + "channel": "ibm_quantum", + "token": default_token, + } + channel_env = {"QISKIT_IBM_CHANNEL": "ibm_quantum"} + with temporary_account_config_file(contents=contents), custom_envs(channel_env), no_envs( + "QISKIT_IBM_TOKEN" + ): + service = FakeRuntimeService() + self.assertEqual(service.channel, "ibm_quantum") + self.assertEqual(service._account.token, default_token) + + # no channel param, any account for the channel from env + del contents["default-ibm-quantum"] + with temporary_account_config_file(contents=contents), custom_envs(channel_env), no_envs( + "QISKIT_IBM_TOKEN" + ): + service = FakeRuntimeService() + self.assertEqual(service.channel, "ibm_quantum") + self.assertEqual(service._account.token, any_token) + # default channel + with temporary_account_config_file(contents=contents), no_envs("QISKIT_IBM_CHANNEL"): + service = FakeRuntimeService() + self.assertEqual(service.channel, "ibm_cloud") + def tearDown(self) -> None: """Test level tear down.""" super().tearDown() @@ -735,7 +690,10 @@ def test_enable_account_both_channel(self): token = uuid.uuid4().hex contents = get_account_config_contents(channel="ibm_cloud", token=token) contents.update(get_account_config_contents(channel="ibm_quantum", token=uuid.uuid4().hex)) - with temporary_account_config_file(contents=contents), no_envs(["QISKIT_IBM_TOKEN"]): + + with temporary_account_config_file(contents=contents), no_envs( + ["QISKIT_IBM_TOKEN", "QISKIT_IBM_CHANNEL"] + ): service = FakeRuntimeService() self.assertTrue(service._account) self.assertEqual(service._account.token, token) @@ -754,7 +712,7 @@ def test_enable_account_by_env_channel(self): "QISKIT_IBM_URL": url, "QISKIT_IBM_INSTANCE": "h/g/p" if channel == "ibm_quantum" else "crn:12", } - with custom_envs(envs): + with custom_envs(envs), no_envs("QISKIT_IBM_CHANNEL"): service = FakeRuntimeService(channel=channel) self.assertTrue(service._account) @@ -871,7 +829,7 @@ def test_enable_account_by_env_pref(self): "QISKIT_IBM_URL": url, "QISKIT_IBM_INSTANCE": "my_crn", } - with custom_envs(envs): + with custom_envs(envs), no_envs("QISKIT_IBM_CHANNEL"): service = FakeRuntimeService(**extra) self.assertTrue(service._account) diff --git a/test/unit/test_data_serialization.py b/test/unit/test_data_serialization.py index 3d4786029..604b78cb1 100644 --- a/test/unit/test_data_serialization.py +++ b/test/unit/test_data_serialization.py @@ -20,46 +20,14 @@ from datetime import datetime import numpy as np -import scipy.sparse -from qiskit.algorithms.optimizers import ( - ADAM, - GSLS, - SPSA, - QNSPSA, - L_BFGS_B, - NELDER_MEAD, -) + from qiskit.circuit import Parameter, QuantumCircuit from qiskit.test.reference_circuits import ReferenceCircuits from qiskit.circuit.library import EfficientSU2, CXGate, PhaseGate, U2Gate -from qiskit.opflow import ( - PauliSumOp, - MatrixOp, - PauliOp, - CircuitOp, - EvolvedOp, - TaperedPauliSumOp, - Z2Symmetries, - I, - X, - Y, - Z, - StateFn, - CircuitStateFn, - DictStateFn, - VectorStateFn, - OperatorStateFn, - SparseVectorStateFn, - CVaRMeasurement, - ComposedOp, - SummedOp, - TensoredOp, -) from qiskit.providers.fake_provider import FakeNairobi from qiskit.quantum_info import SparsePauliOp, Pauli, Statevector from qiskit.result import Result from qiskit_aer.noise import NoiseModel - from qiskit_ibm_runtime.utils import RuntimeEncoder, RuntimeDecoder from .mock.fake_runtime_client import CustomResultRuntimeJob from .mock.fake_runtime_service import FakeRuntimeService @@ -125,73 +93,47 @@ def test_coder_qc(self): def test_coder_operators(self): """Test runtime encoder and decoder for operators.""" + + # filter warnings triggered by opflow imports + with warnings.catch_warnings(): + warnings.filterwarnings( + "ignore", category=DeprecationWarning, module=r"qiskit\.opflow\." + ) + from qiskit.opflow import PauliSumOp # pylint: disable=import-outside-toplevel + + # catch warnings triggered by opflow use + with warnings.catch_warnings(record=True) as w_log: + deprecated_op = PauliSumOp(SparsePauliOp(Pauli("XYZX"), coeffs=[2])) + self.assertTrue(len(w_log) > 0) + coeff_x = Parameter("x") coeff_y = coeff_x + 1 - quantum_circuit = QuantumCircuit(1) - quantum_circuit.h(0) - operator = 2.0 * I ^ I - z2_symmetries = Z2Symmetries( - [Pauli("IIZI"), Pauli("ZIII")], - [Pauli("IIXI"), Pauli("XIII")], - [1, 3], - [-1, 1], - ) - isqrt2 = 1 / np.sqrt(2) - sparse = scipy.sparse.csr_matrix([[0, isqrt2, 0, isqrt2]]) subtests = ( - PauliSumOp(SparsePauliOp(Pauli("XYZX"), coeffs=[2]), coeff=3), - PauliSumOp(SparsePauliOp(Pauli("XYZX"), coeffs=[1]), coeff=coeff_y), - PauliSumOp(SparsePauliOp(Pauli("XYZX"), coeffs=[1 + 2j]), coeff=3 - 2j), - PauliSumOp.from_list([("II", -1.052373245772859), ("IZ", 0.39793742484318045)]), - MatrixOp(primitive=np.array([[0, -1j], [1j, 0]]), coeff=coeff_x), - PauliOp(primitive=Pauli("Y"), coeff=coeff_x), - CircuitOp(quantum_circuit, coeff=coeff_x), - EvolvedOp(operator, coeff=coeff_x), - TaperedPauliSumOp(SparsePauliOp(Pauli("XYZX"), coeffs=[2]), z2_symmetries), - StateFn(quantum_circuit, coeff=coeff_x), - CircuitStateFn(quantum_circuit, is_measurement=True), - DictStateFn("1" * 3, is_measurement=True), - VectorStateFn(np.ones(2**3, dtype=complex)), - OperatorStateFn(CircuitOp(QuantumCircuit(1))), - SparseVectorStateFn(sparse), - Statevector([1, 0]), - CVaRMeasurement(Z, 0.2), - ComposedOp([(X ^ Y ^ Z), (Z ^ X ^ Y ^ Z).to_matrix_op()]), - SummedOp([X ^ X * 2, Y ^ Y], 2), - TensoredOp([(X ^ Y), (Z ^ I)]), - (Z ^ Z) ^ (I ^ 2), + SparsePauliOp(Pauli("XYZX"), coeffs=[2]), + SparsePauliOp(Pauli("XYZX"), coeffs=[coeff_y]), + SparsePauliOp(Pauli("XYZX"), coeffs=[1 + 2j]), + deprecated_op, ) + for operator in subtests: with self.subTest(operator=operator): encoded = json.dumps(operator, cls=RuntimeEncoder) self.assertIsInstance(encoded, str) - decoded = json.loads(encoded, cls=RuntimeDecoder) - self.assertEqual(operator, decoded) - def test_coder_optimizers(self): - """Test runtime encoder and decoder for optimizers.""" - subtests = ( - (ADAM, {"maxiter": 100, "amsgrad": True}), - (GSLS, {"maxiter": 50, "min_step_size": 0.01}), - (SPSA, {"maxiter": 10, "learning_rate": 0.01, "perturbation": 0.1}), - (QNSPSA, {"fidelity": 123, "maxiter": 25, "resamplings": {1: 100, 2: 50}}), - # some SciPy optimizers only work with default arguments due to Qiskit/qiskit-terra#6682 - (L_BFGS_B, {}), - (NELDER_MEAD, {}), - # Enable when https://github.com/scikit-quant/scikit-quant/issues/24 is fixed - # (IMFIL, {"maxiter": 20}), - # (SNOBFIT, {"maxiter": 200, "maxfail": 20}), - ) - for opt_cls, settings in subtests: - with self.subTest(opt_cls=opt_cls): - optimizer = opt_cls(**settings) - encoded = json.dumps(optimizer, cls=RuntimeEncoder) - self.assertIsInstance(encoded, str) - decoded = json.loads(encoded, cls=RuntimeDecoder) - self.assertTrue(isinstance(decoded, opt_cls)) - for key, value in settings.items(): - self.assertEqual(decoded.settings[key], value) + with warnings.catch_warnings(): + # filter warnings triggered by opflow imports + # in L146 of utils/json.py + warnings.filterwarnings( + "ignore", category=DeprecationWarning, module=r"qiskit\.opflow\." + ) + warnings.filterwarnings( + "ignore", + category=DeprecationWarning, + module=r"qiskit_ibm_runtime\.utils\.json", + ) + decoded = json.loads(encoded, cls=RuntimeDecoder) + self.assertEqual(operator, decoded) def test_coder_noise_model(self): """Test encoding and decoding a noise model.""" @@ -244,6 +186,13 @@ def test_encoder_instruction(self): decoded = json.loads(encoded, cls=RuntimeDecoder) self.assertEqual(decoded, obj) + def test_encoder_np_number(self): + """Test encoding and decoding instructions""" + encoded = json.dumps(np.int64(100), cls=RuntimeEncoder) + self.assertIsInstance(encoded, str) + decoded = json.loads(encoded, cls=RuntimeDecoder) + self.assertEqual(decoded, 100) + def test_encoder_callable(self): """Test encoding a callable.""" with warnings.catch_warnings(record=True) as warn_cm: @@ -268,8 +217,7 @@ def test_decoder_import(self): temp_fp.close() subtests = ( - PauliSumOp(SparsePauliOp(Pauli("XYZX"), coeffs=[2]), coeff=3), - DictStateFn("1" * 3, is_measurement=True), + SparsePauliOp(Pauli("XYZX"), coeffs=[2]), Statevector([1, 0]), ) for operator in subtests: diff --git a/test/unit/test_estimator.py b/test/unit/test_estimator.py index f7751ce6d..7d29a9df4 100644 --- a/test/unit/test_estimator.py +++ b/test/unit/test_estimator.py @@ -12,7 +12,6 @@ """Tests for estimator class.""" -import warnings from qiskit import QuantumCircuit from qiskit.quantum_info import SparsePauliOp, Pauli, random_hermitian, random_pauli_list @@ -20,11 +19,11 @@ import numpy as np -from qiskit_ibm_runtime import Estimator, Session, Options +from qiskit_ibm_runtime import Estimator, Session +from .mock.fake_runtime_service import FakeRuntimeService from ..ibm_test_case import IBMTestCase from ..utils import get_mocked_backend -from .mock.fake_runtime_service import FakeRuntimeService class TestEstimator(IBMTestCase): @@ -41,6 +40,7 @@ def test_unsupported_values_for_estimator_options(self): {"resilience_level": 4, "optimization_level": 3}, {"optimization_level": 4, "resilience_level": 2}, ] + with Session( service=FakeRuntimeService(channel="ibm_quantum", token="abc"), backend="common_backend", @@ -51,28 +51,6 @@ def test_unsupported_values_for_estimator_options(self): _ = inst.run(self.circuit, observables=self.observables, **bad_opt) self.assertIn(list(bad_opt.keys())[0], str(exc.exception)) - def test_deprecated_noise_amplifier(self): - """Test noise_amplifier deprecation.""" - opt = Options() - opt.resilience.noise_amplifier = "GlobalFoldingAmplifier" - - with warnings.catch_warnings(record=True) as warn: - warnings.simplefilter("always") - estimator = Estimator(backend=get_mocked_backend(), options=opt) - estimator.run(self.circuit, self.observables) - self.assertEqual(len(warn), 1, "Deprecation warning not found.") - self.assertIn("noise_amplifier", str(warn[-1].message)) - - def test_deprecated_noise_amplifier_run(self): - """Test noise_amplifier deprecation in run.""" - - with warnings.catch_warnings(record=True) as warn: - warnings.simplefilter("always") - estimator = Estimator(backend=get_mocked_backend()) - estimator.run(self.circuit, self.observables, noise_amplifier="GlobalFoldingAmplifier") - self.assertEqual(len(warn), 1, "Deprecation warning not found.") - self.assertIn("noise_amplifier", str(warn[-1].message)) - def test_observable_types_single_circuit(self): """Test different observable types for a single circuit.""" all_obs = [ diff --git a/test/unit/test_ibm_primitives.py b/test/unit/test_ibm_primitives.py index 6d5f2814f..185505ee4 100644 --- a/test/unit/test_ibm_primitives.py +++ b/test/unit/test_ibm_primitives.py @@ -15,7 +15,6 @@ import sys import os from unittest.mock import MagicMock, patch -import warnings from dataclasses import asdict from typing import Dict @@ -82,28 +81,6 @@ def test_dict_options(self): inst = cls(session=MagicMock(spec=MockSession), options=options) self.assertTrue(dict_paritally_equal(inst.options.__dict__, options)) - def test_backend_in_options(self): - """Test specifying backend in options.""" - primitives = [Sampler, Estimator] - backend_name = "ibm_gotham" - backend = MagicMock(spec=IBMBackend) - backend._instance = None - backend.name = backend_name - backends = [backend_name, backend] - for cls in primitives: - for backend in backends: - with self.subTest(primitive=cls, backend=backend): - options = {"backend": backend} - with warnings.catch_warnings(record=True) as warn: - warnings.simplefilter("always") - cls(session=MagicMock(spec=MockSession), options=options) - self.assertTrue( - all( - issubclass(one_warn.category, DeprecationWarning) - for one_warn in warn - ) - ) - def test_runtime_options(self): """Test RuntimeOptions specified as primitive options.""" session = MagicMock(spec=MockSession) @@ -170,13 +147,11 @@ def test_init_with_session_backend_str(self): for cls in primitives: with self.subTest(primitive=cls), patch( "qiskit_ibm_runtime.base_primitive.QiskitRuntimeService" - ) as mock_service: - with self.assertWarns(DeprecationWarning): - mock_service.reset_mock() - mock_service.global_service = None + ): + with self.assertRaises(ValueError) as exc: inst = cls(session=backend_name) - mock_service.assert_called_once() self.assertIsNone(inst.session) + self.assertIn("session must be of type Session or None", str(exc.exception)) def test_init_with_backend_instance(self): """Test initializing a primitive with a backend instance.""" @@ -198,9 +173,10 @@ def test_init_with_backend_instance(self): runtime_options = service.run.call_args.kwargs["options"] self.assertEqual(runtime_options["backend"], backend.name) - with self.assertWarns(DeprecationWarning): + with self.assertRaises(ValueError) as exc: inst = cls(session=backend) self.assertIsNone(inst.session) + self.assertIn("session must be of type Session or None", str(exc.exception)) def test_init_with_backend_session(self): """Test initializing a primitive with both backend and session.""" @@ -413,22 +389,6 @@ def test_run_overwrite_runtime_options(self): rt_options = kwargs["options"] self._assert_dict_partially_equal(rt_options, options) - def test_kwarg_options(self): - """Test specifying arbitrary options.""" - session = MagicMock(spec=MockSession) - primitives = [Sampler, Estimator] - for cls in primitives: - with self.subTest(primitive=cls): - options = Options(foo="foo") # pylint: disable=unexpected-keyword-arg - inst = cls(session=session, options=options) - inst.run(self.qx, observables=self.obs) - if sys.version_info >= (3, 8): - inputs = session.run.call_args.kwargs["inputs"] - else: - _, kwargs = session.run.call_args - inputs = kwargs["inputs"] - self.assertEqual(inputs.get("foo"), "foo") - def test_run_kwarg_options(self): """Test specifying arbitrary options in run.""" session = MagicMock(spec=MockSession) @@ -475,10 +435,6 @@ def test_set_options(self): new_options = [ ({"optimization_level": 2}, Options()), ({"optimization_level": 3, "shots": 200}, Options()), - ( - {"shots": 300, "foo": "foo"}, - Options(foo="foo"), # pylint: disable=unexpected-keyword-arg - ), ] session = MagicMock(spec=MockSession) @@ -547,7 +503,12 @@ def test_default_error_levels(self): simulator={"noise_model": "foo"}, ) inst = cls(session=session, options=options) - inst.run(self.qx, observables=self.obs) + + if isinstance(inst, Estimator): + inst.run(self.qx, observables=self.obs) + else: + inst.run(self.qx) + if sys.version_info >= (3, 8): inputs = session.run.call_args.kwargs["inputs"] else: @@ -681,7 +642,6 @@ def test_transpilation_options(self): def test_max_execution_time_options(self): """Test transpilation options.""" options_dicts = [ - {"max_execution_time": Options._MIN_EXECUTION_TIME - 1}, {"max_execution_time": Options._MAX_EXECUTION_TIME + 1}, ] session = MagicMock(spec=MockSession) @@ -701,7 +661,7 @@ def test_max_execution_time_options(self): inst = cls(session=session, options=opts_dict) inst.run(self.qx, observables=self.obs) self.assertIn( - "max_execution_time must be between 300 and 28800 seconds", + "max_execution_time must be below 28800 seconds", str(exc.exception), ) @@ -724,11 +684,12 @@ def test_raise_faulty_qubits(self): estimator = Estimator(session=session) with self.assertRaises(ValueError) as err: - sampler.run(transpiled, skip_transpilation=True) + estimator.run(transpiled, observable, skip_transpilation=True) self.assertIn(f"faulty qubit {faulty_qubit}", str(err.exception)) + transpiled.measure_all() with self.assertRaises(ValueError) as err: - estimator.run(transpiled, observable, skip_transpilation=True) + sampler.run(transpiled, skip_transpilation=True) self.assertIn(f"faulty qubit {faulty_qubit}", str(err.exception)) def test_raise_faulty_qubits_many(self): @@ -753,11 +714,14 @@ def test_raise_faulty_qubits_many(self): estimator = Estimator(session=session) with self.assertRaises(ValueError) as err: - sampler.run(transpiled, skip_transpilation=True) + estimator.run(transpiled, [observable, observable], skip_transpilation=True) self.assertIn(f"faulty qubit {faulty_qubit}", str(err.exception)) + for circ in transpiled: + circ.measure_all() + with self.assertRaises(ValueError) as err: - estimator.run(transpiled, [observable, observable], skip_transpilation=True) + sampler.run(transpiled, skip_transpilation=True) self.assertIn(f"faulty qubit {faulty_qubit}", str(err.exception)) def test_raise_faulty_edge(self): @@ -779,12 +743,13 @@ def test_raise_faulty_edge(self): estimator = Estimator(session=session) with self.assertRaises(ValueError) as err: - sampler.run(transpiled, skip_transpilation=True) + estimator.run(transpiled, observable, skip_transpilation=True) self.assertIn("cx", str(err.exception)) self.assertIn(f"faulty edge {tuple(edge_qubits)}", str(err.exception)) + transpiled.measure_all() with self.assertRaises(ValueError) as err: - estimator.run(transpiled, observable, skip_transpilation=True) + sampler.run(transpiled, skip_transpilation=True) self.assertIn("cx", str(err.exception)) self.assertIn(f"faulty edge {tuple(edge_qubits)}", str(err.exception)) @@ -807,11 +772,12 @@ def test_faulty_qubit_not_used(self): estimator = Estimator(session=session) with patch.object(Session, "run") as mock_run: - sampler.run(transpiled, skip_transpilation=True) + estimator.run(transpiled, observable, skip_transpilation=True) mock_run.assert_called_once() + transpiled.measure_active() with patch.object(Session, "run") as mock_run: - estimator.run(transpiled, observable, skip_transpilation=True) + sampler.run(transpiled, skip_transpilation=True) mock_run.assert_called_once() def test_faulty_edge_not_used(self): @@ -835,11 +801,12 @@ def test_faulty_edge_not_used(self): estimator = Estimator(session=session) with patch.object(Session, "run") as mock_run: - sampler.run(transpiled, skip_transpilation=True) + estimator.run(transpiled, observable, skip_transpilation=True) mock_run.assert_called_once() + transpiled.measure_all() with patch.object(Session, "run") as mock_run: - estimator.run(transpiled, observable, skip_transpilation=True) + sampler.run(transpiled, skip_transpilation=True) mock_run.assert_called_once() def test_no_raise_skip_transpilation(self): @@ -864,11 +831,12 @@ def test_no_raise_skip_transpilation(self): estimator = Estimator(session=session) with patch.object(Session, "run") as mock_run: - sampler.run(transpiled) + estimator.run(transpiled, observable) mock_run.assert_called_once() + transpiled.measure_all() with patch.object(Session, "run") as mock_run: - estimator.run(transpiled, observable) + sampler.run(transpiled) mock_run.assert_called_once() def _update_dict(self, dict1, dict2): diff --git a/test/unit/test_logger.py b/test/unit/test_logger.py index 986ce3e5e..3fa558ad9 100644 --- a/test/unit/test_logger.py +++ b/test/unit/test_logger.py @@ -108,7 +108,6 @@ def test_valid_log_levels_mixed_casing(self): "be {}.".format(logger.level, level_value), ) - # TODO: NamedTemporaryFiles do not support name in Windows @skipIf(os.name == "nt", "Test not supported in Windows") def test_log_file(self): """Test setting up a logger by specifying a file and log level.""" diff --git a/test/unit/test_options.py b/test/unit/test_options.py index e6432ec7c..0b7ee595d 100644 --- a/test/unit/test_options.py +++ b/test/unit/test_options.py @@ -12,7 +12,6 @@ """Tests for Options class.""" -import warnings from dataclasses import asdict from ddt import data, ddt @@ -62,51 +61,6 @@ def test_merge_options(self): f"options={options}, combined={combined}", ) - def test_merge_options_extra_fields(self): - """Test merging options with extra fields.""" - options_vars = [ - ( - { - "initial_layout": [2, 3], - "transpilation": {"layout_method": "trivial"}, - "foo": "foo", - }, - Options(foo="foo"), # pylint: disable=unexpected-keyword-arg - ), - ( - { - "initial_layout": [3, 4], - "transpilation": {"layout_method": "dense", "bar": "bar"}, - }, - Options(transpilation={"bar": "bar"}), - ), - ( - { - "initial_layout": [1, 2], - "foo": "foo", - "transpilation": {"layout_method": "dense", "foo": "foo"}, - }, - Options( # pylint: disable=unexpected-keyword-arg - foo="foo", transpilation={"foo": "foo"} - ), - ), - ] - for new_ops, expected in options_vars: - with self.subTest(new_ops=new_ops): - options = Options() - combined = Options._merge_options(asdict(options), new_ops) - - # Make sure the values are equal. - self.assertTrue( - flat_dict_partially_equal(combined, new_ops), - f"new_ops={new_ops}, combined={combined}", - ) - # Make sure the structure didn't change. - self.assertTrue( - dict_keys_equal(combined, asdict(expected)), - f"expected={expected}, combined={combined}", - ) - def test_runtime_options(self): """Test converting runtime options.""" full_options = RuntimeOptions( @@ -137,14 +91,8 @@ def test_program_inputs(self): environment={"log_level": "DEBUG"}, simulator={"noise_model": noise_model}, resilience={"noise_factors": (0, 2, 4)}, - foo="foo", - bar="bar", ) - - with warnings.catch_warnings(record=True) as warn: - warnings.simplefilter("always") - inputs = Options._get_program_inputs(asdict(options)) - self.assertEqual(len(warn), 2) + inputs = Options._get_program_inputs(asdict(options)) expected = { "execution": {"shots": 100, "noise_model": noise_model}, @@ -157,7 +105,6 @@ def test_program_inputs(self): "resilience": { "noise_factors": (0, 2, 4), }, - "foo": "foo", } self.assertTrue( dict_paritally_equal(inputs, expected), @@ -191,6 +138,30 @@ def test_init_options_with_dictionary(self): # Make sure the structure didn't change. self.assertTrue(dict_keys_equal(asdict(Options()), options), f"options={options}") + def test_kwargs_options(self): + """Test specifying arbitrary options.""" + with self.assertRaises(TypeError) as exc: + _ = Options(foo="foo") # pylint: disable=unexpected-keyword-arg + self.assertIn( + "__init__() got an unexpected keyword argument 'foo'", + str(exc.exception), + ) + + def test_backend_in_options(self): + """Test specifying backend in options.""" + backend_name = "ibm_gotham" + backend = FakeManila() + backend._instance = None + backend.name = backend_name + backends = [backend_name, backend] + for backend in backends: + with self.assertRaises(TypeError) as exc: + _ = Options(backend=backend) # pylint: disable=unexpected-keyword-arg + self.assertIn( + "__init__() got an unexpected keyword argument 'backend'", + str(exc.exception), + ) + def test_unsupported_options(self): """Test error on unsupported second level options""" # defining minimal dict of options diff --git a/test/unit/test_sampler.py b/test/unit/test_sampler.py index 02d5a6fbe..2d32766f2 100644 --- a/test/unit/test_sampler.py +++ b/test/unit/test_sampler.py @@ -12,7 +12,7 @@ """Tests for sampler class.""" -from qiskit.circuit import QuantumCircuit +from qiskit.test.reference_circuits import ReferenceCircuits from qiskit_ibm_runtime import Sampler, Session from ..ibm_test_case import IBMTestCase @@ -28,11 +28,12 @@ def test_unsupported_values_for_sampler_options(self): {"resilience_level": 2, "optimization_level": 3}, {"optimization_level": 4, "resilience_level": 1}, ] + with Session( service=FakeRuntimeService(channel="ibm_quantum", token="abc"), backend="common_backend", ) as session: - circuit = QuantumCircuit(1, 1) + circuit = ReferenceCircuits.bell() for bad_opt in options_bad: inst = Sampler(session=session) with self.assertRaises(ValueError) as exc: diff --git a/test/unit/test_session.py b/test/unit/test_session.py index 446caacc4..9ac349c3f 100644 --- a/test/unit/test_session.py +++ b/test/unit/test_session.py @@ -16,7 +16,6 @@ from qiskit_ibm_runtime import Session from qiskit_ibm_runtime.ibm_backend import IBMBackend -from qiskit_ibm_runtime.exceptions import IBMInputValueError import qiskit_ibm_runtime.session as session_pkg from .mock.fake_runtime_service import FakeRuntimeService from ..ibm_test_case import IBMTestCase @@ -76,18 +75,10 @@ def test_max_time(self): def test_run_after_close(self): """Test running after session is closed.""" session = Session(service=MagicMock(), backend="ibm_gotham") - session.close() + session.cancel() with self.assertRaises(RuntimeError): session.run(program_id="program_id", inputs={}) - def test_conflicting_backend(self): - """Test passing in different backend through options.""" - service = MagicMock() - backend = "ibm_gotham" - session = Session(service=service, backend=backend) - with self.assertRaises(IBMInputValueError): - session.run(program_id="test", inputs={}, options={"backend": "different_backend"}) - def test_run(self): """Test the run method.""" job = MagicMock() @@ -114,7 +105,7 @@ def test_run(self): _, kwargs = service.run.call_args self.assertEqual(kwargs["program_id"], program_id) self.assertDictEqual(kwargs["options"], {"backend": backend, **options}) - self.assertDictContainsSubset({"session_time": 42}, kwargs["options"]) + self.assertTrue({"session_time": 42}.items() <= kwargs["options"].items()) self.assertDictEqual(kwargs["inputs"], inputs) self.assertEqual(kwargs["session_id"], session_ids[idx]) self.assertEqual(kwargs["start_session"], start_sessions[idx]) @@ -135,7 +126,7 @@ def test_context_manager(self): """Test session as a context manager.""" with Session(service=MagicMock(), backend="ibm_gotham") as session: session.run(program_id="foo", inputs={}) - session.close() + session.cancel() self.assertFalse(session._active) def test_default_backend(self): @@ -163,3 +154,11 @@ def test_global_service(self): service=FakeRuntimeService(channel="ibm_quantum", token="uvw"), backend="ibm_gotham" ) as session: self.assertEqual(session._service._account.token, "uvw") + + def test_session_from_id(self): + """Create session with given session_id""" + service = MagicMock() + session_id = "123" + session = Session.from_id(session_id=session_id, service=service) + session.run(program_id="foo", inputs={}) + self.assertEqual(session.session_id, session_id) diff --git a/test/utils.py b/test/utils.py index 4ec9bc0af..09e96ffd7 100644 --- a/test/utils.py +++ b/test/utils.py @@ -134,8 +134,7 @@ def wait_for_status(job, status, poll_time=1, time_out=20): def get_real_device(service): """Get a real device for the service.""" try: - # TODO: Remove filters when ibmq_berlin is removed - return service.least_busy(simulator=False, filters=lambda b: b.name != "ibmq_berlin").name + return service.least_busy(simulator=False).name except QiskitBackendNotFoundError: raise unittest.SkipTest("No real device") # cloud has no real device From b5c710091e654a45e6e4fbca5c9a93bd2e2a4d4f Mon Sep 17 00:00:00 2001 From: Jessie Yu Date: Wed, 1 Nov 2023 17:12:41 -0400 Subject: [PATCH 07/35] v2 options --- qiskit_ibm_runtime/__init__.py | 5 +- qiskit_ibm_runtime/base_primitive.py | 94 +++--- qiskit_ibm_runtime/estimator.py | 241 +++++++++++-- qiskit_ibm_runtime/options/__init__.py | 8 +- .../options/environment_options.py | 25 +- .../options/estimator_options.py | 244 ++++++++++++++ .../options/execution_options.py | 97 +++--- qiskit_ibm_runtime/options/options.py | 317 ++++-------------- .../options/resilience_options.py | 237 +++++++------ .../options/simulator_options.py | 33 +- .../options/transpilation_options.py | 72 +--- .../options/twirling_options.py | 44 +-- qiskit_ibm_runtime/options/utils.py | 63 +++- qiskit_ibm_runtime/sampler.py | 18 + test/unit/test_options.py | 11 +- 15 files changed, 881 insertions(+), 628 deletions(-) create mode 100644 qiskit_ibm_runtime/options/estimator_options.py diff --git a/qiskit_ibm_runtime/__init__.py b/qiskit_ibm_runtime/__init__.py index 64a3c8eb6..9bb797e46 100644 --- a/qiskit_ibm_runtime/__init__.py +++ b/qiskit_ibm_runtime/__init__.py @@ -161,6 +161,7 @@ def result_callback(job_id, result): QiskitRuntimeService Estimator + EstimatorV2 Sampler Session IBMBackend @@ -187,8 +188,8 @@ def result_callback(job_id, result): from .utils.utils import setup_logger from .version import __version__ -from .estimator import Estimator -from .sampler import Sampler +from .estimator import EstimatorV1 as Estimator, EstimatorV2 +from .sampler import SamplerV1 as Sampler from .options import Options # Setup the logger for the IBM Quantum Provider package. diff --git a/qiskit_ibm_runtime/base_primitive.py b/qiskit_ibm_runtime/base_primitive.py index 3adc658b4..541942a51 100644 --- a/qiskit_ibm_runtime/base_primitive.py +++ b/qiskit_ibm_runtime/base_primitive.py @@ -17,11 +17,12 @@ from typing import Dict, Optional, Any, Union import copy import logging -from dataclasses import asdict +from dataclasses import asdict, replace from qiskit.providers.options import Options as TerraOptions -from .options import Options +from .options import BaseOptions +from .options.utils import merge_options from .runtime_job import RuntimeJob from .ibm_backend import IBMBackend from .session import get_cm_session @@ -37,11 +38,14 @@ class BasePrimitive(ABC): """Base class for Qiskit Runtime primitives.""" + _OPTIONS_CLASS: BaseOptions = None + version = 0 + def __init__( self, backend: Optional[Union[str, IBMBackend]] = None, session: Optional[Union[Session, str, IBMBackend]] = None, - options: Optional[Union[Dict, Options]] = None, + options: Optional[Union[Dict, BaseOptions]] = None, ): """Initializes the primitive. @@ -72,15 +76,19 @@ def __init__( self._service: QiskitRuntimeService = None self._backend: Optional[IBMBackend] = None + self._initialize_options(options=options) + if isinstance(session, Session): self._session = session self._service = self._session.service self._backend = self._service.backend( name=self._session.backend(), instance=self._session._instance ) + return elif session is not None: raise ValueError("session must be of type Session or None") - elif isinstance(backend, IBMBackend): + + if isinstance(backend, IBMBackend): self._service = backend.service self._backend = backend elif isinstance(backend, str): @@ -106,31 +114,6 @@ def __init__( raise ValueError( "A backend or session must be specified when not using ibm_cloud channel." ) - self._simulator_backend = ( - self._backend.configuration().simulator if self._backend else False - ) - - if options is None: - self._options = asdict(Options()) - elif isinstance(options, Options): - self._options = asdict(copy.deepcopy(options)) - else: - options_copy = copy.deepcopy(options) - default_options = asdict(Options()) - self._options = Options._merge_options_with_defaults( - default_options, options_copy, is_simulator=self._simulator_backend - ) - - # self._first_run = True - # self._circuits_map = {} - # if self.circuits: - # for circuit in self.circuits: - # circuit_id = _hash( - # json.dumps(_circuit_key(circuit), cls=RuntimeEncoder) - # ) - # if circuit_id not in self._session._circuits_map: - # self._circuits_map[circuit_id] = circuit - # self._session._circuits_map[circuit_id] = circuit def _run_primitive(self, primitive_inputs: Dict, user_kwargs: Dict) -> RuntimeJob: """Run the primitive. @@ -142,12 +125,11 @@ def _run_primitive(self, primitive_inputs: Dict, user_kwargs: Dict) -> RuntimeJo Returns: Submitted job. """ - combined = Options._merge_options_with_defaults( - self._options, user_kwargs, self._simulator_backend - ) + my_options = self._options if self.version == 1 else self.options + combined = merge_options(my_options, user_kwargs) self._validate_options(combined) - primitive_inputs.update(Options._get_program_inputs(combined)) + primitive_inputs.update(my_options._get_program_inputs(combined)) if self._backend and combined["transpilation"]["skip_transpilation"]: for circ in primitive_inputs["circuits"]: @@ -155,7 +137,7 @@ def _run_primitive(self, primitive_inputs: Dict, user_kwargs: Dict) -> RuntimeJo logger.info("Submitting job using options %s", combined) - runtime_options = Options._get_runtime_options(combined) + runtime_options = my_options._get_runtime_options(combined) if self._session: return self._session.run( program_id=self._program_id(), @@ -187,24 +169,44 @@ def session(self) -> Optional[Session]: """ return self._session - @property - def options(self) -> TerraOptions: - """Return options values for the sampler. - - Returns: - options - """ - return TerraOptions(**self._options) - def set_options(self, **fields: Any) -> None: """Set options values for the sampler. Args: **fields: The fields to update the options """ - self._options = Options._merge_options_with_defaults( - self._options, fields, self._simulator_backend - ) + if self.version == 1: + self._options = merge_options(self._options, fields) + else: + self.options = self._OPTIONS_CLASS(**merge_options(self.options, fields)) + + def _initialize_options(self, options: Optional[Union[Dict, BaseOptions]] = None): + """Initialize the options.""" + opt_cls = self._OPTIONS_CLASS + + if self.version == 1: + if options is None: + self._options = asdict(opt_cls()) + elif isinstance(options, opt_cls): + self._options = asdict(copy.deepcopy(options)) + elif isinstance(options, dict): + options_copy = copy.deepcopy(options) + default_options = asdict(opt_cls()) + self._options = merge_options(default_options, options_copy) + else: + raise ValueError(f"Invalid 'options' type. It can only be a dictionary of {opt_cls}") + elif self.version == 2: + if options is None: + self.options = opt_cls() + elif isinstance(options, opt_cls): + self.options = replace(options) + elif isinstance(options, dict): + default_options = opt_cls() + self.options = opt_cls(**merge_options(default_options, options)) + else: + raise ValueError(f"Invalid 'options' type. It can only be a dictionary of {opt_cls}") + else: + raise ValueError(f"Invalid primitive version {self.version}") @abstractmethod def _validate_options(self, options: dict) -> None: diff --git a/qiskit_ibm_runtime/estimator.py b/qiskit_ibm_runtime/estimator.py index a22d1c911..5fcb3d757 100644 --- a/qiskit_ibm_runtime/estimator.py +++ b/qiskit_ibm_runtime/estimator.py @@ -13,6 +13,7 @@ """Estimator primitive.""" from __future__ import annotations +from abc import ABC import os from typing import Optional, Dict, Sequence, Any, Union, Mapping import logging @@ -28,10 +29,13 @@ from qiskit.primitives.utils import init_observable from qiskit.circuit import Parameter from qiskit.primitives.base.base_primitive import _isreal +from qiskit.providers.options import Options as TerraOptions from .runtime_job import RuntimeJob from .ibm_backend import IBMBackend from .options import Options +from .options.estimator_options import EstimatorOptionsV2, EstimatorOptions +from .options.utils import merge_options from .base_primitive import BasePrimitive from .utils.qctrl import validate as qctrl_validate from .utils.deprecation import issue_deprecation_msg @@ -63,6 +67,10 @@ class Estimator(BasePrimitive, BaseEstimator): + _PROGRAM_ID = "estimator" + version = 0 + +class EstimatorV2(Estimator): """Class for interacting with Qiskit Runtime Estimator primitive service. Qiskit Runtime Estimator primitive service estimates expectation values of quantum circuits and @@ -108,14 +116,16 @@ class Estimator(BasePrimitive, BaseEstimator): print(psi1_H23.result()) """ - _PROGRAM_ID = "estimator" _ALLOWED_BASIS: str = "IXYZ01+-rl" + _OPTIONS_CLASS = EstimatorOptionsV2 + + version = 2 def __init__( self, backend: Optional[Union[str, IBMBackend]] = None, session: Optional[Union[Session, str, IBMBackend]] = None, - options: Optional[Union[Dict, Options]] = None, + options: Optional[Union[Dict, EstimatorOptions]] = None, ): """Initializes the Estimator primitive. @@ -134,13 +144,11 @@ def __init__( options: Primitive options, see :class:`Options` for detailed description. The ``backend`` keyword is still supported but is deprecated. """ - # `self._options` in this class is a Dict. - # The base class, however, uses a `_run_options` which is an instance of - # qiskit.providers.Options. We largely ignore this _run_options because we use - # a nested dictionary to categorize options. BaseEstimator.__init__(self) BasePrimitive.__init__(self, backend=backend, session=session, options=options) + self.options._is_simulator = self._backend and self._backend.configuration().simulator + def run( # pylint: disable=arguments-differ self, circuits: QuantumCircuit | Sequence[QuantumCircuit], @@ -205,9 +213,7 @@ def _run( # pylint: disable=arguments-differ """ inputs = { "circuits": circuits, - "circuit_indices": list(range(len(circuits))), "observables": observables, - "observable_indices": list(range(len(observables))), "parameters": [circ.parameters for circ in circuits], "parameter_values": parameter_values, } @@ -224,30 +230,11 @@ def _validate_options(self, options: dict) -> None: if os.getenv("QISKIT_RUNTIME_SKIP_OPTIONS_VALIDATION"): return + # TODO: Fix q-ctrl validation if self._service._channel_strategy == "q-ctrl": qctrl_validate(options) return - if not options.get("resilience_level") in list( - range(Options._MAX_RESILIENCE_LEVEL_ESTIMATOR + 1) - ): - raise ValueError( - f"resilience_level can only take the values " - f"{list(range(Options._MAX_RESILIENCE_LEVEL_ESTIMATOR + 1))} in Estimator" - ) - - if ( - options.get("resilience_level") == 3 - and self._backend - and self._backend.configuration().simulator - ): - if not options.get("simulator").get("coupling_map"): - raise ValueError( - "When the backend is a simulator and resilience_level == 3," - "a coupling map is required." - ) - Options.validate_options(options) - @staticmethod def _validate_observables( observables: Sequence[ObservablesArrayLike] | ObservablesArrayLike, @@ -330,3 +317,201 @@ def _cross_validate_circuits_observables( def _program_id(cls) -> str: """Return the program ID.""" return "estimator" + + +class EstimatorV1(Estimator): + """Class for interacting with Qiskit Runtime Estimator primitive service. + + Qiskit Runtime Estimator primitive service estimates expectation values of quantum circuits and + observables. + + The :meth:`run` can be used to submit circuits, observables, and parameters + to the Estimator primitive. + + You are encouraged to use :class:`~qiskit_ibm_runtime.Session` to open a session, + during which you can invoke one or more primitives. Jobs submitted within a session + are prioritized by the scheduler, and data is cached for efficiency. + + Example:: + + from qiskit.circuit.library import RealAmplitudes + from qiskit.quantum_info import SparsePauliOp + + from qiskit_ibm_runtime import QiskitRuntimeService, Estimator + + service = QiskitRuntimeService(channel="ibm_cloud") + + psi1 = RealAmplitudes(num_qubits=2, reps=2) + + H1 = SparsePauliOp.from_list([("II", 1), ("IZ", 2), ("XI", 3)]) + H2 = SparsePauliOp.from_list([("IZ", 1)]) + H3 = SparsePauliOp.from_list([("ZI", 1), ("ZZ", 1)]) + + with Session(service=service, backend="ibmq_qasm_simulator") as session: + estimator = Estimator(session=session) + + theta1 = [0, 1, 1, 2, 3, 5] + + # calculate [ ] + psi1_H1 = estimator.run(circuits=[psi1], observables=[H1], parameter_values=[theta1]) + print(psi1_H1.result()) + + # calculate [ , ] + psi1_H23 = estimator.run( + circuits=[psi1, psi1], + observables=[H2, H3], + parameter_values=[theta1]*2 + ) + print(psi1_H23.result()) + """ + + _OPTIONS_CLASS = Options + version = 1 + + def __init__( + self, + backend: Optional[Union[str, IBMBackend]] = None, + session: Optional[Union[Session, str, IBMBackend]] = None, + options: Optional[Union[Dict, Options]] = None, + ): + """Initializes the Estimator primitive. + + Args: + backend: Backend to run the primitive. This can be a backend name or an :class:`IBMBackend` + instance. If a name is specified, the default account (e.g. ``QiskitRuntimeService()``) + is used. + + session: Session in which to call the primitive. + + If both ``session`` and ``backend`` are specified, ``session`` takes precedence. + If neither is specified, and the primitive is created inside a + :class:`qiskit_ibm_runtime.Session` context manager, then the session is used. + Otherwise if IBM Cloud channel is used, a default backend is selected. + + options: Primitive options, see :class:`Options` for detailed description. + The ``backend`` keyword is still supported but is deprecated. + """ + # `self._options` in this class is a Dict. + # The base class, however, uses a `_run_options` which is an instance of + # qiskit.providers.Options. We largely ignore this _run_options because we use + # a nested dictionary to categorize options. + BaseEstimator.__init__(self) + BasePrimitive.__init__(self, backend=backend, session=session, options=options) + + def run( # pylint: disable=arguments-differ + self, + circuits: QuantumCircuit | Sequence[QuantumCircuit], + observables: BaseOperator | PauliSumOp | Sequence[BaseOperator | PauliSumOp], + parameter_values: Sequence[float] | Sequence[Sequence[float]] | None = None, + **kwargs: Any, + ) -> RuntimeJob: + """Submit a request to the estimator primitive. + + Args: + circuits: a (parameterized) :class:`~qiskit.circuit.QuantumCircuit` or + a list of (parameterized) :class:`~qiskit.circuit.QuantumCircuit`. + + observables: Observable objects. + + parameter_values: Concrete parameters to be bound. + + **kwargs: Individual options to overwrite the default primitive options. + These include the runtime options in :class:`qiskit_ibm_runtime.RuntimeOptions`. + + Returns: + Submitted job. + The result of the job is an instance of :class:`qiskit.primitives.EstimatorResult`. + + Raises: + ValueError: Invalid arguments are given. + """ + # To bypass base class merging of options. + user_kwargs = {"_user_kwargs": kwargs} + return super().run( + circuits=circuits, + observables=observables, + parameter_values=parameter_values, + **user_kwargs, + ) + + def _run( # pylint: disable=arguments-differ + self, + circuits: Sequence[QuantumCircuit], + observables: Sequence[BaseOperator | PauliSumOp], + parameter_values: Sequence[Sequence[float]], + **kwargs: Any, + ) -> RuntimeJob: + """Submit a request to the estimator primitive. + + Args: + circuits: a (parameterized) :class:`~qiskit.circuit.QuantumCircuit` or + a list of (parameterized) :class:`~qiskit.circuit.QuantumCircuit`. + + observables: A list of observable objects. + + parameter_values: An optional list of concrete parameters to be bound. + + **kwargs: Individual options to overwrite the default primitive options. + These include the runtime options in :class:`~qiskit_ibm_runtime.RuntimeOptions`. + + Returns: + Submitted job + """ + inputs = { + "circuits": circuits, + "circuit_indices": list(range(len(circuits))), + "observables": observables, + "observable_indices": list(range(len(observables))), + "parameters": [circ.parameters for circ in circuits], + "parameter_values": parameter_values, + } + return self._run_primitive( + primitive_inputs=inputs, user_kwargs=kwargs.get("_user_kwargs", {}) + ) + + def _validate_options(self, options: dict) -> None: + """Validate that program inputs (options) are valid + Raises: + ValueError: if resilience_level is out of the allowed range. + ValueError: if resilience_level==3, backend is simulator and no coupling map + """ + if os.getenv("QISKIT_RUNTIME_SKIP_OPTIONS_VALIDATION"): + return + + if self._service._channel_strategy == "q-ctrl": + qctrl_validate(options) + return + + if not options.get("resilience_level") in list( + range(Options._MAX_RESILIENCE_LEVEL_ESTIMATOR + 1) + ): + raise ValueError( + f"resilience_level can only take the values " + f"{list(range(Options._MAX_RESILIENCE_LEVEL_ESTIMATOR + 1))} in Estimator" + ) + + if ( + options.get("resilience_level") == 3 + and self._backend + and self._backend.configuration().simulator + ): + if not options.get("simulator").get("coupling_map"): + raise ValueError( + "When the backend is a simulator and resilience_level == 3," + "a coupling map is required." + ) + Options.validate_options(options) + + @property + def options(self) -> TerraOptions: + """Return options values for the sampler. + + Returns: + options + """ + return TerraOptions(**self._options) + + @classmethod + def _program_id(cls) -> str: + """Return the program ID.""" + return "estimator" diff --git a/qiskit_ibm_runtime/options/__init__.py b/qiskit_ibm_runtime/options/__init__.py index baf48c0f9..0a5a18ae6 100644 --- a/qiskit_ibm_runtime/options/__init__.py +++ b/qiskit_ibm_runtime/options/__init__.py @@ -50,13 +50,15 @@ EnvironmentOptions SimulatorOptions TwirlingOptions + EstimatorOptions """ from .environment_options import EnvironmentOptions -from .execution_options import ExecutionOptions -from .options import Options +from .execution_options import ExecutionOptionsV1 as ExecutionOptions +from .options import Options, BaseOptions from .simulator_options import SimulatorOptions from .transpilation_options import TranspilationOptions -from .resilience_options import ResilienceOptions +from .resilience_options import ResilienceOptionsV1 as ResilienceOptions from .twirling_options import TwirlingOptions +from .estimator_options import EstimatorOptionsV2 as EstimatorOptions diff --git a/qiskit_ibm_runtime/options/environment_options.py b/qiskit_ibm_runtime/options/environment_options.py index ebb183adf..c6c7b0524 100644 --- a/qiskit_ibm_runtime/options/environment_options.py +++ b/qiskit_ibm_runtime/options/environment_options.py @@ -12,8 +12,10 @@ """Options related to the execution environment.""" -from typing import Optional, Callable, List, Literal, get_args -from dataclasses import dataclass, field +from typing import Optional, Callable, List, Literal + +from pydantic.dataclasses import dataclass as pydantic_dataclass +from pydantic import ConfigDict LogLevelType = Literal[ "DEBUG", @@ -24,7 +26,7 @@ ] -@dataclass +@pydantic_dataclass(config=ConfigDict(validate_assignment=True, arbitrary_types_allowed=True, extra="forbid")) class EnvironmentOptions: """Options related to the execution environment. @@ -44,19 +46,6 @@ class EnvironmentOptions: function call. """ - log_level: str = "WARNING" + log_level: LogLevelType = "WARNING" callback: Optional[Callable] = None - job_tags: Optional[List] = field(default_factory=list) - - @staticmethod - def validate_environment_options(environment_options: dict) -> None: - """Validate that environment options are legal. - Raises: - ValueError: if log_level is not in LogLevelType. - """ - log_level = environment_options.get("log_level") - if not log_level in get_args(LogLevelType): - raise ValueError( - f"Unsupported value {log_level} for log_level. " - f"Supported values are {get_args(LogLevelType)}" - ) + job_tags: Optional[List] = None diff --git a/qiskit_ibm_runtime/options/estimator_options.py b/qiskit_ibm_runtime/options/estimator_options.py new file mode 100644 index 000000000..4cba6a6c4 --- /dev/null +++ b/qiskit_ibm_runtime/options/estimator_options.py @@ -0,0 +1,244 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2022. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +"""Primitive options.""" + +from typing import Optional, Union, ClassVar, Literal, get_args, Any +import copy + +from qiskit.transpiler import CouplingMap +from pydantic.dataclasses import dataclass as pydantic_dataclass +from pydantic import Field, ConfigDict, model_validator, field_validator + +from .utils import Dict, Unset, UnsetType, _remove_dict_unset_values +from .execution_options import ExecutionOptionsV2 +from .transpilation_options import TranspilationOptions +from .resilience_options import ResilienceOptionsV2 +from .twirling_options import TwirlingOptions +from .options import OptionsV2 + +DDSequenceType = Literal["XX", "XpXm", "XY4"] + + +class EstimatorOptions(OptionsV2): + pass + + +@pydantic_dataclass(config=ConfigDict(validate_assignment=True, arbitrary_types_allowed=True, extra="forbid")) +class EstimatorOptionsV2(EstimatorOptions): + """Options for v2 Estimator. + + Args: + optimization_level: How much optimization to perform on the circuits. + Higher levels generate more optimized circuits, + at the expense of longer transpilation times. This is based on the + ``optimization_level`` parameter in qiskit-terra but may include + backend-specific optimization. Default: 1. + + * 0: no optimization + * 1: light optimization + * 2: heavy optimization + * 3: even heavier optimization + + resilience_level: How much resilience to build against errors. + Higher levels generate more accurate results, + at the expense of longer processing times. Default: 1. + + * 0: No mitigation. + * 1: Minimal mitigation costs. Mitigate error associated with readout errors. + * 2: Medium mitigation costs. Typically reduces bias in estimators but + is not guaranteed to be zero bias. Only applies to estimator. + * 3: Heavy mitigation with layer sampling. Theoretically expected to deliver zero + bias estimators. Only applies to estimator. + + Refer to the + `Qiskit Runtime documentation + `_. + for more information about the error mitigation methods used at each level. + + dynamical_decoupling: Optional, specify a dynamical decoupling sequence to use. + Allowed values are ``"XX"``, ``"XpXm"``, ``"XY4"``. + Default: None + + transpilation: Transpilation options. See :class:`TranspilationOptions` for all + available options. + + resilience: Advanced resilience options to fine tune the resilience strategy. + See :class:`ResilienceOptions` for all available options. + + execution: Execution time options. See :class:`ExecutionOptions` for all available options. + + environment: Options related to the execution environment. See + :class:`EnvironmentOptions` for all available options. + + simulator: Simulator options. See + :class:`SimulatorOptions` for all available options. + + """ + _version: int = 2 + _is_simulator: bool = False + + _MAX_OPTIMIZATION_LEVEL = 3 + _MAX_RESILIENCE_LEVEL = 3 + + # Sadly we cannot use pydantic's built in validation because it won't work on Unset. + optimization_level: Union[UnsetType, int] = Unset + resilience_level: Union[UnsetType, int] = Unset + dynamical_decoupling: Union[UnsetType, DDSequenceType] = Unset + transpilation: Union[TranspilationOptions, Dict] = Field(default_factory=TranspilationOptions) + resilience: Union[ResilienceOptionsV2, Dict] = Field(default_factory=ResilienceOptionsV2) + execution: Union[ExecutionOptionsV2, Dict] = Field(default_factory=ExecutionOptionsV2) + twirling: Union[TwirlingOptions, Dict] = Field(default_factory=TwirlingOptions) + experimental: Union[UnsetType, dict] = Unset + + @field_validator("optimization_level") + @classmethod + def _validate_optimization_level(cls, optimization_level: Union[UnsetType, int]): + """Validate optimization_leve.""" + if not isinstance(optimization_level, UnsetType) and not (0 <= optimization_level <= 3): + raise ValueError(f"Invalid optimization_level. Valid range is 0-{EstimatorOptionsV2._MAX_OPTIMIZATION_LEVEL}") + return optimization_level + + @field_validator("resilience_level") + @classmethod + def _validate_resilience_level(cls, resilience_level: Union[UnsetType, int]): + """Validate resilience_level.""" + if not isinstance(resilience_level, UnsetType) and not (0 <= resilience_level <= 3): + raise ValueError(f"Invalid optimization_level. Valid range is 0-{EstimatorOptionsV2._MAX_RESILIENCE_LEVEL}") + return resilience_level + + @model_validator(mode='after') + def _validate_options(self): + """Validate the model.""" + # TODO: Server should have different optimization/resilience levels for simulator + # TODO: Allow bypasing validation + + if self.resilience_level == 3 and self._is_simulator and not self.simulator.coupling_map: + raise ValueError( + "When the backend is a simulator and resilience_level == 3," + "a coupling map is required." + ) + + return self + + @staticmethod + def _get_program_inputs(options: dict) -> dict: + """Convert the input options to program compatible inputs. + + Returns: + Inputs acceptable by primitives. + """ + + sim_options = options.get("simulator", {}) + inputs = {} + inputs["transpilation"] = copy.copy(options.get("transpilation", {})) + inputs["skip_transpilation"] = inputs["transpilation"].pop("skip_transpilation") + coupling_map = sim_options.get("coupling_map", None) + # TODO: We can just move this to json encoder + if isinstance(coupling_map, CouplingMap): + coupling_map = list(map(list, coupling_map.get_edges())) + inputs["transpilation"].update( + { + "optimization_level": options.get("optimization_level"), + "coupling_map": coupling_map, + "basis_gates": sim_options.get("basis_gates", None), + } + ) + + inputs["resilience_level"] = options.get("resilience_level") + inputs["resilience"] = options.get("resilience", {}) + + # TODO: Turn off ZNE/PEC fields + # Turn off all ZNE fields + # self.zne_extrapolator = Unset + # self.zne_noise_factors = Unset + # self.zne_stderr_threshold = Unset + + # # Validate PEC options + # if isinstance(self.pec_mitigation, UnsetType): + # self.pec_max_overhead = Unset + + inputs["twirling"] = options.get("twirling", {}) + + inputs["execution"] = options.get("execution") + inputs["execution"].update( + { + "noise_model": sim_options.get("noise_model", None), + "seed_simulator": sim_options.get("seed_simulator", None), + } + ) + + inputs["_experimental"] = True + inputs["version"] = EstimatorOptionsV2._version + _remove_dict_unset_values(inputs) + + return inputs + + +# @dataclass(frozen=True) +# class _ResilienceLevel0Options: +# resilience_level: int = 0 +# resilience: ResilienceOptions = field( +# default_factory=lambda: ResilienceOptions( +# measure_noise_mitigation=False, zne_mitigation=False, pec_mitigation=False +# ) +# ) +# twirling: TwirlingOptions = field( +# default_factory=lambda: TwirlingOptions(gates=False, measure=False) +# ) + + +# @dataclass(frozen=True) +# class _ResilienceLevel1Options: +# resilience_level: int = 1 +# resilience: ResilienceOptions = field( +# default_factory=lambda: ResilienceOptions( +# measure_noise_mitigation=True, zne_mitigation=False, pec_mitigation=False +# ) +# ) +# twirling: TwirlingOptions = field( +# default_factory=lambda: TwirlingOptions(gates=False, measure=True, strategy="active-accum") +# ) + + +# @dataclass(frozen=True) +# class _ResilienceLevel2Options: +# resilience_level: int = 2 +# resilience: ResilienceOptions = field( +# default_factory=lambda: ResilienceOptions( +# measure_noise_mitigation=True, pec_mitigation=False, **asdict(_ZneOptions()) +# ) +# ) +# twirling: TwirlingOptions = field( +# default_factory=lambda: TwirlingOptions(gates=True, measure=True, strategy="active-accum") +# ) + + +# @dataclass(frozen=True) +# class _ResilienceLevel3Options: +# resilience_level: int = 3 +# resilience: ResilienceOptions = field( +# default_factory=lambda: ResilienceOptions( +# measure_noise_mitigation=True, zne_mitigation=False, **asdict(_PecOptions()) +# ) +# ) +# twirling: TwirlingOptions = field( +# default_factory=lambda: TwirlingOptions(gates=True, measure=True, strategy="active") +# ) + + +# _DEFAULT_RESILIENCE_LEVEL_OPTIONS = { +# 0: _ResilienceLevel0Options(), +# 1: _ResilienceLevel1Options(), +# 2: _ResilienceLevel2Options(), +# 3: _ResilienceLevel3Options(), +# } diff --git a/qiskit_ibm_runtime/options/execution_options.py b/qiskit_ibm_runtime/options/execution_options.py index aaf4fede6..1fc1aeeb5 100644 --- a/qiskit_ibm_runtime/options/execution_options.py +++ b/qiskit_ibm_runtime/options/execution_options.py @@ -12,26 +12,20 @@ """Execution options.""" -from dataclasses import dataclass -from typing import Literal, get_args, Optional -from numbers import Integral +from typing import Union +from pydantic.dataclasses import dataclass as pydantic_dataclass +from pydantic import Field, ConfigDict, model_validator, field_validator, ValidationInfo -ExecutionSupportedOptions = Literal[ - "shots", - "init_qubits", - "samples", - "shots_per_sample", - "interleave_samples", -] +from .utils import Unset, UnsetType -@dataclass -class ExecutionOptions: +@pydantic_dataclass(config=ConfigDict(validate_assignment=True, arbitrary_types_allowed=True, extra="forbid")) +class ExecutionOptionsV2: """Execution options. Args: - shots: Number of repetitions of each circuit, for sampling. Default: 4096. + shots: Number of repetitions of each circuit, for sampling. init_qubits: Whether to reset the qubits to the ground state for each shot. Default: ``True``. @@ -40,62 +34,53 @@ class ExecutionOptions: is used when twirling or resilience levels 1, 2, 3. If None it will be calculated automatically based on the ``shots`` and ``shots_per_sample`` (if specified). - Default: None + Default: Unset shots_per_sample: The number of shots per sample of each measurement circuit to run. This is used when twirling or resilience levels 1, 2, 3. If None it will be calculated automatically based on the ``shots`` and ``samples`` (if specified). - Default: None + Default: Unset interleave_samples: If True interleave samples from different measurement circuits when running. If False run all samples from each measurement circuit in order. Default: False """ - - shots: int = 4096 + shots: Union[UnsetType, int] = Unset init_qubits: bool = True - samples: Optional[int] = None - shots_per_sample: Optional[int] = None + samples: Union[UnsetType, int] = Unset + shots_per_sample: Union[UnsetType, int] = Unset interleave_samples: bool = False - @staticmethod - def validate_execution_options(execution_options: dict) -> None: - """Validate that execution options are legal. - Raises: - ValueError: if any execution option is not supported - """ - for opt in execution_options: - if not opt in get_args(ExecutionSupportedOptions): - raise ValueError(f"Unsupported value '{opt}' for execution.") - - shots = execution_options.get("shots") - samples = execution_options.get("samples") - shots_per_sample = execution_options.get("shots_per_sample") - if ( - shots is not None - and samples is not None - and shots_per_sample is not None - and shots != samples * shots_per_sample - ): + @field_validator("shots", "samples", "shots_per_sample") + @classmethod + def _validate_positive_integer(cls, fld: Union[UnsetType, int], info: ValidationInfo): + """Validate zne_stderr_threshold.""" + if isinstance(fld, int) and fld < 1: + raise ValueError(f"{info.field_name} must be >= 1") + return fld + + @model_validator(mode='after') + def _validate_options(self): + """Validate the model.""" + if all(not isinstance(fld, UnsetType) for fld in [self.shots, self.samples, self.shots_per_sample]) and self.shots != self.samples * self.shots_per_sample: raise ValueError( - f"If shots ({shots}) != samples ({samples}) * shots_per_sample ({shots_per_sample})" + f"Shots ({self.shots}) != samples ({self.samples}) * shots_per_sample ({self.shots_per_sample})" ) - if shots is not None: - if not isinstance(shots, Integral): - raise ValueError(f"shots must be None or an integer, not {type(shots)}") - if shots < 1: - raise ValueError("shots must be None or >= 1") - if samples is not None: - if not isinstance(samples, Integral): - raise ValueError(f"samples must be None or an integer, not {type(samples)}") - if samples < 1: - raise ValueError("samples must be None or >= 1") - if shots_per_sample is not None: - if not isinstance(shots_per_sample, Integral): - raise ValueError( - f"shots_per_sample must be None or an integer, not {type(shots_per_sample)}" - ) - if shots_per_sample < 1: - raise ValueError("shots_per_sample must be None or >= 1") + return self + + +@pydantic_dataclass(config=ConfigDict(validate_assignment=True, arbitrary_types_allowed=True, extra="forbid")) +class ExecutionOptionsV1: + """Execution options. + + Args: + shots: Number of repetitions of each circuit, for sampling. Default: 4000. + + init_qubits: Whether to reset the qubits to the ground state for each shot. + Default: ``True``. + """ + + shots: int = 4000 + init_qubits: bool = True diff --git a/qiskit_ibm_runtime/options/options.py b/qiskit_ibm_runtime/options/options.py index 814e51f18..e19f0d3c7 100644 --- a/qiskit_ibm_runtime/options/options.py +++ b/qiskit_ibm_runtime/options/options.py @@ -12,6 +12,7 @@ """Primitive options.""" +from abc import ABC, abstractmethod from typing import Optional, Union, ClassVar, Literal, get_args, Any from dataclasses import dataclass, fields, field, asdict import copy @@ -19,22 +20,48 @@ from qiskit.transpiler import CouplingMap from pydantic.dataclasses import dataclass as pydantic_dataclass -from pydantic import Field +from pydantic import Field, ConfigDict -from .utils import Dict, _to_obj, _remove_dict_none_values +from .utils import Dict, _to_obj, UnsetType, Unset from .environment_options import EnvironmentOptions -from .execution_options import ExecutionOptions +from .execution_options import ExecutionOptionsV1 as ExecutionOptions from .simulator_options import SimulatorOptions from .transpilation_options import TranspilationOptions -from .resilience_options import ResilienceOptions, _ZneOptions, _PecOptions -from .twirling_options import TwirlingOptions +from .resilience_options import ResilienceOptionsV1 as ResilienceOptions from ..runtime_options import RuntimeOptions -DDSequenceType = Literal[None, "XX", "XpXm", "XY4"] +@dataclass +class BaseOptions(ABC): + + @abstractmethod + def _get_program_inputs(options: dict) -> dict: + raise NotImplemented() + + @staticmethod + def _get_runtime_options(options: dict) -> dict: + """Extract runtime options. + + Returns: + Runtime options. + """ + environment = options.get("environment") or {} + out = {"max_execution_time": options.get("max_execution_time", None)} + + for fld in fields(RuntimeOptions): + if fld.name in environment: + out[fld.name] = environment[fld.name] + + if "image" in options: + out["image"] = options["image"] + elif "image" in options.get("experimental", {}): + out["image"] = options["experimental"]["image"] -@pydantic_dataclass -class PrimitiveOptions: + return out + + +@pydantic_dataclass(config=ConfigDict(validate_assignment=True, arbitrary_types_allowed=True, extra="forbid")) +class OptionsV2(BaseOptions): """Base primitive options. Args: @@ -56,13 +83,13 @@ class PrimitiveOptions: :class:`SimulatorOptions` for all available options. """ - max_execution_time: Optional[int] = None + max_execution_time: Union[UnsetType, int] = Unset environment: Union[EnvironmentOptions, Dict] = Field(default_factory=EnvironmentOptions) simulator: Union[SimulatorOptions, Dict] = Field(default_factory=SimulatorOptions) @dataclass -class Options: +class Options(BaseOptions): """Options for the primitives. Args: @@ -104,10 +131,6 @@ class Options: `_. for more information. - dynamical_decoupling: Optional, specify a dynamical decoupling sequence to use. - Allowed values are ``"XX"``, ``"XpXm"``, ``"XY4"``. - Default: None - transpilation: Transpilation options. See :class:`TranspilationOptions` for all available options. @@ -126,9 +149,7 @@ class Options: # Defaults for optimization_level and for resilience_level will be assigned # in Sampler/Estimator _DEFAULT_OPTIMIZATION_LEVEL = 3 - _DEFAULT_NOISELESS_OPTIMIZATION_LEVEL = 1 _DEFAULT_RESILIENCE_LEVEL = 1 - _DEFAULT_NOISELESS_RESILIENCE_LEVEL = 0 _MAX_OPTIMIZATION_LEVEL = 3 _MAX_RESILIENCE_LEVEL_ESTIMATOR = 3 _MAX_RESILIENCE_LEVEL_SAMPLER = 1 @@ -137,13 +158,11 @@ class Options: optimization_level: Optional[int] = None resilience_level: Optional[int] = None max_execution_time: Optional[int] = None - dynamical_decoupling: Optional[DDSequenceType] = None transpilation: Union[TranspilationOptions, Dict] = field(default_factory=TranspilationOptions) resilience: Union[ResilienceOptions, Dict] = field(default_factory=ResilienceOptions) execution: Union[ExecutionOptions, Dict] = field(default_factory=ExecutionOptions) environment: Union[EnvironmentOptions, Dict] = field(default_factory=EnvironmentOptions) simulator: Union[SimulatorOptions, Dict] = field(default_factory=SimulatorOptions) - twirling: Union[TwirlingOptions, Dict] = field(default_factory=TwirlingOptions) _obj_fields: ClassVar[dict] = { "transpilation": TranspilationOptions, @@ -151,7 +170,6 @@ class Options: "environment": EnvironmentOptions, "simulator": SimulatorOptions, "resilience": ResilienceOptions, - "twirling": TwirlingOptions, } def __post_init__(self): # type: ignore @@ -169,80 +187,39 @@ def _get_program_inputs(options: dict) -> dict: Returns: Inputs acceptable by primitives. """ - - if not options.get("_experimental", True): - sim_options = options.get("simulator", {}) - inputs = {} - inputs["transpilation_settings"] = options.get("transpilation", {}) - inputs["transpilation_settings"].update( - { - "optimization_settings": {"level": options.get("optimization_level")}, - "coupling_map": sim_options.get("coupling_map", None), - "basis_gates": sim_options.get("basis_gates", None), - } - ) - if isinstance(inputs["transpilation_settings"]["coupling_map"], CouplingMap): - inputs["transpilation_settings"]["coupling_map"] = list( - map(list, inputs["transpilation_settings"]["coupling_map"].get_edges()) - ) - - inputs["resilience_settings"] = options.get("resilience", {}) - inputs["resilience_settings"].update({"level": options.get("resilience_level")}) - inputs["run_options"] = options.get("execution") - inputs["run_options"].update( - { - "noise_model": sim_options.get("noise_model", None), - "seed_simulator": sim_options.get("seed_simulator", None), - } - ) - - known_keys = list(Options.__dataclass_fields__.keys()) - known_keys.append("image") - # Add additional unknown keys. - for key in options.keys(): - if key not in known_keys: - warnings.warn(f"Key '{key}' is an unrecognized option. It may be ignored.") - inputs[key] = options[key] - inputs["_experimental"] = False - return inputs - else: - sim_options = options.get("simulator", {}) - inputs = {} - inputs["transpilation"] = copy.copy(options.get("transpilation", {})) - inputs["skip_transpilation"] = inputs["transpilation"].pop("skip_transpilation") - coupling_map = sim_options.get("coupling_map", None) - if isinstance(coupling_map, CouplingMap): - coupling_map = list(map(list, coupling_map.get_edges())) - inputs["transpilation"].update( - { - "optimization_level": options.get("optimization_level"), - "coupling_map": coupling_map, - "basis_gates": sim_options.get("basis_gates", None), - } - ) - - inputs["resilience_level"] = options.get("resilience_level") - inputs["resilience"] = options.get("resilience", {}) - inputs["twirling"] = options.get("twirling", {}) - - inputs["execution"] = options.get("execution") - inputs["execution"].update( - { - "noise_model": sim_options.get("noise_model", None), - "seed_simulator": sim_options.get("seed_simulator", None), - } + sim_options = options.get("simulator", {}) + inputs = {} + inputs["transpilation_settings"] = options.get("transpilation", {}) + inputs["transpilation_settings"].update( + { + "optimization_settings": {"level": options.get("optimization_level")}, + "coupling_map": sim_options.get("coupling_map", None), + "basis_gates": sim_options.get("basis_gates", None), + } + ) + if isinstance(inputs["transpilation_settings"]["coupling_map"], CouplingMap): + inputs["transpilation_settings"]["coupling_map"] = list( + map(list, inputs["transpilation_settings"]["coupling_map"].get_edges()) ) - known_keys = list(Options.__dataclass_fields__.keys()) - known_keys.append("image") - # Add additional unknown keys. - for key in options.keys(): - if key not in known_keys: - warnings.warn(f"Key '{key}' is an unrecognized option. It may be ignored.") - inputs[key] = options[key] + inputs["resilience_settings"] = options.get("resilience", {}) + inputs["resilience_settings"].update({"level": options.get("resilience_level")}) + inputs["run_options"] = options.get("execution") + inputs["run_options"].update( + { + "noise_model": sim_options.get("noise_model", None), + "seed_simulator": sim_options.get("seed_simulator", None), + } + ) - inputs["_experimental"] = True - return inputs + known_keys = list(Options.__dataclass_fields__.keys()) + known_keys.append("image") + # Add additional unknown keys. + for key in options.keys(): + if key not in known_keys: + warnings.warn(f"Key '{key}' is an unrecognized option. It may be ignored.") + inputs[key] = options[key] + return inputs @staticmethod def validate_options(options: dict) -> None: @@ -258,15 +235,6 @@ def validate_options(options: dict) -> None: f"optimization_level can only take the values " f"{list(range(Options._MAX_OPTIMIZATION_LEVEL + 1))}" ) - - dd_seq = options.get("dynamical_decoupling") - if dd_seq not in get_args(DDSequenceType): - raise ValueError( - f"Unsupported value '{dd_seq}' for dynamical_decoupling. " - f"Allowed values are {get_args(DDSequenceType)}" - ) - - TwirlingOptions.validate_twirling_options(options.get("twirling")) ResilienceOptions.validate_resilience_options(options.get("resilience")) TranspilationOptions.validate_transpilation_options(options.get("transpilation")) execution_time = options.get("max_execution_time") @@ -309,30 +277,7 @@ def _set_default_resilience_options(options: dict) -> dict: return options @staticmethod - def _get_runtime_options(options: dict) -> dict: - """Extract runtime options. - - Returns: - Runtime options. - """ - environment = options.get("environment") or {} - out = {"max_execution_time": options.get("max_execution_time", None)} - - for fld in fields(RuntimeOptions): - if fld.name in environment: - out[fld.name] = environment[fld.name] - - if "image" in options: - out["image"] = options["image"] - - return out - - @staticmethod - def _merge_options( - old_options: dict, - new_options: Optional[dict] = None, - allowed_none_keys: Optional[set] = None, - ) -> dict: + def _merge_options(old_options: dict, new_options: Optional[dict] = None) -> dict: """Merge current options with the new ones. Args: @@ -341,7 +286,6 @@ def _merge_options( Returns: Merged dictionary. """ - allowed_none_keys = allowed_none_keys or set() def _update_options(old: dict, new: dict, matched: Optional[dict] = None) -> None: if not new and not matched: @@ -353,13 +297,9 @@ def _update_options(old: dict, new: dict, matched: Optional[dict] = None) -> Non matched = new.pop(key, {}) _update_options(val, new, matched) elif key in new.keys(): - new_val = new.pop(key) - if new_val is not None or key in allowed_none_keys: - old[key] = new_val + old[key] = new.pop(key) elif key in matched.keys(): - new_val = matched.pop(key) - if new_val is not None or key in allowed_none_keys: - old[key] = new_val + old[key] = matched.pop(key) # Add new keys. for key, val in matched.items(): @@ -377,120 +317,3 @@ def _update_options(old: dict, new: dict, matched: Optional[dict] = None) -> Non combined.update(new_options_copy) return combined - - @classmethod - def _merge_options_with_defaults( - cls, - primitive_options: dict, - overwrite_options: Optional[dict] = None, - is_simulator: bool = False, - ) -> dict: - def _get_merged_value(name: str, first: dict = None, second: dict = None) -> Any: - first = first or overwrite_options - second = second or primitive_options - return first.get(name) or second.get(name) - - # 1. Determine optimization and resilience levels - optimization_level = _get_merged_value("optimization_level") - resilience_level = _get_merged_value("resilience_level") - noise_model = _get_merged_value( - "noise_model", - first=overwrite_options.get("simulator", {}), - second=primitive_options.get("simulator", {}), - ) - if optimization_level is None: - optimization_level = ( - cls._DEFAULT_NOISELESS_OPTIMIZATION_LEVEL - if (is_simulator and noise_model is None) - else cls._DEFAULT_OPTIMIZATION_LEVEL - ) - if resilience_level is None: - resilience_level = ( - cls._DEFAULT_NOISELESS_RESILIENCE_LEVEL - if (is_simulator and noise_model is None) - else cls._DEFAULT_RESILIENCE_LEVEL - ) - - # 2. Determine the default resilience options - if resilience_level not in _DEFAULT_RESILIENCE_LEVEL_OPTIONS: - raise ValueError(f"resilience_level {resilience_level} is not a valid value.") - default_options = asdict(_DEFAULT_RESILIENCE_LEVEL_OPTIONS[resilience_level]) - default_options["optimization_level"] = optimization_level - - # HACK: To allow certain values to be explicitly updated with None - none_keys = {"shots", "samples", "shots_per_sample", "zne_extrapolator", "pec_max_overhead"} - - # 3. Merge in primitive options. - final_options = Options._merge_options( - default_options, primitive_options, allowed_none_keys=none_keys - ) - - # 4. Merge in overwrites. - final_options = Options._merge_options( - final_options, overwrite_options, allowed_none_keys=none_keys - ) - - # 5. Remove Nones - _remove_dict_none_values(final_options, allowed_none_keys=none_keys) - - return final_options - - -@dataclass(frozen=True) -class _ResilienceLevel0Options: - resilience_level: int = 0 - resilience: ResilienceOptions = field( - default_factory=lambda: ResilienceOptions( - measure_noise_mitigation=False, zne_mitigation=False, pec_mitigation=False - ) - ) - twirling: TwirlingOptions = field( - default_factory=lambda: TwirlingOptions(gates=False, measure=False) - ) - - -@dataclass(frozen=True) -class _ResilienceLevel1Options: - resilience_level: int = 1 - resilience: ResilienceOptions = field( - default_factory=lambda: ResilienceOptions( - measure_noise_mitigation=True, zne_mitigation=False, pec_mitigation=False - ) - ) - twirling: TwirlingOptions = field( - default_factory=lambda: TwirlingOptions(gates=False, measure=True, strategy="active-accum") - ) - - -@dataclass(frozen=True) -class _ResilienceLevel2Options: - resilience_level: int = 2 - resilience: ResilienceOptions = field( - default_factory=lambda: ResilienceOptions( - measure_noise_mitigation=True, pec_mitigation=False, **asdict(_ZneOptions()) - ) - ) - twirling: TwirlingOptions = field( - default_factory=lambda: TwirlingOptions(gates=True, measure=True, strategy="active-accum") - ) - - -@dataclass(frozen=True) -class _ResilienceLevel3Options: - resilience_level: int = 3 - resilience: ResilienceOptions = field( - default_factory=lambda: ResilienceOptions( - measure_noise_mitigation=True, zne_mitigation=False, **asdict(_PecOptions()) - ) - ) - twirling: TwirlingOptions = field( - default_factory=lambda: TwirlingOptions(gates=True, measure=True, strategy="active") - ) - - -_DEFAULT_RESILIENCE_LEVEL_OPTIONS = { - 0: _ResilienceLevel0Options(), - 1: _ResilienceLevel1Options(), - 2: _ResilienceLevel2Options(), - 3: _ResilienceLevel3Options(), -} diff --git a/qiskit_ibm_runtime/options/resilience_options.py b/qiskit_ibm_runtime/options/resilience_options.py index 1c66e6645..3f1f84827 100644 --- a/qiskit_ibm_runtime/options/resilience_options.py +++ b/qiskit_ibm_runtime/options/resilience_options.py @@ -12,8 +12,13 @@ """Resilience options.""" -from typing import Sequence, Literal, get_args, Union -from dataclasses import dataclass +from typing import Sequence, Literal, get_args, Union, Optional +from dataclasses import dataclass, fields + +from pydantic.dataclasses import dataclass as pydantic_dataclass +from pydantic import Field, ConfigDict, field_validator, model_validator + +from .utils import Dict, Unset, UnsetType ResilienceSupportedOptions = Literal[ "noise_amplifier", @@ -31,7 +36,6 @@ ] ZneExtrapolatorType = Literal[ - None, "exponential", "double_exponential", "linear", @@ -42,27 +46,11 @@ ] -@dataclass -class ResilienceOptions: +@pydantic_dataclass(config=ConfigDict(validate_assignment=True, arbitrary_types_allowed=True, extra="forbid")) +class ResilienceOptionsV2: """Resilience options. Args: - noise_factors (DEPRECATED): An list of real valued noise factors that determine - by what amount the circuits' noise is amplified. - Only applicable for ``resilience_level=2``. - Default: (1, 3, 5) if resilience level is 2. Otherwise ``None``. - - noise_amplifier (DEPRECATED): A noise amplification strategy. Currently only - ``"LocalFoldingAmplifier"`` is supported Only applicable for ``resilience_level=2``. - Default: "LocalFoldingAmplifier". - - extrapolator (DEPRECATED): An extrapolation strategy. One of ``"LinearExtrapolator"``, - ``"QuadraticExtrapolator"``, ``"CubicExtrapolator"``, ``"QuarticExtrapolator"``. - Note that ``"CubicExtrapolator"`` and ``"QuarticExtrapolator"`` require more - noise factors than the default. - Only applicable for ``resilience_level=2``. - Default: ``LinearExtrapolator`` if resilience level is 2. Otherwise ``None``. - measure_noise_mitigation: Whether to enable measurement error mitigation method. By default, this is enabled for resilience level 1, 2, and 3 (when applicable). @@ -72,7 +60,6 @@ class ResilienceOptions: zne_noise_factors: An list of real valued noise factors that determine by what amount the circuits' noise is amplified. Only applicable if ZNE is enabled. - Default: (1, 3, 5). zne_extrapolator: An extrapolation strategy. One or more of ``"multi_exponential"``, ``"single_exponential"``, ``"double_exponential"``, ``"linear"``. @@ -85,7 +72,6 @@ class ResilienceOptions: will be rejected. If all models are rejected the result for the lowest noise factor is used for that basis term. Only applicable if ZNE is enabled. - Default: 0.25 pec_mitigation: Whether to turn on Probabilistic Error Cancellation error mitigation method. By default, PEC is enabled for resilience level 3. @@ -96,88 +82,56 @@ class ResilienceOptions: implement partial PEC with a scaled noise model corresponding to the maximum sampling overhead. Only applicable if PEC is enabled. - Default: 100 """ - noise_amplifier: NoiseAmplifierType = None - noise_factors: Sequence[float] = None - extrapolator: ExtrapolatorType = None - - # Measurement error mitigation - measure_noise_mitigation: bool = None + # TREX + measure_noise_mitigation: Union[UnsetType, bool] = Unset + # TODO: measure_noise_local_model # ZNE - zne_mitigation: bool = None - zne_noise_factors: Sequence[float] = None - zne_extrapolator: Union[ZneExtrapolatorType, Sequence[ZneExtrapolatorType]] = ( + zne_mitigation: Union[UnsetType, bool] = Unset + zne_noise_factors: Union[UnsetType, Sequence[float]] = Unset + zne_extrapolator: Union[UnsetType, ZneExtrapolatorType, Sequence[ZneExtrapolatorType]] = ( "exponential", "linear", ) - zne_stderr_threshold: float = None + zne_stderr_threshold: Union[UnsetType, float] = Unset # PEC - pec_mitigation: bool = None - pec_max_overhead: float = None - - @staticmethod - def validate_resilience_options(resilience_options: dict) -> None: - """Validate that resilience options are legal. - - Raises: - ValueError: if any resilience option is not supported - ValueError: if noise_amplifier is not in NoiseAmplifierType. - ValueError: if extrapolator is not in ExtrapolatorType. - ValueError: if extrapolator == "QuarticExtrapolator" and number of noise_factors < 5. - ValueError: if extrapolator == "CubicExtrapolator" and number of noise_factors < 4. - TypeError: if an input value has an invalid type. - """ - noise_amplifier = resilience_options.get("noise_amplifier") or "LocalFoldingAmplifier" - if noise_amplifier not in get_args(NoiseAmplifierType): - raise ValueError( - f"Unsupported value {noise_amplifier} for noise_amplifier. " - f"Supported values are {get_args(NoiseAmplifierType)}" - ) - - extrapolator = resilience_options.get("extrapolator") - if extrapolator and extrapolator not in get_args(ExtrapolatorType): - raise ValueError( - f"Unsupported value {extrapolator} for extrapolator. " - f"Supported values are {get_args(ExtrapolatorType)}" - ) - - if ( - extrapolator == "QuarticExtrapolator" - and len(resilience_options.get("noise_factors")) < 5 - ): - raise ValueError("QuarticExtrapolator requires at least 5 noise_factors.") - if extrapolator == "CubicExtrapolator" and len(resilience_options.get("noise_factors")) < 4: - raise ValueError("CubicExtrapolator requires at least 4 noise_factors.") - - # Validation of new ZNE options - if resilience_options.get("zne_mitigation"): - # Validate extrapolator - extrapolator = resilience_options.get("zne_extrapolator") - if isinstance(extrapolator, str): - extrapolator = (extrapolator,) - if extrapolator is not None: - for extrap in extrapolator: - if extrap not in get_args(ZneExtrapolatorType): - raise ValueError( - f"Unsupported value {extrapolator} for zne_extrapolator. " - f"Supported values are {get_args(ZneExtrapolatorType)}" - ) - - # Validation of noise factors - factors = resilience_options.get("zne_noise_factors") - if not isinstance(factors, (list, tuple)): - raise TypeError( - f"zne_noise_factors option value must be a sequence, not {type(factors)}" - ) - if any(i <= 0 for i in factors): - raise ValueError("zne_noise_factors` option value must all be non-negative") - if len(factors) < 1: - raise ValueError("zne_noise_factors cannot be empty") - if extrapolator is not None: + pec_mitigation: Union[UnsetType, bool] = Unset + pec_max_overhead: Union[UnsetType, float] = Unset + + @field_validator("zne_noise_factors") + @classmethod + def _validate_zne_noise_factors(cls, factors: Union[UnsetType, Sequence[float]]): + """Validate zne_noise_factors.""" + if isinstance(factors, Sequence) and any(i <= 0 for i in factors): + raise ValueError("zne_noise_factors` option value must all be non-negative") + return factors + + @field_validator("zne_stderr_threshold") + @classmethod + def _validate_zne_stderr_threshold(cls, threshold: Union[UnsetType, float]): + """Validate zne_stderr_threshold.""" + if isinstance(threshold, float) and threshold <= 0: + raise ValueError("Invalid zne_stderr_threshold option value must be > 0") + return threshold + + @field_validator("pec_max_overhead") + @classmethod + def _validate_pec_max_overhead(cls, overhead: Union[UnsetType, float]): + """Validate pec_max_overhead.""" + if isinstance(overhead, float) and overhead < 1: + raise ValueError("pec_max_overhead must be None or >= 1") + return overhead + + @model_validator(mode='after') + def _validate_options(self): + """Validate the model.""" + # Validate ZNE options + if self.zne_mitigation is True: + # Validate noise factors + extrapolator combination + if all(not isinstance(fld, UnsetType) for fld in [self.zne_noise_factors, self.zne_extrapolator]): required_factors = { "exponential": 2, "double_exponential": 4, @@ -187,40 +141,79 @@ def validate_resilience_options(resilience_options: dict) -> None: "polynomial_degree_3": 4, "polynomial_degree_4": 5, } - for extrap in extrapolator: - if len(factors) < required_factors[extrap]: + for extrap in self.zne_extrapolator: + if len(self.zne_noise_factors) < required_factors[extrap]: raise ValueError( f"{extrap} requires at least {required_factors[extrap]} zne_noise_factors" ) - - # Validation of threshold - threshold = resilience_options.get("zne_stderr_threshold") - if threshold is not None and threshold <= 0: - raise ValueError("Invalid zne_stderr_threshold option value must be > 0") - - if resilience_options.get("pec_mitigation"): - if resilience_options.get("zne_mitigation"): + # Validate not ZNE+PEC + if self.pec_mitigation: raise ValueError( "pec_mitigation and zne_mitigation`options cannot be " "simultaneously enabled. Set one of them to False." ) - max_overhead = resilience_options.get("pec_max_overhead") - if max_overhead is not None and max_overhead < 1: - raise ValueError("pec_max_overhead must be None or >= 1") + return self + + +# @dataclass(frozen=True) +# class _ZneOptions: +# zne_mitigation: bool = True +# zne_noise_factors: Sequence[float] = (1, 3, 5) +# zne_extrapolator: Union[ZneExtrapolatorType, Sequence[ZneExtrapolatorType]] = ( +# "exponential", +# "linear", +# ) +# zne_stderr_threshold: float = 0.25 -@dataclass(frozen=True) -class _ZneOptions: - zne_mitigation: bool = True - zne_noise_factors: Sequence[float] = (1, 3, 5) - zne_extrapolator: Union[ZneExtrapolatorType, Sequence[ZneExtrapolatorType]] = ( - "exponential", - "linear", - ) - zne_stderr_threshold: float = 0.25 +# @dataclass(frozen=True) +# class _PecOptions: +# pec_mitigation: bool = True +# pec_max_overhead: float = 100 -@dataclass(frozen=True) -class _PecOptions: - pec_mitigation: bool = True - pec_max_overhead: float = 100 + +@pydantic_dataclass(config=ConfigDict(validate_assignment=True, arbitrary_types_allowed=True, extra="forbid")) +class ResilienceOptionsV1: + """Resilience options. + + Args: + noise_factors: An list of real valued noise factors that determine by what amount the + circuits' noise is amplified. + Only applicable for ``resilience_level=2``. + Default: ``None``, and (1, 3, 5) if resilience level is 2. + + noise_amplifier: A noise amplification strategy. Currently only + ``"LocalFoldingAmplifier"`` is supported Only applicable for ``resilience_level=2``. + Default: "LocalFoldingAmplifier". + + extrapolator: An extrapolation strategy. One of ``"LinearExtrapolator"``, + ``"QuadraticExtrapolator"``, ``"CubicExtrapolator"``, ``"QuarticExtrapolator"``. + Note that ``"CubicExtrapolator"`` and ``"QuarticExtrapolator"`` require more + noise factors than the default. + Only applicable for ``resilience_level=2``. + Default: ``None``, and ``LinearExtrapolator`` if resilience level is 2. + """ + + noise_amplifier: Optional[NoiseAmplifierType] = None + noise_factors: Optional[Sequence[float]] = None + extrapolator: Optional[ExtrapolatorType] = None + + @staticmethod + def validate_resilience_options(resilience_options: dict) -> None: + """Validate that resilience options are legal. + Raises: + ValueError: if any resilience option is not supported + ValueError: if noise_amplifier is not in NoiseAmplifierType. + ValueError: if extrapolator is not in ExtrapolatorType. + ValueError: if extrapolator == "QuarticExtrapolator" and number of noise_factors < 5. + ValueError: if extrapolator == "CubicExtrapolator" and number of noise_factors < 4. + """ + extrapolator = resilience_options.get("extrapolator") + if ( + extrapolator == "QuarticExtrapolator" + and len(resilience_options.get("noise_factors")) < 5 + ): + raise ValueError("QuarticExtrapolator requires at least 5 noise_factors.") + if extrapolator == "CubicExtrapolator" and len(resilience_options.get("noise_factors")) < 4: + raise ValueError("CubicExtrapolator requires at least 4 noise_factors.") diff --git a/qiskit_ibm_runtime/options/simulator_options.py b/qiskit_ibm_runtime/options/simulator_options.py index 5506e4845..b2fb819cd 100644 --- a/qiskit_ibm_runtime/options/simulator_options.py +++ b/qiskit_ibm_runtime/options/simulator_options.py @@ -12,7 +12,6 @@ """Simulator options.""" - from typing import Optional, List, Union, Literal, get_args, TYPE_CHECKING from dataclasses import dataclass @@ -21,18 +20,20 @@ from qiskit.utils import optionals from qiskit.transpiler import CouplingMap # pylint: disable=unused-import -if TYPE_CHECKING: +from pydantic.dataclasses import dataclass as pydantic_dataclass +from pydantic import Field, ConfigDict, model_validator + +try: import qiskit_aer -SimulatorSupportedOptions = Literal[ - "noise_model", - "seed_simulator", - "coupling_map", - "basis_gates", -] + NoiseModel = qiskit_aer.noise.NoiseModel + +except ImportError: + class NoiseModel: + pass -@dataclass() +@pydantic_dataclass(config=ConfigDict(validate_assignment=True, arbitrary_types_allowed=True, extra="forbid")) class SimulatorOptions: """Simulator options. @@ -53,21 +54,11 @@ class SimulatorOptions: ``['u1', 'u2', 'u3', 'cx']``. If ``None``, do not unroll. """ - noise_model: Optional[Union[dict, "qiskit_aer.noise.noise_model.NoiseModel"]] = None + noise_model: Optional[Union[dict, NoiseModel]] = None seed_simulator: Optional[int] = None - coupling_map: Optional[Union[List[List[int]], "CouplingMap"]] = None + coupling_map: Optional[Union[List[List[int]], CouplingMap]] = None basis_gates: Optional[List[str]] = None - @staticmethod - def validate_simulator_options(simulator_options: dict) -> None: - """Validate that simulator options are legal. - Raises: - ValueError: if any simulator option is not supported - """ - for opt in simulator_options: - if not opt in get_args(SimulatorSupportedOptions): - raise ValueError(f"Unsupported value '{opt}' for simulator.") - def set_backend(self, backend: Union[BackendV1, BackendV2]) -> None: """Set backend for simulation. This method changes noise_model, coupling_map, basis_gates according to given backend. diff --git a/qiskit_ibm_runtime/options/transpilation_options.py b/qiskit_ibm_runtime/options/transpilation_options.py index fb3e96ae6..d0dd7514d 100644 --- a/qiskit_ibm_runtime/options/transpilation_options.py +++ b/qiskit_ibm_runtime/options/transpilation_options.py @@ -12,39 +12,21 @@ """Transpilation options.""" -from typing import Optional, List, Union, Literal, get_args -from dataclasses import dataclass +from typing import List, Union, Literal +from pydantic.dataclasses import dataclass as pydantic_dataclass +from pydantic import Field, ConfigDict, field_validator -TranspilationSupportedOptions = Literal[ - "skip_transpilation", - "initial_layout", - "layout_method", - "routing_method", - "approximation_degree", -] -LayoutMethodType = Literal[ - "trivial", - "dense", - "noise_adaptive", - "sabre", -] -RoutingMethodType = Literal[ - "basic", - "lookahead", - "stochastic", - "sabre", - "none", -] +from .utils import Unset, UnsetType -@dataclass +@pydantic_dataclass(config=ConfigDict(validate_assignment=True, arbitrary_types_allowed=True, extra="forbid")) class TranspilationOptions: """Transpilation options. Args: - skip_transpilation: Whether to skip transpilation. + skip_transpilation: Whether to skip transpilation. Default is False. initial_layout: Initial position of virtual qubits on physical qubits. See ``qiskit.compiler.transpile`` for more information. @@ -60,38 +42,18 @@ class TranspilationOptions: """ skip_transpilation: bool = False - initial_layout: Optional[Union[dict, List]] = None # TODO: Support Layout - layout_method: Optional[str] = None - routing_method: Optional[str] = None - approximation_degree: Optional[float] = None - - @staticmethod - def validate_transpilation_options(transpilation_options: dict) -> None: - """Validate that transpilation options are legal. - Raises: - ValueError: if any transpilation option is not supported - ValueError: if layout_method is not in LayoutMethodType or None. - ValueError: if routing_method is not in RoutingMethodType or None. - ValueError: if approximation_degree in not None or in the range 0.0 to 1.0. - """ - for opt in transpilation_options: - if not opt in get_args(TranspilationSupportedOptions): - raise ValueError(f"Unsupported value '{opt}' for transpilation.") - layout_method = transpilation_options.get("layout_method") - if not (layout_method in get_args(LayoutMethodType) or layout_method is None): - raise ValueError( - f"Unsupported value {layout_method} for layout_method. " - f"Supported values are {get_args(LayoutMethodType)} and None" - ) - routing_method = transpilation_options.get("routing_method") - if not (routing_method in get_args(RoutingMethodType) or routing_method is None): - raise ValueError( - f"Unsupported value {routing_method} for routing_method. " - f"Supported values are {get_args(RoutingMethodType)} and None" - ) - approximation_degree = transpilation_options.get("approximation_degree") - if not (approximation_degree is None or 0.0 <= approximation_degree <= 1.0): + initial_layout: Union[UnsetType, dict, List] = Unset # TODO: Support Layout + layout_method: Union[UnsetType, Literal["trivial", "dense", "noise_adaptive", "sabre"]] = Unset + routing_method: Union[UnsetType, Literal["basic", "lookahead", "stochastic", "sabre", "none"]] = Unset + approximation_degree: Union[UnsetType, float] = Unset + + @field_validator("approximation_degree") + @classmethod + def _validate_approximation_degree(cls, degree: Union[UnsetType, float]): + """Validate approximation_degree.""" + if degree is not Unset and 0.0 <= degree <= 1.0: raise ValueError( "approximation_degree must be between 0.0 (maximal approximation) " "and 1.0 (no approximation)" ) + return degree diff --git a/qiskit_ibm_runtime/options/twirling_options.py b/qiskit_ibm_runtime/options/twirling_options.py index 5d14507f2..7c2f2ea98 100644 --- a/qiskit_ibm_runtime/options/twirling_options.py +++ b/qiskit_ibm_runtime/options/twirling_options.py @@ -12,12 +12,15 @@ """Twirling options.""" -from typing import Literal, get_args -from dataclasses import dataclass +from typing import Literal, Union + +from pydantic.dataclasses import dataclass as pydantic_dataclass +from pydantic import model_validator, ConfigDict + +from .utils import Unset, UnsetType TwirlingStrategyType = Literal[ - None, "active", "active-accum", "active-circuit", @@ -25,7 +28,7 @@ ] -@dataclass +@pydantic_dataclass(config=ConfigDict(validate_assignment=True, arbitrary_types_allowed=True, extra="forbid")) class TwirlingOptions: """Twirling options. @@ -53,25 +56,14 @@ class TwirlingOptions: resilience level 3. """ - gates: bool = None - measure: bool = None - strategy: TwirlingStrategyType = None - - @staticmethod - def validate_twirling_options(twirling_options: dict) -> None: - """Validate that twirling options are legal. - - Raises: - ValueError: if any resilience option is not supported - ValueError: if noise_amplifier is not in NoiseAmplifierType. - ValueError: if extrapolator is not in ExtrapolatorType. - ValueError: if extrapolator == "QuarticExtrapolator" and number of noise_factors < 5. - ValueError: if extrapolator == "CubicExtrapolator" and number of noise_factors < 4. - """ - if twirling_options.get("gates"): - strategy = twirling_options.get("strategy") - if strategy not in get_args(TwirlingStrategyType): - raise ValueError( - f"Unsupported value {strategy} for twirling strategy. " - f"Supported values are {get_args(TwirlingStrategyType)}" - ) + gates: Union[UnsetType, bool] = Unset + measure: Union[UnsetType, bool] = Unset + strategy: Union[UnsetType, TwirlingStrategyType] = Unset + + # @model_validator(mode='after') + # def _validate_options(self): + # """Validate the model.""" + # if self.gates is not True: + # self.strategy = Unset + + # return self diff --git a/qiskit_ibm_runtime/options/utils.py b/qiskit_ibm_runtime/options/utils.py index 0968c0ace..774341214 100644 --- a/qiskit_ibm_runtime/options/utils.py +++ b/qiskit_ibm_runtime/options/utils.py @@ -12,7 +12,9 @@ """Utility functions for options.""" -from typing import Optional +from typing import Optional, Union +import copy +from dataclasses import is_dataclass, asdict from ..ibm_backend import IBMBackend @@ -63,6 +65,15 @@ def _remove_dict_none_values(in_dict: dict, allowed_none_keys: Optional[set] = N _remove_dict_none_values(val, allowed_none_keys=allowed_none_keys) +def _remove_dict_unset_values(in_dict: dict) -> None: + """Remove Unset values.""" + for key, val in list(in_dict.items()): + if isinstance(val, Unset): + del in_dict[key] + elif isinstance(val, dict): + _remove_dict_unset_values(val) + + def _to_obj(cls_, data): # type: ignore if data is None: return cls_() @@ -75,6 +86,48 @@ def _to_obj(cls_, data): # type: ignore ) +def merge_options(old_options: Union[dict, "BaseOptions"], new_options: Optional[dict] = None) -> dict: + """Merge current options with the new ones. + + Args: + new_options: New options to merge. + + Returns: + Merged dictionary. + """ + + def _update_options(old: dict, new: dict, matched: Optional[dict] = None) -> None: + if not new and not matched: + return + matched = matched or {} + + for key, val in old.items(): + if isinstance(val, dict): + matched = new.pop(key, {}) + _update_options(val, new, matched) + elif key in new.keys(): + old[key] = new.pop(key) + elif key in matched.keys(): + old[key] = matched.pop(key) + + # Add new keys. + for key, val in matched.items(): + old[key] = val + + combined = asdict(old_options) if is_dataclass(old_options) else copy.deepcopy(old_options) + if not new_options: + return combined + new_options_copy = copy.deepcopy(new_options) + + # First update values of the same key. + _update_options(combined, new_options_copy) + + # Add new keys. + combined.update(new_options_copy) + + return combined + + class Dict: """Fake Dict type. @@ -83,3 +136,11 @@ class Dict: """ pass + + +class UnsetType: + """Class used to represent an unset field.""" + pass + + +Unset: UnsetType = UnsetType() diff --git a/qiskit_ibm_runtime/sampler.py b/qiskit_ibm_runtime/sampler.py index 0b19bcc5c..8ec0f03ed 100644 --- a/qiskit_ibm_runtime/sampler.py +++ b/qiskit_ibm_runtime/sampler.py @@ -19,6 +19,7 @@ from qiskit.circuit import QuantumCircuit from qiskit.primitives import BaseSampler +from qiskit.providers.options import Options as TerraOptions from .options import Options from .runtime_job import RuntimeJob @@ -33,6 +34,12 @@ class Sampler(BasePrimitive, BaseSampler): + """Base type for Sampelr.""" + + version = 0 + + +class SamplerV1(Sampler): """Class for interacting with Qiskit Runtime Sampler primitive service. Qiskit Runtime Sampler primitive service calculates quasi-probability distribution @@ -62,6 +69,8 @@ class Sampler(BasePrimitive, BaseSampler): # You can run more jobs inside the session """ + _OPTIONS_CLASS = Options + def __init__( self, backend: Optional[Union[str, IBMBackend]] = None, @@ -170,6 +179,15 @@ def _validate_options(self, options: dict) -> None: ) Options.validate_options(options) + @property + def options(self) -> TerraOptions: + """Return options values for the sampler. + + Returns: + options + """ + return TerraOptions(**self._options) + @classmethod def _program_id(cls) -> str: """Return the program ID.""" diff --git a/test/unit/test_options.py b/test/unit/test_options.py index 0b7ee595d..935cfede1 100644 --- a/test/unit/test_options.py +++ b/test/unit/test_options.py @@ -21,6 +21,8 @@ from qiskit_aer.noise import NoiseModel from qiskit_ibm_runtime import Options, RuntimeOptions +from qiskit_ibm_runtime.options.utils import merge_options +from qiskit_ibm_runtime.options import EstimatorOptions from qiskit_ibm_runtime.utils.qctrl import _warn_and_clean_options from ..ibm_test_case import IBMTestCase @@ -31,7 +33,8 @@ class TestOptions(IBMTestCase): """Class for testing the Sampler class.""" - def test_merge_options(self): + @data(Options, EstimatorOptions) + def test_merge_options(self, opt_cls): """Test merging options.""" options_vars = [ {}, @@ -47,8 +50,8 @@ def test_merge_options(self): ] for new_ops in options_vars: with self.subTest(new_ops=new_ops): - options = Options() - combined = Options._merge_options(asdict(options), new_ops) + options = opt_cls() + combined = merge_options(asdict(options), new_ops) # Make sure the values are equal. self.assertTrue( @@ -106,6 +109,8 @@ def test_program_inputs(self): "noise_factors": (0, 2, 4), }, } + import pprint + pprint.pprint(inputs) self.assertTrue( dict_paritally_equal(inputs, expected), f"inputs={inputs}, expected={expected}", From 3671b46356e69456f7683e7f1d5a24201390f2c3 Mon Sep 17 00:00:00 2001 From: Jessie Yu Date: Fri, 3 Nov 2023 15:12:10 -0400 Subject: [PATCH 08/35] estimator options --- qiskit_ibm_runtime/__init__.py | 2 +- qiskit_ibm_runtime/base_primitive.py | 33 +- qiskit_ibm_runtime/estimator.py | 49 +- .../options/estimator_options.py | 21 +- .../options/execution_options.py | 5 +- qiskit_ibm_runtime/options/options.py | 30 +- .../options/resilience_options.py | 93 +-- .../options/transpilation_options.py | 5 +- .../options/twirling_options.py | 9 +- qiskit_ibm_runtime/options/utils.py | 33 +- .../qiskit/primitives/__init__.py | 2 + .../qiskit/primitives/base_estimator.py | 94 +++ .../qiskit/primitives/base_primitive.py | 64 ++ qiskit_ibm_runtime/sampler.py | 2 + qiskit_ibm_runtime/utils/qctrl.py | 8 +- test/ibm_test_case.py | 2 + test/unit/test_estimator.py | 88 ++- test/unit/test_estimator_options.py | 247 ++++++ test/unit/test_ibm_primitives.py | 67 +- test/unit/test_ibm_primitives_v2.py | 733 ++++++++++++++++++ test/utils.py | 42 +- 21 files changed, 1447 insertions(+), 182 deletions(-) create mode 100644 qiskit_ibm_runtime/qiskit/primitives/__init__.py create mode 100644 qiskit_ibm_runtime/qiskit/primitives/base_estimator.py create mode 100644 qiskit_ibm_runtime/qiskit/primitives/base_primitive.py create mode 100644 test/unit/test_estimator_options.py create mode 100644 test/unit/test_ibm_primitives_v2.py diff --git a/qiskit_ibm_runtime/__init__.py b/qiskit_ibm_runtime/__init__.py index 9bb797e46..1cee9a8b4 100644 --- a/qiskit_ibm_runtime/__init__.py +++ b/qiskit_ibm_runtime/__init__.py @@ -190,7 +190,7 @@ def result_callback(job_id, result): from .estimator import EstimatorV1 as Estimator, EstimatorV2 from .sampler import SamplerV1 as Sampler -from .options import Options +from .options import Options, EstimatorOptions # Setup the logger for the IBM Quantum Provider package. logger = logging.getLogger(__name__) diff --git a/qiskit_ibm_runtime/base_primitive.py b/qiskit_ibm_runtime/base_primitive.py index 541942a51..cd6e4cef8 100644 --- a/qiskit_ibm_runtime/base_primitive.py +++ b/qiskit_ibm_runtime/base_primitive.py @@ -22,7 +22,7 @@ from qiskit.providers.options import Options as TerraOptions from .options import BaseOptions -from .options.utils import merge_options +from .options.utils import merge_options, set_default_error_levels from .runtime_job import RuntimeJob from .ibm_backend import IBMBackend from .session import get_cm_session @@ -68,10 +68,6 @@ def __init__( Raises: ValueError: Invalid arguments are given. """ - # `self._options` in this class is a Dict. - # The base class, however, uses a `_run_options` which is an instance of - # qiskit.providers.Options. We largely ignore this _run_options because we use - # a nested dictionary to categorize options. self._session: Optional[Session] = None self._service: QiskitRuntimeService = None self._backend: Optional[IBMBackend] = None @@ -126,10 +122,30 @@ def _run_primitive(self, primitive_inputs: Dict, user_kwargs: Dict) -> RuntimeJo Submitted job. """ my_options = self._options if self.version == 1 else self.options + logger.debug("Merging current options %s with %s", my_options, user_kwargs) combined = merge_options(my_options, user_kwargs) - self._validate_options(combined) - primitive_inputs.update(my_options._get_program_inputs(combined)) + if self.version == 1: + if self._backend: + combined = set_default_error_levels( + combined, + self._backend, + self._OPTIONS_CLASS._DEFAULT_OPTIMIZATION_LEVEL, + self._OPTIONS_CLASS._DEFAULT_RESILIENCE_LEVEL, + ) + else: + combined["optimization_level"] = self._OPTIONS_CLASS._DEFAULT_OPTIMIZATION_LEVEL + combined["resilience_level"] = self._OPTIONS_CLASS._DEFAULT_RESILIENCE_LEVEL + + self._validate_options(combined) + combined = self._OPTIONS_CLASS._set_default_resilience_options(combined) + combined = self._OPTIONS_CLASS._remove_none_values(combined) + primitive_inputs.update(self._OPTIONS_CLASS._get_program_inputs(combined)) + runtime_options = self._OPTIONS_CLASS._get_runtime_options(combined) + else: + self._validate_options(combined) + primitive_inputs.update(my_options._get_program_inputs(combined)) + runtime_options = my_options._get_runtime_options(combined) if self._backend and combined["transpilation"]["skip_transpilation"]: for circ in primitive_inputs["circuits"]: @@ -137,7 +153,6 @@ def _run_primitive(self, primitive_inputs: Dict, user_kwargs: Dict) -> RuntimeJo logger.info("Submitting job using options %s", combined) - runtime_options = my_options._get_runtime_options(combined) if self._session: return self._session.run( program_id=self._program_id(), @@ -184,6 +199,8 @@ def _initialize_options(self, options: Optional[Union[Dict, BaseOptions]] = None """Initialize the options.""" opt_cls = self._OPTIONS_CLASS + logger.debug("Initializing %s with options %s", self.__class__.__name__, options) + if self.version == 1: if options is None: self._options = asdict(opt_cls()) diff --git a/qiskit_ibm_runtime/estimator.py b/qiskit_ibm_runtime/estimator.py index 5fcb3d757..87022b287 100644 --- a/qiskit_ibm_runtime/estimator.py +++ b/qiskit_ibm_runtime/estimator.py @@ -39,6 +39,8 @@ from .base_primitive import BasePrimitive from .utils.qctrl import validate as qctrl_validate from .utils.deprecation import issue_deprecation_msg +# TODO: remove when we have real v2 base estimator +from .qiskit.primitives import BaseEstimatorV2 # pylint: disable=unused-import,cyclic-import from .session import Session @@ -66,11 +68,11 @@ """Parameter types that can be bound to a single circuit.""" -class Estimator(BasePrimitive, BaseEstimator): +class Estimator(BasePrimitive): _PROGRAM_ID = "estimator" version = 0 -class EstimatorV2(Estimator): +class EstimatorV2(Estimator, BaseEstimatorV2): """Class for interacting with Qiskit Runtime Estimator primitive service. Qiskit Runtime Estimator primitive service estimates expectation values of quantum circuits and @@ -144,10 +146,12 @@ def __init__( options: Primitive options, see :class:`Options` for detailed description. The ``backend`` keyword is still supported but is deprecated. """ - BaseEstimator.__init__(self) + BaseEstimatorV2.__init__(self) BasePrimitive.__init__(self, backend=backend, session=session, options=options) - self.options._is_simulator = self._backend and self._backend.configuration().simulator + self.options._is_simulator = self._backend is not None and self._backend.configuration().simulator is True + if self._service._channel_strategy == "q-ctrl": + raise NotImplemented("EstimatorV2 is not supported with q-ctrl channel strategy.") def run( # pylint: disable=arguments-differ self, @@ -181,12 +185,25 @@ def run( # pylint: disable=arguments-differ """ # To bypass base class merging of options. user_kwargs = {"_user_kwargs": kwargs} - return super().run( + + circuits = self._validate_circuits(circuits=circuits) + observables = self._validate_observables(observables=observables) + parameter_values = self._validate_parameter_values(parameter_values=parameter_values, default=[()] * len(circuits),) + self._cross_validate_circuits_observables(circuits=circuits, observables=observables) + self._cross_validate_circuits_parameter_values(circuits=circuits, parameter_values=parameter_values) + + return self._run( circuits=circuits, observables=observables, parameter_values=parameter_values, - **user_kwargs, + **user_kwargs ) + # return super().run( + # circuits=circuits, + # observables=observables, + # parameter_values=parameter_values, + # **user_kwargs, + # ) def _run( # pylint: disable=arguments-differ self, @@ -211,6 +228,7 @@ def _run( # pylint: disable=arguments-differ Returns: Submitted job """ + logger.debug("Running %s with new options %s", self.__class__.__name__, kwargs.get("_user_kwargs", {})) inputs = { "circuits": circuits, "observables": observables, @@ -223,17 +241,11 @@ def _run( # pylint: disable=arguments-differ def _validate_options(self, options: dict) -> None: """Validate that program inputs (options) are valid + Raises: - ValueError: if resilience_level is out of the allowed range. - ValueError: if resilience_level==3, backend is simulator and no coupling map + ValidationError: if validation fails. """ - if os.getenv("QISKIT_RUNTIME_SKIP_OPTIONS_VALIDATION"): - return - - # TODO: Fix q-ctrl validation - if self._service._channel_strategy == "q-ctrl": - qctrl_validate(options) - return + self._OPTIONS_CLASS(**options) @staticmethod def _validate_observables( @@ -241,11 +253,10 @@ def _validate_observables( ) -> Sequence[ObservablesArrayLike]: def _check_and_init(obs: Any) -> Any: if isinstance(obs, str): - pass - if not all(basis in Estimator._ALLOWED_BASIS for basis in obs): + if not all(basis in EstimatorV2._ALLOWED_BASIS for basis in obs): raise ValueError( f"Invalid character(s) found in observable string. " - f"Allowed basis are {Estimator._ALLOWED_BASIS}." + f"Allowed basis are {EstimatorV2._ALLOWED_BASIS}." ) elif isinstance(obs, Sequence): return tuple(_check_and_init(obs_) for obs_ in obs) @@ -319,7 +330,7 @@ def _program_id(cls) -> str: return "estimator" -class EstimatorV1(Estimator): +class EstimatorV1(Estimator, BaseEstimator): """Class for interacting with Qiskit Runtime Estimator primitive service. Qiskit Runtime Estimator primitive service estimates expectation values of quantum circuits and diff --git a/qiskit_ibm_runtime/options/estimator_options.py b/qiskit_ibm_runtime/options/estimator_options.py index 4cba6a6c4..07fc2462f 100644 --- a/qiskit_ibm_runtime/options/estimator_options.py +++ b/qiskit_ibm_runtime/options/estimator_options.py @@ -12,14 +12,14 @@ """Primitive options.""" -from typing import Optional, Union, ClassVar, Literal, get_args, Any +from typing import Union, Literal import copy from qiskit.transpiler import CouplingMap from pydantic.dataclasses import dataclass as pydantic_dataclass from pydantic import Field, ConfigDict, model_validator, field_validator -from .utils import Dict, Unset, UnsetType, _remove_dict_unset_values +from .utils import Dict, Unset, UnsetType, _remove_dict_unset_values, merge_options, skip_unset_validation from .execution_options import ExecutionOptionsV2 from .transpilation_options import TranspilationOptions from .resilience_options import ResilienceOptionsV2 @@ -110,9 +110,10 @@ def _validate_optimization_level(cls, optimization_level: Union[UnsetType, int]) @field_validator("resilience_level") @classmethod + @skip_unset_validation def _validate_resilience_level(cls, resilience_level: Union[UnsetType, int]): """Validate resilience_level.""" - if not isinstance(resilience_level, UnsetType) and not (0 <= resilience_level <= 3): + if not (0 <= resilience_level <= 3): raise ValueError(f"Invalid optimization_level. Valid range is 0-{EstimatorOptionsV2._MAX_RESILIENCE_LEVEL}") return resilience_level @@ -157,16 +158,6 @@ def _get_program_inputs(options: dict) -> dict: inputs["resilience_level"] = options.get("resilience_level") inputs["resilience"] = options.get("resilience", {}) - # TODO: Turn off ZNE/PEC fields - # Turn off all ZNE fields - # self.zne_extrapolator = Unset - # self.zne_noise_factors = Unset - # self.zne_stderr_threshold = Unset - - # # Validate PEC options - # if isinstance(self.pec_mitigation, UnsetType): - # self.pec_max_overhead = Unset - inputs["twirling"] = options.get("twirling", {}) inputs["execution"] = options.get("execution") @@ -177,6 +168,10 @@ def _get_program_inputs(options: dict) -> dict: } ) + # Add arbitrary experimental options + if isinstance(options.get("experimental", None), dict): + inputs = merge_options(inputs, options.get("experimental")) + inputs["_experimental"] = True inputs["version"] = EstimatorOptionsV2._version _remove_dict_unset_values(inputs) diff --git a/qiskit_ibm_runtime/options/execution_options.py b/qiskit_ibm_runtime/options/execution_options.py index 1fc1aeeb5..480d885ba 100644 --- a/qiskit_ibm_runtime/options/execution_options.py +++ b/qiskit_ibm_runtime/options/execution_options.py @@ -17,7 +17,7 @@ from pydantic.dataclasses import dataclass as pydantic_dataclass from pydantic import Field, ConfigDict, model_validator, field_validator, ValidationInfo -from .utils import Unset, UnsetType +from .utils import Unset, UnsetType, skip_unset_validation @pydantic_dataclass(config=ConfigDict(validate_assignment=True, arbitrary_types_allowed=True, extra="forbid")) @@ -55,9 +55,10 @@ class ExecutionOptionsV2: @field_validator("shots", "samples", "shots_per_sample") @classmethod + @skip_unset_validation def _validate_positive_integer(cls, fld: Union[UnsetType, int], info: ValidationInfo): """Validate zne_stderr_threshold.""" - if isinstance(fld, int) and fld < 1: + if fld < 1: raise ValueError(f"{info.field_name} must be >= 1") return fld diff --git a/qiskit_ibm_runtime/options/options.py b/qiskit_ibm_runtime/options/options.py index e19f0d3c7..ec58df55f 100644 --- a/qiskit_ibm_runtime/options/options.py +++ b/qiskit_ibm_runtime/options/options.py @@ -22,17 +22,19 @@ from pydantic.dataclasses import dataclass as pydantic_dataclass from pydantic import Field, ConfigDict -from .utils import Dict, _to_obj, UnsetType, Unset +from .utils import Dict, _to_obj, UnsetType, Unset, _remove_dict_unset_values from .environment_options import EnvironmentOptions from .execution_options import ExecutionOptionsV1 as ExecutionOptions from .simulator_options import SimulatorOptions from .transpilation_options import TranspilationOptions from .resilience_options import ResilienceOptionsV1 as ResilienceOptions from ..runtime_options import RuntimeOptions +# TODO use real base options when available +from ..qiskit.primitives import BasePrimitiveOptions @dataclass -class BaseOptions(ABC): +class BaseOptions(ABC, BasePrimitiveOptions): @abstractmethod def _get_program_inputs(options: dict) -> dict: @@ -45,17 +47,19 @@ def _get_runtime_options(options: dict) -> dict: Returns: Runtime options. """ - environment = options.get("environment") or {} - out = {"max_execution_time": options.get("max_execution_time", None)} + options_copy = copy.deepcopy(options) + _remove_dict_unset_values(options_copy) + environment = options_copy.get("environment") or {} + out = {"max_execution_time": options_copy.get("max_execution_time", None)} for fld in fields(RuntimeOptions): if fld.name in environment: out[fld.name] = environment[fld.name] - if "image" in options: - out["image"] = options["image"] - elif "image" in options.get("experimental", {}): - out["image"] = options["experimental"]["image"] + if "image" in options_copy: + out["image"] = options_copy["image"] + elif "image" in options_copy.get("experimental", {}): + out["image"] = options_copy["experimental"]["image"] return out @@ -235,8 +239,8 @@ def validate_options(options: dict) -> None: f"optimization_level can only take the values " f"{list(range(Options._MAX_OPTIMIZATION_LEVEL + 1))}" ) - ResilienceOptions.validate_resilience_options(options.get("resilience")) - TranspilationOptions.validate_transpilation_options(options.get("transpilation")) + ResilienceOptions(**options.get("resilience", {})) + TranspilationOptions(**options.get("transpilation", {})) execution_time = options.get("max_execution_time") if execution_time is not None: if execution_time > Options._MAX_EXECUTION_TIME: @@ -244,9 +248,9 @@ def validate_options(options: dict) -> None: f"max_execution_time must be below " f"{Options._MAX_EXECUTION_TIME} seconds." ) - EnvironmentOptions.validate_environment_options(options.get("environment")) - ExecutionOptions.validate_execution_options(options.get("execution")) - SimulatorOptions.validate_simulator_options(options.get("simulator")) + EnvironmentOptions(**options.get("environment", {})) + ExecutionOptions(**options.get("execution", {})) + SimulatorOptions(**options.get("simulator", {})) @staticmethod def _remove_none_values(options: dict) -> dict: diff --git a/qiskit_ibm_runtime/options/resilience_options.py b/qiskit_ibm_runtime/options/resilience_options.py index 3f1f84827..74b4adbb3 100644 --- a/qiskit_ibm_runtime/options/resilience_options.py +++ b/qiskit_ibm_runtime/options/resilience_options.py @@ -12,13 +12,12 @@ """Resilience options.""" -from typing import Sequence, Literal, get_args, Union, Optional -from dataclasses import dataclass, fields +from typing import Sequence, Literal, Union, Optional from pydantic.dataclasses import dataclass as pydantic_dataclass -from pydantic import Field, ConfigDict, field_validator, model_validator +from pydantic import ConfigDict, field_validator, model_validator -from .utils import Dict, Unset, UnsetType +from .utils import Unset, UnsetType, skip_unset_validation ResilienceSupportedOptions = Literal[ "noise_amplifier", @@ -103,55 +102,57 @@ class ResilienceOptionsV2: @field_validator("zne_noise_factors") @classmethod + @skip_unset_validation def _validate_zne_noise_factors(cls, factors: Union[UnsetType, Sequence[float]]): """Validate zne_noise_factors.""" - if isinstance(factors, Sequence) and any(i <= 0 for i in factors): - raise ValueError("zne_noise_factors` option value must all be non-negative") + if any(i < 1 for i in factors): + raise ValueError("zne_noise_factors` option value must all be >= 1") return factors @field_validator("zne_stderr_threshold") @classmethod + @skip_unset_validation def _validate_zne_stderr_threshold(cls, threshold: Union[UnsetType, float]): """Validate zne_stderr_threshold.""" - if isinstance(threshold, float) and threshold <= 0: + if threshold <= 0: raise ValueError("Invalid zne_stderr_threshold option value must be > 0") return threshold @field_validator("pec_max_overhead") @classmethod + @skip_unset_validation def _validate_pec_max_overhead(cls, overhead: Union[UnsetType, float]): """Validate pec_max_overhead.""" - if isinstance(overhead, float) and overhead < 1: + if overhead < 1: raise ValueError("pec_max_overhead must be None or >= 1") return overhead @model_validator(mode='after') def _validate_options(self): """Validate the model.""" - # Validate ZNE options - if self.zne_mitigation is True: - # Validate noise factors + extrapolator combination - if all(not isinstance(fld, UnsetType) for fld in [self.zne_noise_factors, self.zne_extrapolator]): - required_factors = { - "exponential": 2, - "double_exponential": 4, - "linear": 2, - "polynomial_degree_1": 2, - "polynomial_degree_2": 3, - "polynomial_degree_3": 4, - "polynomial_degree_4": 5, - } - for extrap in self.zne_extrapolator: - if len(self.zne_noise_factors) < required_factors[extrap]: - raise ValueError( - f"{extrap} requires at least {required_factors[extrap]} zne_noise_factors" - ) - # Validate not ZNE+PEC - if self.pec_mitigation: - raise ValueError( - "pec_mitigation and zne_mitigation`options cannot be " - "simultaneously enabled. Set one of them to False." - ) + # Validate ZNE noise factors + extrapolator combination + if all(not isinstance(fld, UnsetType) for fld in [self.zne_noise_factors, self.zne_extrapolator]): + required_factors = { + "exponential": 2, + "double_exponential": 4, + "linear": 2, + "polynomial_degree_1": 2, + "polynomial_degree_2": 3, + "polynomial_degree_3": 4, + "polynomial_degree_4": 5, + } + extrapolators = [self.zne_extrapolator] if isinstance(self.zne_extrapolator, str) else self.zne_extrapolator + for extrap in extrapolators: + if len(self.zne_noise_factors) < required_factors[extrap]: + raise ValueError( + f"{extrap} requires at least {required_factors[extrap]} zne_noise_factors" + ) + # Validate not ZNE+PEC + if self.pec_mitigation is True and self.zne_mitigation is True: + raise ValueError( + "pec_mitigation and zne_mitigation`options cannot be " + "simultaneously enabled. Set one of them to False." + ) return self @@ -199,21 +200,13 @@ class ResilienceOptionsV1: noise_factors: Optional[Sequence[float]] = None extrapolator: Optional[ExtrapolatorType] = None - @staticmethod - def validate_resilience_options(resilience_options: dict) -> None: - """Validate that resilience options are legal. - Raises: - ValueError: if any resilience option is not supported - ValueError: if noise_amplifier is not in NoiseAmplifierType. - ValueError: if extrapolator is not in ExtrapolatorType. - ValueError: if extrapolator == "QuarticExtrapolator" and number of noise_factors < 5. - ValueError: if extrapolator == "CubicExtrapolator" and number of noise_factors < 4. - """ - extrapolator = resilience_options.get("extrapolator") - if ( - extrapolator == "QuarticExtrapolator" - and len(resilience_options.get("noise_factors")) < 5 - ): - raise ValueError("QuarticExtrapolator requires at least 5 noise_factors.") - if extrapolator == "CubicExtrapolator" and len(resilience_options.get("noise_factors")) < 4: - raise ValueError("CubicExtrapolator requires at least 4 noise_factors.") + @model_validator(mode='after') + def _validate_options(self): + """Validate the model.""" + required_factors = { + "QuarticExtrapolator": 5, + "CubicExtrapolator": 4, + } + req_len = required_factors.get(self.extrapolator, None) + if req_len and len(self.noise_factors) < req_len: + raise ValueError(f"{self.extrapolator} requires at least {req_len} noise_factors.") diff --git a/qiskit_ibm_runtime/options/transpilation_options.py b/qiskit_ibm_runtime/options/transpilation_options.py index d0dd7514d..cb39d069b 100644 --- a/qiskit_ibm_runtime/options/transpilation_options.py +++ b/qiskit_ibm_runtime/options/transpilation_options.py @@ -17,7 +17,7 @@ from pydantic.dataclasses import dataclass as pydantic_dataclass from pydantic import Field, ConfigDict, field_validator -from .utils import Unset, UnsetType +from .utils import Unset, UnsetType, skip_unset_validation @pydantic_dataclass(config=ConfigDict(validate_assignment=True, arbitrary_types_allowed=True, extra="forbid")) @@ -49,9 +49,10 @@ class TranspilationOptions: @field_validator("approximation_degree") @classmethod + @skip_unset_validation def _validate_approximation_degree(cls, degree: Union[UnsetType, float]): """Validate approximation_degree.""" - if degree is not Unset and 0.0 <= degree <= 1.0: + if not (0.0 <= degree <= 1.0): raise ValueError( "approximation_degree must be between 0.0 (maximal approximation) " "and 1.0 (no approximation)" diff --git a/qiskit_ibm_runtime/options/twirling_options.py b/qiskit_ibm_runtime/options/twirling_options.py index 7c2f2ea98..b34d67028 100644 --- a/qiskit_ibm_runtime/options/twirling_options.py +++ b/qiskit_ibm_runtime/options/twirling_options.py @@ -15,7 +15,7 @@ from typing import Literal, Union from pydantic.dataclasses import dataclass as pydantic_dataclass -from pydantic import model_validator, ConfigDict +from pydantic import ConfigDict from .utils import Unset, UnsetType @@ -60,10 +60,3 @@ class TwirlingOptions: measure: Union[UnsetType, bool] = Unset strategy: Union[UnsetType, TwirlingStrategyType] = Unset - # @model_validator(mode='after') - # def _validate_options(self): - # """Validate the model.""" - # if self.gates is not True: - # self.strategy = Unset - - # return self diff --git a/qiskit_ibm_runtime/options/utils.py b/qiskit_ibm_runtime/options/utils.py index 774341214..dc34f1745 100644 --- a/qiskit_ibm_runtime/options/utils.py +++ b/qiskit_ibm_runtime/options/utils.py @@ -12,9 +12,11 @@ """Utility functions for options.""" -from typing import Optional, Union +from typing import Optional, Union, Callable +import functools import copy from dataclasses import is_dataclass, asdict +from functools import cache from ..ibm_backend import IBMBackend @@ -56,19 +58,10 @@ def set_default_error_levels( return options -def _remove_dict_none_values(in_dict: dict, allowed_none_keys: Optional[set] = None) -> None: - allowed_none_keys = allowed_none_keys or set() - for key, val in list(in_dict.items()): - if val is None and key not in allowed_none_keys: - del in_dict[key] - elif isinstance(val, dict): - _remove_dict_none_values(val, allowed_none_keys=allowed_none_keys) - - def _remove_dict_unset_values(in_dict: dict) -> None: """Remove Unset values.""" for key, val in list(in_dict.items()): - if isinstance(val, Unset): + if isinstance(val, UnsetType): del in_dict[key] elif isinstance(val, dict): _remove_dict_unset_values(val) @@ -128,6 +121,16 @@ def _update_options(old: dict, new: dict, matched: Optional[dict] = None) -> Non return combined +def skip_unset_validation(func: Callable) -> Callable: + """Decorator used to skip unset value""" + @functools.wraps(func) + def wrapper(cls, val, *args, **kwargs) -> Callable: + if isinstance(val, UnsetType): + return val + return func(cls, val, *args, **kwargs) + + return wrapper + class Dict: """Fake Dict type. @@ -140,7 +143,13 @@ class Dict: class UnsetType: """Class used to represent an unset field.""" - pass + + def __repr__(self) -> str: + return "Unset" + + @cache + def __new__(cls) -> "UnsetType": + return super().__new__(cls) Unset: UnsetType = UnsetType() diff --git a/qiskit_ibm_runtime/qiskit/primitives/__init__.py b/qiskit_ibm_runtime/qiskit/primitives/__init__.py new file mode 100644 index 000000000..4dc147c5b --- /dev/null +++ b/qiskit_ibm_runtime/qiskit/primitives/__init__.py @@ -0,0 +1,2 @@ +from .base_estimator import BaseEstimatorV2 +from .base_primitive import BasePrimitiveOptions \ No newline at end of file diff --git a/qiskit_ibm_runtime/qiskit/primitives/base_estimator.py b/qiskit_ibm_runtime/qiskit/primitives/base_estimator.py new file mode 100644 index 000000000..d8f07576e --- /dev/null +++ b/qiskit_ibm_runtime/qiskit/primitives/base_estimator.py @@ -0,0 +1,94 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2022. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +r""" + +.. estimator-desc: + +===================== +Overview of Estimator +===================== + +Estimator class estimates expectation values of quantum circuits and observables. + +An estimator is initialized with an empty parameter set. The estimator is used to +create a :class:`~qiskit.providers.JobV1`, via the +:meth:`qiskit.primitives.Estimator.run()` method. This method is called +with the following parameters + +* quantum circuits (:math:`\psi_i(\theta)`): list of (parameterized) quantum circuits + (a list of :class:`~qiskit.circuit.QuantumCircuit` objects). + +* observables (:math:`H_j`): a list of :class:`~qiskit.quantum_info.SparsePauliOp` + objects. + +* parameter values (:math:`\theta_k`): list of sets of values + to be bound to the parameters of the quantum circuits + (list of list of float). + +The method returns a :class:`~qiskit.providers.JobV1` object, calling +:meth:`qiskit.providers.JobV1.result()` yields the +a list of expectation values plus optional metadata like confidence intervals for +the estimation. + +.. math:: + + \langle\psi_i(\theta_k)|H_j|\psi_i(\theta_k)\rangle + +Here is an example of how the estimator is used. + +.. code-block:: python + + from qiskit.primitives import Estimator + from qiskit.circuit.library import RealAmplitudes + from qiskit.quantum_info import SparsePauliOp + + psi1 = RealAmplitudes(num_qubits=2, reps=2) + psi2 = RealAmplitudes(num_qubits=2, reps=3) + + H1 = SparsePauliOp.from_list([("II", 1), ("IZ", 2), ("XI", 3)]) + H2 = SparsePauliOp.from_list([("IZ", 1)]) + H3 = SparsePauliOp.from_list([("ZI", 1), ("ZZ", 1)]) + + theta1 = [0, 1, 1, 2, 3, 5] + theta2 = [0, 1, 1, 2, 3, 5, 8, 13] + theta3 = [1, 2, 3, 4, 5, 6] + + estimator = Estimator() + + # calculate [ ] + job = estimator.run([psi1], [H1], [theta1]) + job_result = job.result() # It will block until the job finishes. + print(f"The primitive-job finished with result {job_result}")) + + # calculate [ , + # , + # ] + job2 = estimator.run([psi1, psi2, psi1], [H1, H2, H3], [theta1, theta2, theta3]) + job_result = job2.result() + print(f"The primitive-job finished with result {job_result}") +""" + +from __future__ import annotations + +from typing import Generic, TypeVar + +from qiskit.providers import JobV1 as Job + +from .base_primitive import BasePrimitiveV2 + +T = TypeVar("T", bound=Job) + + +class BaseEstimatorV2(BasePrimitiveV2, Generic[T]): + + version = 2 diff --git a/qiskit_ibm_runtime/qiskit/primitives/base_primitive.py b/qiskit_ibm_runtime/qiskit/primitives/base_primitive.py new file mode 100644 index 000000000..82719bf1c --- /dev/null +++ b/qiskit_ibm_runtime/qiskit/primitives/base_primitive.py @@ -0,0 +1,64 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2022. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +"""Primitive abstract base class.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass, replace +from collections.abc import Sequence + +from qiskit.circuit import QuantumCircuit + + +@dataclass +class BasePrimitiveOptions: + + def __call__(self): + return replace(self) + + +class BasePrimitiveV2(ABC): + """Version 2 of primitive abstract base class.""" + + version = 2 + + def __init__(self, options: dict | None | BasePrimitiveOptions = None): + + pass + + @abstractmethod + def set_options(self, **fields): + """Set options values for the estimator. + + Args: + **fields: The fields to update the options + """ + raise NotImplementedError() + + + @staticmethod + def _validate_circuits( + circuits: Sequence[QuantumCircuit] | QuantumCircuit, + ) -> tuple[QuantumCircuit, ...]: + if isinstance(circuits, QuantumCircuit): + circuits = (circuits,) + elif not isinstance(circuits, Sequence) or not all( + isinstance(cir, QuantumCircuit) for cir in circuits + ): + raise TypeError("Invalid circuits, expected Sequence[QuantumCircuit].") + elif not isinstance(circuits, tuple): + circuits = tuple(circuits) + if len(circuits) == 0: + raise ValueError("No circuits were provided.") + return circuits diff --git a/qiskit_ibm_runtime/sampler.py b/qiskit_ibm_runtime/sampler.py index 8ec0f03ed..fa3d99cbb 100644 --- a/qiskit_ibm_runtime/sampler.py +++ b/qiskit_ibm_runtime/sampler.py @@ -71,6 +71,8 @@ class SamplerV1(Sampler): _OPTIONS_CLASS = Options + version = 1 + def __init__( self, backend: Optional[Union[str, IBMBackend]] = None, diff --git a/qiskit_ibm_runtime/utils/qctrl.py b/qiskit_ibm_runtime/utils/qctrl.py index f3acd1ec2..374592be0 100644 --- a/qiskit_ibm_runtime/utils/qctrl.py +++ b/qiskit_ibm_runtime/utils/qctrl.py @@ -30,7 +30,7 @@ def validate(options: Dict[str, Any]) -> None: _warn_and_clean_options(options) # Default validation otherwise. - TranspilationOptions.validate_transpilation_options(options.get("transpilation")) + TranspilationOptions(**options.get("transpilation", {})) execution_time = options.get("max_execution_time") if execution_time is not None: if execution_time > Options._MAX_EXECUTION_TIME: @@ -38,9 +38,9 @@ def validate(options: Dict[str, Any]) -> None: f"max_execution_time must be below " f"{Options._MAX_EXECUTION_TIME} seconds." ) - EnvironmentOptions.validate_environment_options(options.get("environment")) - ExecutionOptions.validate_execution_options(options.get("execution")) - SimulatorOptions.validate_simulator_options(options.get("simulator")) + EnvironmentOptions(**options.get("environment", {})) + ExecutionOptions(**options.get("execution", {})) + SimulatorOptions(**options.get("simulator", {})) def _raise_if_error_in_options(options: Dict[str, Any]) -> None: diff --git a/test/ibm_test_case.py b/test/ibm_test_case.py index 2878597d4..d301fc413 100644 --- a/test/ibm_test_case.py +++ b/test/ibm_test_case.py @@ -30,6 +30,8 @@ from .templates import RUNTIME_PROGRAM, RUNTIME_PROGRAM_METADATA, PROGRAM_PREFIX +logging.getLogger("qiskit_ibm_runtime").setLevel("DEBUG") + class IBMTestCase(unittest.TestCase): """Custom TestCase for use with qiskit-ibm-runtime.""" diff --git a/test/unit/test_estimator.py b/test/unit/test_estimator.py index 7d29a9df4..d507b6104 100644 --- a/test/unit/test_estimator.py +++ b/test/unit/test_estimator.py @@ -12,18 +12,20 @@ """Tests for estimator class.""" +from unittest.mock import MagicMock, patch from qiskit import QuantumCircuit from qiskit.quantum_info import SparsePauliOp, Pauli, random_hermitian, random_pauli_list from qiskit.circuit import Parameter import numpy as np +from ddt import data, ddt -from qiskit_ibm_runtime import Estimator, Session +from qiskit_ibm_runtime import Estimator, Session, EstimatorV2, EstimatorOptions from .mock.fake_runtime_service import FakeRuntimeService from ..ibm_test_case import IBMTestCase -from ..utils import get_mocked_backend +from ..utils import get_mocked_backend, MockSession, dict_paritally_equal class TestEstimator(IBMTestCase): @@ -51,6 +53,76 @@ def test_unsupported_values_for_estimator_options(self): _ = inst.run(self.circuit, observables=self.observables, **bad_opt) self.assertIn(list(bad_opt.keys())[0], str(exc.exception)) + +@ddt +class TestEstimatorV2(IBMTestCase): + """Class for testing the Estimator class.""" + + def setUp(self) -> None: + super().setUp() + self.circuit = QuantumCircuit(1, 1) + self.observables = SparsePauliOp.from_list([("I", 1)]) + + def test_unsupported_values_for_estimator_options(self): + """Test exception when options levels are not supported.""" + options_bad = [ + {"resilience_level": 4, "optimization_level": 3}, + {"optimization_level": 4, "resilience_level": 2}, + ] + + with Session( + service=FakeRuntimeService(channel="ibm_quantum", token="abc"), + backend="common_backend", + ) as session: + for bad_opt in options_bad: + inst = EstimatorV2(session=session) + with self.assertRaises(ValueError) as exc: + _ = inst.run(self.circuit, observables=self.observables, **bad_opt) + self.assertIn(list(bad_opt.keys())[0], str(exc.exception)) + + def test_run_default_options(self): + """Test run using default options.""" + session = MagicMock(spec=MockSession) + options_vars = [ + (EstimatorOptions(resilience_level=1), {"resilience_level": 1}), + ( + EstimatorOptions(optimization_level=3), + {"transpilation": {"optimization_level": 3}}, + ), + ( + { + "transpilation": {"initial_layout": [1, 2]}, + "execution": {"shots": 100}, + }, + { + "transpilation": {"initial_layout": [1, 2]}, + "execution": {"shots": 100}, + }, + ), + ] + for options, expected in options_vars: + with self.subTest(options=options): + inst = EstimatorV2(session=session, options=options) + inst.run(self.circuit, observables=self.observables) + inputs = session.run.call_args.kwargs["inputs"] + self.assertTrue( + dict_paritally_equal(inputs, expected), + f"{inputs} and {expected} not partially equal.", + ) + + @data({"zne_extrapolator": "bad_extrapolator"}, + {"zne_extrapolator": "double_exponential", "zne_noise_factors": [1]}, + ) + def test_invalid_resilience_options(self, res_opt): + """Test invalid resilience options.""" + session = MagicMock(spec=MockSession) + with self.assertRaises(ValueError) as exc: + inst = EstimatorV2(session=session, options={"resilience": res_opt}) + inst.run(self.circuit, observables=self.observables) + self.assertIn(list(res_opt.values())[0], str(exc.exception)) + if len(res_opt.keys()) > 1: + self.assertIn(list(res_opt.keys())[1], str(exc.exception)) + def test_observable_types_single_circuit(self): """Test different observable types for a single circuit.""" all_obs = [ @@ -79,7 +151,7 @@ def test_observable_types_single_circuit(self): ] circuit = QuantumCircuit(2) - estimator = Estimator(backend=get_mocked_backend()) + estimator = EstimatorV2(backend=get_mocked_backend()) for obs in all_obs: with self.subTest(obs=obs): estimator.run(circuits=circuit, observables=obs) @@ -108,7 +180,7 @@ def test_observable_types_multi_circuits(self): ] circuit = QuantumCircuit(2) - estimator = Estimator(backend=get_mocked_backend()) + estimator = EstimatorV2(backend=get_mocked_backend()) for obs in all_obs: with self.subTest(obs=obs): estimator.run(circuits=[circuit] * num_qx, observables=obs) @@ -128,7 +200,7 @@ def test_invalid_basis(self): ] circuit = QuantumCircuit(2) - estimator = Estimator(backend=get_mocked_backend()) + estimator = EstimatorV2(backend=get_mocked_backend()) for obs in all_obs: with self.subTest(obs=obs): with self.assertRaises(ValueError): @@ -152,7 +224,7 @@ def test_single_parameter_single_circuit(self): [{theta: np.pi}], ] - estimator = Estimator(backend=get_mocked_backend()) + estimator = EstimatorV2(backend=get_mocked_backend()) for val in param_vals: with self.subTest(val=val): estimator.run(circuits=circuit, observables="ZZ", parameter_values=val) @@ -173,7 +245,7 @@ def test_multiple_parameters_single_circuit(self): [{theta: [np.pi, np.pi / 2]}], ] - estimator = Estimator(backend=get_mocked_backend()) + estimator = EstimatorV2(backend=get_mocked_backend()) for val in param_vals: with self.subTest(val=val): estimator.run(circuits=circuit, observables="ZZ", parameter_values=val) @@ -191,7 +263,7 @@ def test_multiple_parameters_multiple_circuits(self): [{theta: [[np.pi, np.pi / 2], [np.pi / 4, np.pi / 8]]}, {theta: [0.5, 0.5]}], ] - estimator = Estimator(backend=get_mocked_backend()) + estimator = EstimatorV2(backend=get_mocked_backend()) for val in param_vals: with self.subTest(val=val): estimator.run(circuits=[circuit] * 2, observables=["ZZ"] * 2, parameter_values=val) diff --git a/test/unit/test_estimator_options.py b/test/unit/test_estimator_options.py new file mode 100644 index 000000000..8dbfa5143 --- /dev/null +++ b/test/unit/test_estimator_options.py @@ -0,0 +1,247 @@ +# 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 Options class.""" + +from dataclasses import asdict + +from ddt import data, ddt +from pydantic import ValidationError +from qiskit.providers import BackendV1 +from qiskit.providers.fake_provider import FakeManila, FakeNairobiV2 +from qiskit.transpiler import CouplingMap +from qiskit_aer.noise import NoiseModel + +from qiskit_ibm_runtime.options.utils import merge_options +from qiskit_ibm_runtime.options import EstimatorOptions +from qiskit_ibm_runtime.utils.qctrl import _warn_and_clean_options + +from ..ibm_test_case import IBMTestCase +from ..utils import dict_keys_equal, dict_paritally_equal, flat_dict_partially_equal + + +@ddt +class TestEStimatorOptions(IBMTestCase): + """Class for testing the Sampler class.""" + + def test_merge_options(self): + """Test merging options.""" + options_vars = [ + {}, + {"resilience_level": 9}, + {"resilience_level": 8, "transpilation": {"initial_layout": [1, 2]}}, + {"shots": 99, "seed_simulator": 42}, + {"resilience_level": 99, "shots": 98, "initial_layout": [3, 4]}, + { + "initial_layout": [1, 2], + "transpilation": {"layout_method": "trivial"}, + "log_level": "INFO", + }, + ] + for new_ops in options_vars: + with self.subTest(new_ops=new_ops): + options = EstimatorOptions() + combined = merge_options(asdict(options), new_ops) + + # Make sure the values are equal. + self.assertTrue( + flat_dict_partially_equal(combined, new_ops), + f"new_ops={new_ops}, combined={combined}", + ) + # Make sure the structure didn't change. + self.assertTrue( + dict_keys_equal(combined, asdict(options)), + f"options={options}, combined={combined}", + ) + + def test_program_inputs(self): + """Test converting to program inputs from estimator options.""" + noise_model = NoiseModel.from_backend(FakeNairobiV2()) + options = EstimatorOptions( # pylint: disable=unexpected-keyword-arg + optimization_level=1, + resilience_level=2, + transpilation={"initial_layout": [1, 2], "skip_transpilation": True}, + execution={"shots": 100}, + environment={"log_level": "DEBUG"}, + simulator={"noise_model": noise_model}, + resilience={"zne_noise_factors": (1, 2, 4)}, + ) + inputs = EstimatorOptions._get_program_inputs(asdict(options)) + + expected = { + "execution": {"shots": 100, "noise_model": noise_model}, + "skip_transpilation": True, + "transpilation": { + "optimization_level": 1, + "initial_layout": [1, 2], + }, + "resilience_level": 2, + "resilience": { + "zne_noise_factors": (1.0, 2.0, 4.0), + }, + } + self.assertTrue( + dict_paritally_equal(inputs, expected), + f"inputs={inputs}, expected={expected}", + ) + + def test_init_options_with_dictionary(self): + """Test initializing options with dictionaries.""" + + options_dicts = [ + {}, + {"resilience_level": 1}, + {"simulator": {"seed_simulator": 42}}, + {"resilience_level": 1, "environment": {"log_level": "WARNING"}}, + { + "transpilation": {"initial_layout": [1, 2], "layout_method": "trivial"}, + "execution": {"shots": 100}, + }, + {"environment": {"log_level": "ERROR"}}, + ] + + for opts_dict in options_dicts: + with self.subTest(opts_dict=opts_dict): + options = asdict(EstimatorOptions(**opts_dict)) + self.assertTrue( + dict_paritally_equal(options, opts_dict), + f"options={options}, opts_dict={opts_dict}", + ) + + # Make sure the structure didn't change. + self.assertTrue(dict_keys_equal(asdict(EstimatorOptions()), options), f"options={options}") + + def test_kwargs_options(self): + """Test specifying arbitrary options.""" + with self.assertRaises(ValidationError) as exc: + _ = EstimatorOptions(foo="foo") # pylint: disable=unexpected-keyword-arg + self.assertIn("foo", str(exc.exception)) + + def test_coupling_map_options(self): + """Check that coupling_map is processed correctly for various types""" + coupling_map = {(1, 0), (2, 1), (0, 1), (1, 2)} + coupling_maps = [ + coupling_map, + list(map(list, coupling_map)), + CouplingMap(coupling_map), + ] + for variant in coupling_maps: + with self.subTest(opts_dict=variant): + options = EstimatorOptions() + options.simulator.coupling_map = variant + inputs = EstimatorOptions._get_program_inputs(asdict(options)) + resulting_cmap = inputs["transpilation"]["coupling_map"] + self.assertEqual(coupling_map, set(map(tuple, resulting_cmap))) + + @data({"optimization_level": 99}, + {"resilience_level": 99}, + {"dynamical_decoupling": "foo"}, + {"transpilation": {"skip_transpilation": "foo"}}, + {"execution": {"shots": 0}}, + {"twirling": {"strategy": "foo"}}, + {"transpilation": {"foo": "bar"}}, + {"resilience_level": 3, "_is_simulator": True}, + {"zne_noise_factors": [0.5]}, + {"noise_factors": [1, 3, 5]}, + {"zne_extrapolator": "exponential", "zne_noise_factors": [1]}, + {"zne_mitigation": True, "pec_mitigation": True} + ) + def test_bad_inputs(self, val): + with self.assertRaises(ValidationError) as exc: + EstimatorOptions(**val) + self.assertIn(list(val.keys())[0], str(exc.exception)) + + @data(FakeManila(), FakeNairobiV2()) + def test_simulator_set_backend(self, fake_backend): + """Test Options.simulator.set_backend method.""" + + options = EstimatorOptions() + options.simulator.seed_simulator = 42 + options.simulator.set_backend(fake_backend) + + noise_model = NoiseModel.from_backend(fake_backend) + basis_gates = ( + fake_backend.configuration().basis_gates + if isinstance(fake_backend, BackendV1) + else fake_backend.operation_names + ) + coupling_map = ( + fake_backend.configuration().coupling_map + if isinstance(fake_backend, BackendV1) + else fake_backend.coupling_map + ) + + self.assertEqual(options.simulator.coupling_map, coupling_map) + self.assertEqual(options.simulator.noise_model, noise_model) + + expected_options = EstimatorOptions() + expected_options.simulator = { + "noise_model": noise_model, + "basis_gates": basis_gates, + "coupling_map": coupling_map, + "seed_simulator": 42, + } + + self.assertDictEqual(asdict(options), asdict(expected_options)) + + def test_qctrl_overrides(self): + """Test override of options""" + all_test_options = [ + ( + { + "optimization_level": 2, + "transpilation": {"approximation_degree": 1}, + "resilience_level": 3, + "resilience": { + "noise_factors": (1, 3, 5), + "extrapolator": "Linear", + }, + }, + { + "optimization_level": 3, + "transpilation": {"approximation_degree": 0}, + "resilience_level": 1, + "resilience": { + "noise_factors": None, + "extrapolator": None, + }, + }, + ), + ( + { + "optimization_level": 0, + "transpilation": {"approximation_degree": 1, "skip_transpilation": True}, + "resilience_level": 1, + }, + { + "optimization_level": 3, + "transpilation": {"approximation_degree": 0, "skip_transpilation": False}, + "resilience_level": 1, + }, + ), + ( + { + "optimization_level": 0, + "transpilation": {"skip_transpilation": True}, + "resilience_level": 1, + }, + { + "optimization_level": 3, + "transpilation": {"skip_transpilation": False}, + "resilience_level": 1, + }, + ), + ] + for option, expected_ in all_test_options: + with self.subTest(msg=f"{option}"): + _warn_and_clean_options(option) + self.assertEqual(expected_, option) diff --git a/test/unit/test_ibm_primitives.py b/test/unit/test_ibm_primitives.py index 185505ee4..6e8ab5735 100644 --- a/test/unit/test_ibm_primitives.py +++ b/test/unit/test_ibm_primitives.py @@ -23,6 +23,7 @@ from qiskit.test.reference_circuits import ReferenceCircuits from qiskit.quantum_info import SparsePauliOp from qiskit.providers.fake_provider import FakeManila +from qiskit_aer.noise import NoiseModel from qiskit_ibm_runtime import ( Sampler, @@ -95,11 +96,7 @@ def test_runtime_options(self): options = Options(environment=env) inst = cls(session=session, options=options) inst.run(self.qx, observables=self.obs) - if sys.version_info >= (3, 8): - run_options = session.run.call_args.kwargs["options"] - else: - _, kwargs = session.run.call_args - run_options = kwargs["options"] + run_options = session.run.call_args.kwargs["options"] for key, val in env.items(): self.assertEqual(run_options[key], val) @@ -281,10 +278,10 @@ def test_run_default_options(self): """Test run using default options.""" session = MagicMock(spec=MockSession) options_vars = [ - (Options(resilience_level=1), {"resilience_level": 1}), + (Options(resilience_level=1), {"resilience_settings": {"level": 1}}), ( Options(optimization_level=3), - {"transpilation": {"optimization_level": 3}}, + {"transpilation_settings": {"optimization_settings": {"level": 3}}}, ), ( { @@ -292,8 +289,8 @@ def test_run_default_options(self): "execution": {"shots": 100}, }, { - "transpilation": {"initial_layout": [1, 2]}, - "execution": {"shots": 100}, + "transpilation_settings": {"initial_layout": [1, 2]}, + "run_options": {"shots": 100}, }, ), ] @@ -327,9 +324,9 @@ def test_run_updated_default_options(self): self._assert_dict_partially_equal( inputs, { - "resilience_level": 1, - "transpilation": {"optimization_level": 2}, - "execution": {"shots": 99}, + "resilience_settings": {"level": 1}, + "transpilation_settings": {"optimization_settings": {"level": 2}}, + "run_options": {"shots": 99}, }, ) @@ -337,17 +334,17 @@ def test_run_overwrite_options(self): """Test run using overwritten options.""" session = MagicMock(spec=MockSession) options_vars = [ - ({"resilience_level": 1}, {"resilience_level": 1}), - ({"shots": 200}, {"execution": {"shots": 200}}), + ({"resilience_level": 1}, {"resilience_settings": {"level": 1}}), + ({"shots": 200}, {"run_options": {"shots": 200}}), ( {"optimization_level": 3}, - {"transpilation": {"optimization_level": 3}}, + {"transpilation_settings": {"optimization_settings": {"level": 3}}}, ), ( {"initial_layout": [1, 2], "optimization_level": 2}, { - "transpilation": { - "optimization_level": 2, + "transpilation_settings": { + "optimization_settings": {"level": 2}, "initial_layout": [1, 2], } }, @@ -415,7 +412,7 @@ def test_run_multiple_different_options(self): inst.run(self.qx, observables=self.obs, shots=200) kwargs_list = session.run.call_args_list for idx, shots in zip([0, 1], [100, 200]): - self.assertEqual(kwargs_list[idx][1]["inputs"]["execution"]["shots"], shots) + self.assertEqual(kwargs_list[idx][1]["inputs"]["run_options"]["shots"], shots) self.assertDictEqual(inst.options.__dict__, asdict(Options())) def test_run_same_session(self): @@ -497,10 +494,12 @@ def test_default_error_levels(self): session = MagicMock(spec=MockSession) primitives = [Sampler, Estimator] + noise_model = NoiseModel.from_backend(FakeManila()) + FakeManila() for cls in primitives: with self.subTest(primitive=cls): options = Options( - simulator={"noise_model": "foo"}, + simulator={"noise_model": noise_model}, ) inst = cls(session=session, options=options) @@ -509,50 +508,38 @@ def test_default_error_levels(self): else: inst.run(self.qx) - if sys.version_info >= (3, 8): - inputs = session.run.call_args.kwargs["inputs"] - else: - _, kwargs = session.run.call_args - inputs = kwargs["inputs"] + inputs = session.run.call_args.kwargs["inputs"] self.assertEqual( - inputs["transpilation"]["optimization_level"], + inputs["transpilation_settings"]["optimization_settings"]["level"], Options._DEFAULT_OPTIMIZATION_LEVEL, ) self.assertEqual( - inputs["resilience_level"], + inputs["resilience_settings"]["level"], Options._DEFAULT_RESILIENCE_LEVEL, ) session.service.backend().configuration().simulator = False inst = cls(session=session) inst.run(self.qx, observables=self.obs) - if sys.version_info >= (3, 8): - inputs = session.run.call_args.kwargs["inputs"] - else: - _, kwargs = session.run.call_args - inputs = kwargs["inputs"] + inputs = session.run.call_args.kwargs["inputs"] self.assertEqual( - inputs["transpilation"]["optimization_level"], + inputs["transpilation_settings"]["optimization_settings"]["level"], Options._DEFAULT_OPTIMIZATION_LEVEL, ) self.assertEqual( - inputs["resilience_level"], + inputs["resilience_settings"]["level"], Options._DEFAULT_RESILIENCE_LEVEL, ) session.service.backend().configuration().simulator = True inst = cls(session=session) inst.run(self.qx, observables=self.obs) - if sys.version_info >= (3, 8): - inputs = session.run.call_args.kwargs["inputs"] - else: - _, kwargs = session.run.call_args - inputs = kwargs["inputs"] + inputs = session.run.call_args.kwargs["inputs"] self.assertEqual( - inputs["transpilation"]["optimization_level"], + inputs["transpilation_settings"]["optimization_settings"]["level"], 1, ) - self.assertEqual(inputs["resilience_level"], 0) + self.assertEqual(inputs["resilience_settings"]["level"], 0) def test_resilience_options(self): """Test resilience options.""" diff --git a/test/unit/test_ibm_primitives_v2.py b/test/unit/test_ibm_primitives_v2.py new file mode 100644 index 000000000..687498c5a --- /dev/null +++ b/test/unit/test_ibm_primitives_v2.py @@ -0,0 +1,733 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2021. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +"""Tests for primitive classes.""" + +import sys +import os +from unittest.mock import MagicMock, patch +from dataclasses import asdict +from typing import Dict +from unittest import skip + +from ddt import data, ddt +from qiskit import transpile +from qiskit.circuit import QuantumCircuit +from qiskit.test.reference_circuits import ReferenceCircuits +from qiskit.quantum_info import SparsePauliOp +from qiskit.providers.fake_provider import FakeManila + +from qiskit_ibm_runtime import ( + Sampler, + Estimator, + Options, + Session, +) +from qiskit_ibm_runtime.ibm_backend import IBMBackend +import qiskit_ibm_runtime.session as session_pkg +from qiskit_ibm_runtime import EstimatorV2 +from qiskit_ibm_runtime.estimator import Estimator as IBMBaseEstimator + +from ..ibm_test_case import IBMTestCase +from ..utils import ( + dict_paritally_equal, + flat_dict_partially_equal, + dict_keys_equal, + create_faulty_backend, + combine, + MockSession +) + + +@ddt +class TestPrimitivesV2(IBMTestCase): + """Class for testing the Sampler and Estimator classes.""" + + @classmethod + def setUpClass(cls): + cls.qx = ReferenceCircuits.bell() + cls.obs = SparsePauliOp.from_list([("IZ", 1)]) + return super().setUpClass() + + def tearDown(self) -> None: + super().tearDown() + session_pkg._DEFAULT_SESSION.set(None) + + @data(EstimatorV2) + def test_dict_options(self, primitive): + """Test passing a dictionary as options.""" + options_vars = [ + {}, + { + "resilience_level": 1, + "transpilation": {"initial_layout": [1, 2]}, + "execution": {"shots": 100, "init_qubits": True}, + }, + {"optimization_level": 2}, + {"transpilation": {}}, + ] + for options in options_vars: + inst = primitive(session=MagicMock(spec=MockSession), options=options) + self.assertTrue(dict_paritally_equal(asdict(inst.options()), options)) + + @combine(primitive=[EstimatorV2], env_var=[{"log_level": "DEBUG"}, {"job_tags": ["foo", "bar"]},]) + def test_runtime_options(self, primitive, env_var): + """Test RuntimeOptions specified as primitive options.""" + session = MagicMock(spec=MockSession) + options = primitive._OPTIONS_CLASS(environment=env_var) + inst = primitive(session=session, options=options) + inst.run(self.qx, observables=self.obs) + run_options = session.run.call_args.kwargs["options"] + for key, val in env_var.items(): + self.assertEqual(run_options[key], val) + + @data(EstimatorV2) + def test_options_copied(self, primitive): + """Test modifying original options does not affect primitives.""" + options = primitive._OPTIONS_CLASS() + options.max_execution_time = 100 + inst = primitive(session=MagicMock(spec=MockSession), options=options) + options.max_execution_time = 200 + self.assertEqual(inst.options.max_execution_time, 100) + + @data(EstimatorV2) + def test_init_with_backend_str(self, primitive): + """Test initializing a primitive with a backend name.""" + backend_name = "ibm_gotham" + + with patch( + "qiskit_ibm_runtime.base_primitive.QiskitRuntimeService" + ) as mock_service: + mock_service.reset_mock() + mock_service_inst = MagicMock() + mock_service.return_value = mock_service_inst + mock_backend = MagicMock() + mock_backend.name = backend_name + mock_service.global_service = None + mock_service_inst.backend.return_value = mock_backend + + inst = primitive(backend=backend_name) + mock_service.assert_called_once() + self.assertIsNone(inst.session) + inst.run(self.qx, observables=self.obs) + mock_service_inst.run.assert_called_once() + runtime_options = mock_service_inst.run.call_args.kwargs["options"] + self.assertEqual(runtime_options["backend"], backend_name) + + @data(EstimatorV2) + def test_init_with_session_backend_str(self, primitive): + """Test initializing a primitive with a backend name using session.""" + backend_name = "ibm_gotham" + + with patch( + "qiskit_ibm_runtime.base_primitive.QiskitRuntimeService" + ): + with self.assertRaises(ValueError) as exc: + inst = primitive(session=backend_name) + self.assertIsNone(inst.session) + self.assertIn("session must be of type Session or None", str(exc.exception)) + + @data(EstimatorV2) + def test_init_with_backend_instance(self, primitive): + """Test initializing a primitive with a backend instance.""" + service = MagicMock() + model_backend = FakeManila() + backend = IBMBackend( + configuration=model_backend.configuration(), service=service, api_client=MagicMock() + ) + backend.name = "ibm_gotham" + + service.reset_mock() + inst = primitive(backend=backend) + self.assertIsNone(inst.session) + inst.run(self.qx, observables=self.obs) + service.run.assert_called_once() + runtime_options = service.run.call_args.kwargs["options"] + self.assertEqual(runtime_options["backend"], backend.name) + + with self.assertRaises(ValueError) as exc: + inst = primitive(session=backend) + self.assertIsNone(inst.session) + self.assertIn("session must be of type Session or None", str(exc.exception)) + + @data(EstimatorV2) + def test_init_with_backend_session(self, primitive): + """Test initializing a primitive with both backend and session.""" + session = MagicMock(spec=MockSession) + backend_name = "ibm_gotham" + + session.reset_mock() + inst = primitive(session=session, backend=backend_name) + self.assertIsNotNone(inst.session) + inst.run(self.qx, observables=self.obs) + session.run.assert_called_once() + + @data(EstimatorV2) + def test_init_with_no_backend_session_cloud(self, primitive): + """Test initializing a primitive without backend or session for cloud channel.""" + with patch( + "qiskit_ibm_runtime.base_primitive.QiskitRuntimeService" + ) as mock_service: + mock_service_inst = MagicMock() + mock_service_inst.channel = "ibm_cloud" + mock_service.return_value = mock_service_inst + mock_service.reset_mock() + mock_service.global_service = None + inst = primitive() + mock_service.assert_called_once() + self.assertIsNone(inst.session) + + @data(EstimatorV2) + def test_init_with_no_backend_session_quantum(self, primitive): + """Test initializing a primitive without backend or session for quantum channel.""" + + with patch( + "qiskit_ibm_runtime.base_primitive.QiskitRuntimeService" + ) as mock_service: + mock_service.reset_mock() + with self.assertRaises(ValueError): + _ = primitive() + + @data(EstimatorV2) + def test_default_session_context_manager(self, primitive): + """Test getting default session within context manager.""" + service = MagicMock() + backend = "ibm_gotham" + + with Session(service=service, backend=backend) as session: + inst = primitive() + self.assertEqual(inst.session, session) + self.assertEqual(inst.session.backend(), backend) + + @data(EstimatorV2) + def test_default_session_cm_new_backend(self, primitive): + """Test using a different backend within context manager.""" + cm_backend = "ibm_metropolis" + model_backend = FakeManila() + service = MagicMock() + backend = IBMBackend( + configuration=model_backend.configuration(), + service=service, + api_client=MagicMock(), + ) + backend.name = "ibm_gotham" + + with Session(service=service, backend=cm_backend): + inst = primitive(backend=backend) + self.assertIsNone(inst.session) + inst.run(self.qx, observables=self.obs) + service.run.assert_called_once() + runtime_options = service.run.call_args.kwargs["options"] + self.assertEqual(runtime_options["backend"], backend.name) + + @data(EstimatorV2) + def test_no_session(self, primitive): + """Test running without session.""" + model_backend = FakeManila() + service = MagicMock() + backend = IBMBackend( + configuration=model_backend.configuration(), + service=service, + api_client=MagicMock(), + ) + inst = primitive(backend) + inst.run(self.qx, observables=self.obs) + self.assertIsNone(inst.session) + service.run.assert_called_once() + kwargs_list = service.run.call_args.kwargs + self.assertNotIn("session_id", kwargs_list) + self.assertNotIn("start_session", kwargs_list) + + @data(EstimatorV2) + def test_run_updated_default_options(self, primitive): + """Test run using updated default options.""" + session = MagicMock(spec=MockSession) + inst = primitive(session=session) + inst.set_options(skip_transpilation=True, optimization_level=2, shots=99) + inst.run(self.qx, observables=self.obs) + inputs = session.run.call_args.kwargs["inputs"] + self._assert_dict_partially_equal( + inputs, + { + "skip_transpilation": True, + "transpilation": {"optimization_level": 2}, + "execution": {"shots": 99}, + }, + ) + + @data(EstimatorV2) + def test_run_overwrite_options(self, primitive): + """Test run using overwritten options.""" + session = MagicMock(spec=MockSession) + options_vars = [ + ({"resilience_level": 1}, {"resilience_level": 1}), + ({"shots": 200}, {"execution": {"shots": 200}}), + ( + {"optimization_level": 3}, + {"transpilation": {"optimization_level": 3}}, + ), + ( + {"initial_layout": [1, 2], "optimization_level": 2}, + { + "transpilation": { + "optimization_level": 2, + "initial_layout": [1, 2], + } + }, + ), + ] + opt_cls = primitive._OPTIONS_CLASS + for options, expected in options_vars: + with self.subTest(options=options): + inst = primitive(session=session) + inst.run(self.qx, observables=self.obs, **options) + inputs = session.run.call_args.kwargs["inputs"] + self._assert_dict_partially_equal(inputs, expected) + self.assertDictEqual(asdict(inst.options()), asdict(opt_cls())) + + @data(EstimatorV2) + def test_run_overwrite_runtime_options(self, primitive): + """Test run using overwritten runtime options.""" + session = MagicMock(spec=MockSession) + options_vars = [ + {"log_level": "DEBUG"}, + {"job_tags": ["foo", "bar"]}, + {"max_execution_time": 600}, + {"log_level": "INFO", "max_execution_time": 800}, + ] + for options in options_vars: + with self.subTest(options=options): + inst = primitive(session=session) + inst.run(self.qx, observables=self.obs, **options) + rt_options = session.run.call_args.kwargs["options"] + self._assert_dict_partially_equal(rt_options, options) + + @combine(primitive=[EstimatorV2], + exp_opt=[{"foo": "bar"}, {"transpilation": {"foo": "bar"}}]) + def test_run_experimental_options(self, primitive, exp_opt): + """Test specifying arbitrary options in run.""" + # FIXME + session = MagicMock(spec=MockSession) + inst = primitive(session=session) + inst.run(self.qx, observables=self.obs, experimental=exp_opt) + inputs = session.run.call_args.kwargs["inputs"] + self._assert_dict_partially_equal(inputs, exp_opt) + + @data(EstimatorV2) + def test_run_multiple_different_options(self, primitive): + """Test multiple runs with different options.""" + opt_cls = primitive._OPTIONS_CLASS + session = MagicMock(spec=MockSession) + inst = primitive(session=session) + inst.run(self.qx, observables=self.obs, shots=100) + inst.run(self.qx, observables=self.obs, shots=200) + kwargs_list = session.run.call_args_list + for idx, shots in zip([0, 1], [100, 200]): + self.assertEqual(kwargs_list[idx][1]["inputs"]["execution"]["shots"], shots) + self.assertDictEqual(asdict(inst.options()), asdict(opt_cls())) + + def test_run_same_session(self): + """Test multiple runs within a session.""" + num_runs = 5 + primitives = [EstimatorV2] + session = MagicMock(spec=MockSession) + for idx in range(num_runs): + cls = primitives[idx % len(primitives)] + inst = cls(session=session) + inst.run(self.qx, observables=self.obs) + self.assertEqual(session.run.call_count, num_runs) + + @data(EstimatorV2) + def test_set_options(self, primitive): + """Test set options.""" + opt_cls = primitive._OPTIONS_CLASS + options = opt_cls(optimization_level=1, execution={"shots": 100}) + new_options = [ + ({"optimization_level": 2}, opt_cls()), + ({"optimization_level": 3, "shots": 200}, opt_cls()), + ] + + session = MagicMock(spec=MockSession) + for new_opt, new_str in new_options: + with self.subTest(new_opt=new_opt): + inst = primitive(session=session, options=options) + inst.set_options(**new_opt) + # Make sure the values are equal. + inst_options = asdict(inst.options()) + self.assertTrue( + flat_dict_partially_equal(inst_options, new_opt), + f"inst_options={inst_options}, new_opt={new_opt}", + ) + # Make sure the structure didn't change. + self.assertTrue( + dict_keys_equal(inst_options, asdict(new_str)), + f"inst_options={inst_options}, new_str={new_str}", + ) + + @data(EstimatorV2) + def test_accept_level_1_options(self, primitive): + """Test initializing options properly when given on level 1.""" + + opt_cls = primitive._OPTIONS_CLASS + options_dicts = [ + {}, + {"shots": 10}, + {"seed_simulator": 123}, + {"skip_transpilation": True, "log_level": "ERROR"}, + {"initial_layout": [1, 2], "shots": 100, "optimization_level": 2}, + ] + + expected_list = [opt_cls() for _ in range(len(options_dicts))] + expected_list[1].execution.shots = 10 + expected_list[2].simulator.seed_simulator = 123 + expected_list[3].transpilation.skip_transpilation = True + expected_list[3].environment.log_level = "ERROR" + expected_list[4].transpilation.initial_layout = [1, 2] + expected_list[4].execution.shots = 100 + expected_list[4].optimization_level = 2 + + session = MagicMock(spec=MockSession) + for opts, expected in zip(options_dicts, expected_list): + with self.subTest(options=opts): + inst1 = primitive(session=session, options=opts) + inst2 = primitive(session=session, options=expected) + self.assertEqual(inst1.options(), inst2.options()) + # # Make sure the values are equal. + # inst1_options = inst1.options.__dict__ + # expected_dict = inst2.options.__dict__ + # # Make sure the structure didn't change. + # self.assertTrue( + # dict_keys_equal(inst1_options, expected_dict), + # f"inst_options={inst1_options}, expected={expected_dict}", + # ) + + @skip("We don't change default error level anymore") + def test_default_error_levels(self): + """Test the correct default error levels are used.""" + + session = MagicMock(spec=MockSession) + primitives = [Sampler, Estimator] + for cls in primitives: + with self.subTest(primitive=cls): + options = Options( + simulator={"noise_model": "foo"}, + ) + inst = cls(session=session, options=options) + + if isinstance(inst, Estimator): + inst.run(self.qx, observables=self.obs) + else: + inst.run(self.qx) + + if sys.version_info >= (3, 8): + inputs = session.run.call_args.kwargs["inputs"] + else: + _, kwargs = session.run.call_args + inputs = kwargs["inputs"] + self.assertEqual( + inputs["transpilation"]["optimization_level"], + Options._DEFAULT_OPTIMIZATION_LEVEL, + ) + self.assertEqual( + inputs["resilience_level"], + Options._DEFAULT_RESILIENCE_LEVEL, + ) + + session.service.backend().configuration().simulator = False + inst = cls(session=session) + inst.run(self.qx, observables=self.obs) + if sys.version_info >= (3, 8): + inputs = session.run.call_args.kwargs["inputs"] + else: + _, kwargs = session.run.call_args + inputs = kwargs["inputs"] + self.assertEqual( + inputs["transpilation"]["optimization_level"], + Options._DEFAULT_OPTIMIZATION_LEVEL, + ) + self.assertEqual( + inputs["resilience_level"], + Options._DEFAULT_RESILIENCE_LEVEL, + ) + + session.service.backend().configuration().simulator = True + inst = cls(session=session) + inst.run(self.qx, observables=self.obs) + if sys.version_info >= (3, 8): + inputs = session.run.call_args.kwargs["inputs"] + else: + _, kwargs = session.run.call_args + inputs = kwargs["inputs"] + self.assertEqual( + inputs["transpilation"]["optimization_level"], + 1, + ) + self.assertEqual(inputs["resilience_level"], 0) + + @data(EstimatorV2) + def test_raise_faulty_qubits(self, primitive): + """Test faulty qubits is raised.""" + fake_backend = FakeManila() + num_qubits = fake_backend.configuration().num_qubits + circ = QuantumCircuit(num_qubits, num_qubits) + for i in range(num_qubits): + circ.x(i) + transpiled = transpile(circ, backend=fake_backend) + observable = SparsePauliOp("Z" * num_qubits) + + faulty_qubit = 4 + ibm_backend = create_faulty_backend(fake_backend, faulty_qubit=faulty_qubit) + service = MagicMock() + service.backend.return_value = ibm_backend + session = Session(service=service, backend=fake_backend.name) + + inst = primitive(session=session) + if isinstance(inst, IBMBaseEstimator): # TODO fix for sampler + inputs = {"circuits": transpiled, "observables": observable} + else: + transpiled.measure_all() + inputs = {"circuits": transpiled} + + with self.assertRaises(ValueError) as err: + inst.run(**inputs, skip_transpilation=True) + self.assertIn(f"faulty qubit {faulty_qubit}", str(err.exception)) + + @data(EstimatorV2) + def test_raise_faulty_qubits_many(self, primitive): + """Test faulty qubits is raised if one circuit uses it.""" + fake_backend = FakeManila() + num_qubits = fake_backend.configuration().num_qubits + + circ1 = QuantumCircuit(1, 1) + circ1.x(0) + circ2 = QuantumCircuit(num_qubits, num_qubits) + for i in range(num_qubits): + circ2.x(i) + transpiled = transpile([circ1, circ2], backend=fake_backend) + observable = SparsePauliOp("Z" * num_qubits) + + faulty_qubit = 4 + ibm_backend = create_faulty_backend(fake_backend, faulty_qubit=faulty_qubit) + service = MagicMock() + service.backend.return_value = ibm_backend + session = Session(service=service, backend=fake_backend.name) + + inst = primitive(session=session) + if isinstance(inst, IBMBaseEstimator): + inputs = {"circuits": transpiled, "observables": [observable, observable]} + else: + for circ in transpiled: + circ.measure_all() + inputs = {"circuits": transpiled} + + with self.assertRaises(ValueError) as err: + inst.run(**inputs, skip_transpilation=True) + self.assertIn(f"faulty qubit {faulty_qubit}", str(err.exception)) + + @data(EstimatorV2) + def test_raise_faulty_edge(self, primitive): + """Test faulty edge is raised.""" + fake_backend = FakeManila() + num_qubits = fake_backend.configuration().num_qubits + circ = QuantumCircuit(num_qubits, num_qubits) + for i in range(num_qubits - 2): + circ.cx(i, i + 1) + transpiled = transpile(circ, backend=fake_backend) + observable = SparsePauliOp("Z" * num_qubits) + + edge_qubits = [0, 1] + ibm_backend = create_faulty_backend(fake_backend, faulty_edge=("cx", edge_qubits)) + service = MagicMock() + service.backend.return_value = ibm_backend + session = Session(service=service, backend=fake_backend.name) + + inst = primitive(session=session) + if isinstance(inst, IBMBaseEstimator): + inputs = {"circuits": transpiled, "observables": observable} + else: + transpiled.measure_all() + inputs = {"circuits": transpiled} + + with self.assertRaises(ValueError) as err: + inst.run(**inputs, skip_transpilation=True) + self.assertIn("cx", str(err.exception)) + self.assertIn(f"faulty edge {tuple(edge_qubits)}", str(err.exception)) + + @data(EstimatorV2) + def test_faulty_qubit_not_used(self, primitive): + """Test faulty qubit is not raise if not used.""" + fake_backend = FakeManila() + circ = QuantumCircuit(2, 2) + for i in range(2): + circ.x(i) + transpiled = transpile(circ, backend=fake_backend, initial_layout=[0, 1]) + observable = SparsePauliOp("Z" * fake_backend.configuration().num_qubits) + + faulty_qubit = 4 + ibm_backend = create_faulty_backend(fake_backend, faulty_qubit=faulty_qubit) + + service = MagicMock() + service.backend.return_value = ibm_backend + session = Session(service=service, backend=fake_backend.name) + + inst = primitive(session=session) + if isinstance(inst, IBMBaseEstimator): + inputs = {"circuits": transpiled, "observables": observable} + else: + transpiled.measure_active(inplace=True) + inputs = {"circuits": transpiled} + + with patch.object(Session, "run") as mock_run: + inst.run(**inputs, skip_transpilation=True) + mock_run.assert_called_once() + + @data(EstimatorV2) + def test_faulty_edge_not_used(self, primitive): + """Test faulty edge is not raised if not used.""" + fake_backend = FakeManila() + coupling_map = fake_backend.configuration().coupling_map + + circ = QuantumCircuit(2, 2) + circ.cx(0, 1) + + transpiled = transpile(circ, backend=fake_backend, initial_layout=coupling_map[0]) + observable = SparsePauliOp("Z" * fake_backend.configuration().num_qubits) + + edge_qubits = coupling_map[-1] + ibm_backend = create_faulty_backend(fake_backend, faulty_edge=("cx", edge_qubits)) + + service = MagicMock() + service.backend.return_value = ibm_backend + session = Session(service=service, backend=fake_backend.name) + + inst = primitive(session=session) + if isinstance(inst, IBMBaseEstimator): + inputs = {"circuits": transpiled, "observables": observable} + else: + transpiled.measure_all() + inputs = {"circuits": transpiled} + + with patch.object(Session, "run") as mock_run: + inst.run(**inputs, skip_transpilation=True) + mock_run.assert_called_once() + + @data(EstimatorV2) + def test_no_raise_skip_transpilation(self, primitive): + """Test faulty qubits and edges are not raise if not skipping.""" + fake_backend = FakeManila() + num_qubits = fake_backend.configuration().num_qubits + circ = QuantumCircuit(num_qubits, num_qubits) + for i in range(num_qubits - 2): + circ.cx(i, i + 1) + transpiled = transpile(circ, backend=fake_backend) + observable = SparsePauliOp("Z" * num_qubits) + + edge_qubits = [0, 1] + ibm_backend = create_faulty_backend( + fake_backend, faulty_qubit=0, faulty_edge=("cx", edge_qubits) + ) + + service = MagicMock() + service.backend.return_value = ibm_backend + session = Session(service=service, backend=fake_backend.name) + + inst = primitive(session=session) + if isinstance(inst, IBMBaseEstimator): + inputs = {"circuits": transpiled, "observables": observable} + else: + transpiled.measure_all() + inputs = {"circuits": transpiled} + + with patch.object(Session, "run") as mock_run: + inst.run(**inputs) + mock_run.assert_called_once() + + def _update_dict(self, dict1, dict2): + for key, val in dict1.items(): + if isinstance(val, dict): + self._update_dict(val, dict2.pop(key, {})) + elif key in dict2.keys(): + dict1[key] = dict2.pop(key) + + def _assert_dict_partially_equal(self, dict1, dict2): + """Assert all keys in dict2 are in dict1 and have same values.""" + self.assertTrue( + dict_paritally_equal(dict1, dict2), + f"{dict1} and {dict2} not partially equal.", + ) + + @skip("Q-Ctrl does not support v2 yet") + def test_qctrl_supported_values_for_options(self): + """Test exception when options levels not supported.""" + no_resilience_options = { + "noise_factors": None, + "extrapolator": None, + } + + options_good = [ + # Minium working settings + {}, + # No warnings, we need resilience options here because by default they are getting populated. + {"resilience": no_resilience_options}, + # Arbitrary approximation degree (issues warning) + {"approximation_degree": 1}, + # Arbitrary resilience options(issue warning) + { + "resilience_level": 1, + "resilience": {"noise_factors": (1, 1, 3)}, + "approximation_degree": 1, + }, + # Resilience level > 1 (issue warning) + {"resilience_level": 2}, + # Optimization level = 1,2 (issue warning) + {"optimization_level": 1}, + {"optimization_level": 2}, + # Skip transpilation level(issue warning) + {"skip_transpilation": True}, + ] + session = MagicMock(spec=MockSession) + session.service._channel_strategy = "q-ctrl" + session.service.backend().configuration().simulator = False + primitives = [Sampler, Estimator] + for cls in primitives: + for options in options_good: + with self.subTest(msg=f"{cls}, {options}"): + inst = cls(session=session) + if isinstance(inst, Estimator): + _ = inst.run(self.qx, observables=self.obs, **options) + else: + _ = inst.run(self.qx, **options) + + @skip("Q-Ctrl does not support v2 yet") + def test_qctrl_unsupported_values_for_options(self): + """Test exception when options levels are not supported.""" + options_bad = [ + # Bad resilience levels + ({"resilience_level": 0}, "resilience level"), + # Bad optimization level + ({"optimization_level": 0}, "optimization level"), + ] + session = MagicMock(spec=MockSession) + session.service._channel_strategy = "q-ctrl" + session.service.backend().configuration().simulator = False + primitives = [Sampler, Estimator] + for cls in primitives: + for bad_opt, expected_message in options_bad: + with self.subTest(msg=bad_opt): + inst = cls(session=session) + with self.assertRaises(ValueError) as exc: + if isinstance(inst, Sampler): + _ = inst.run(self.qx, **bad_opt) + else: + _ = inst.run(self.qx, observables=self.obs, **bad_opt) + + self.assertIn(expected_message, str(exc.exception)) diff --git a/test/utils.py b/test/utils.py index 09e96ffd7..eb679b52d 100644 --- a/test/utils.py +++ b/test/utils.py @@ -20,13 +20,18 @@ from typing import Dict, Optional, Any from datetime import datetime +from ddt import data, unpack + from qiskit.circuit import QuantumCircuit +from qiskit.test.utils import generate_cases from qiskit.providers.jobstatus import JOB_FINAL_STATES, JobStatus from qiskit.providers.exceptions import QiskitBackendNotFoundError from qiskit.providers.models import BackendStatus, BackendProperties from qiskit.providers.backend import Backend +from qiskit.quantum_info import SparsePauliOp from qiskit_ibm_runtime.hub_group_project import HubGroupProject -from qiskit_ibm_runtime import QiskitRuntimeService +from qiskit_ibm_runtime import QiskitRuntimeService, Session +from qiskit_ibm_runtime.estimator import Estimator from qiskit_ibm_runtime.ibm_backend import IBMBackend from qiskit_ibm_runtime.runtime_job import RuntimeJob from qiskit_ibm_runtime.exceptions import RuntimeInvalidStateError @@ -152,7 +157,7 @@ def dict_paritally_equal(dict1: Dict, dict2: Dict) -> bool: """Determine whether all keys in dict2 are in dict1 and have same values.""" for key, val in dict2.items(): if isinstance(val, dict): - if not dict_paritally_equal(dict1.get(key), val): + if not dict_paritally_equal(dict1.get(key, {}), val): return False elif key not in dict1 or val != dict1[key]: return False @@ -258,3 +263,36 @@ def get_mocked_backend(name: str = "ibm_gotham") -> Any: mock_backend.service = mock_service return mock_backend + + +def combine(**kwargs): + """Decorator to create combinations and tests + @combine(level=[0, 1, 2, 3], + circuit=[a, b, c, d], + dsc='Test circuit {circuit.__name__} with level {level}', + name='{circuit.__name__}_level{level}') + """ + + def deco(func): + return data(*generate_cases(docstring=func.__doc__, **kwargs))(unpack(func)) + + return deco + + +def get_primitive_inputs(primitive, num_sets=1): + circ = QuantumCircuit(2, 2) + circ.h(0) + circ.cx(0, 1) + obs = SparsePauliOp.from_list([("IZ", 1)]) + + if isinstance(primitive, Estimator): + return {"circuits": [circ]*num_sets, "observables": [obs]*num_sets} + + circ.measure_all() + return {"circuits": [circ]*num_sets} + +class MockSession(Session): + """Mock for session class""" + + _circuits_map: Dict[str, QuantumCircuit] = {} + _instance = None From bf5a677567a14cfc5a981ac9017379c043f1887d Mon Sep 17 00:00:00 2001 From: Jessie Yu Date: Fri, 3 Nov 2023 15:12:27 -0400 Subject: [PATCH 09/35] update test --- test/unit/test_options.py | 104 +++++--------------------------------- 1 file changed, 13 insertions(+), 91 deletions(-) diff --git a/test/unit/test_options.py b/test/unit/test_options.py index 935cfede1..ee3573139 100644 --- a/test/unit/test_options.py +++ b/test/unit/test_options.py @@ -15,6 +15,7 @@ from dataclasses import asdict from ddt import data, ddt +from pydantic import ValidationError from qiskit.providers import BackendV1 from qiskit.providers.fake_provider import FakeManila, FakeNairobiV2 from qiskit.transpiler import CouplingMap @@ -93,24 +94,22 @@ def test_program_inputs(self): execution={"shots": 100}, environment={"log_level": "DEBUG"}, simulator={"noise_model": noise_model}, - resilience={"noise_factors": (0, 2, 4)}, + resilience={"noise_factors": (1, 2, 4)}, ) inputs = Options._get_program_inputs(asdict(options)) expected = { - "execution": {"shots": 100, "noise_model": noise_model}, - "skip_transpilation": True, - "transpilation": { - "optimization_level": 1, + "run_options": {"shots": 100, "noise_model": noise_model}, + "transpilation_settings": { + "optimization_settings": {"level": 1}, + "skip_transpilation": True, "initial_layout": [1, 2], }, - "resilience_level": 2, - "resilience": { - "noise_factors": (0, 2, 4), + "resilience_settings": { + "level": 2, + "noise_factors": (1, 2, 4), }, } - import pprint - pprint.pprint(inputs) self.assertTrue( dict_paritally_equal(inputs, expected), f"inputs={inputs}, expected={expected}", @@ -131,7 +130,6 @@ def test_init_options_with_dictionary(self): {"resilience": {"noise_factors": (0, 2, 4)}}, {"environment": {"log_level": "ERROR"}}, ] - for opts_dict in options_dicts: with self.subTest(opts_dict=opts_dict): options = asdict(Options(**opts_dict)) @@ -152,45 +150,27 @@ def test_kwargs_options(self): str(exc.exception), ) - def test_backend_in_options(self): - """Test specifying backend in options.""" - backend_name = "ibm_gotham" - backend = FakeManila() - backend._instance = None - backend.name = backend_name - backends = [backend_name, backend] - for backend in backends: - with self.assertRaises(TypeError) as exc: - _ = Options(backend=backend) # pylint: disable=unexpected-keyword-arg - self.assertIn( - "__init__() got an unexpected keyword argument 'backend'", - str(exc.exception), - ) - def test_unsupported_options(self): """Test error on unsupported second level options""" # defining minimal dict of options options = { "optimization_level": 1, "resilience_level": 2, - "dynamical_decoupling": "XX", "transpilation": {"initial_layout": [1, 2], "skip_transpilation": True}, "execution": {"shots": 100}, "environment": {"log_level": "DEBUG"}, - "simulator": {"noise_model": "model"}, "resilience": { "noise_factors": (0, 2, 4), "extrapolator": "LinearExtrapolator", }, - "twirling": {}, } Options.validate_options(options) - for opt in ["simulator", "transpilation", "execution"]: + for opt in ["resilience", "simulator", "transpilation", "execution"]: temp_options = options.copy() temp_options[opt] = {"aaa": "bbb"} - with self.assertRaises(ValueError) as exc: + with self.assertRaises(ValidationError) as exc: Options.validate_options(temp_options) - self.assertIn(f"Unsupported value 'aaa' for {opt}.", str(exc.exception)) + self.assertIn(f"bbb", str(exc.exception)) def test_coupling_map_options(self): """Check that coupling_map is processed correctly for various types""" @@ -205,7 +185,7 @@ def test_coupling_map_options(self): options = Options() options.simulator.coupling_map = variant inputs = Options._get_program_inputs(asdict(options)) - resulting_cmap = inputs["transpilation"]["coupling_map"] + resulting_cmap = inputs["transpilation_settings"]["coupling_map"] self.assertEqual(coupling_map, set(map(tuple, resulting_cmap))) @data(FakeManila(), FakeNairobiV2()) @@ -289,61 +269,3 @@ def test_qctrl_overrides(self): with self.subTest(msg=f"{option}"): _warn_and_clean_options(option) self.assertEqual(expected_, option) - - def test_merge_with_defaults_overwrite(self): - """Test merge_with_defaults with different overwrite.""" - expected = {"twirling": {"measure": True}} - all_options = [ - ({"twirling": {"measure": True}}, {}), - ({}, {"twirling": {"measure": True}}), - ({"twirling": {"measure": False}}, {"twirling": {"measure": True}}), - ] - - for old, new in all_options: - with self.subTest(old=old, new=new): - old["resilience_level"] = 0 - final = Options._merge_options_with_defaults(old, new) - self.assertTrue(dict_paritally_equal(final, expected)) - self.assertEqual(final["resilience_level"], 0) - res_dict = final["resilience"] - self.assertFalse(res_dict["measure_noise_mitigation"]) - self.assertFalse(res_dict["zne_mitigation"]) - self.assertFalse(res_dict["pec_mitigation"]) - - def test_merge_with_defaults_different_level(self): - """Test merge_with_defaults with different resilience level.""" - - old = {"resilience_level": 0} - new = {"resilience_level": 3, "measure_noise_mitigation": False} - final = Options._merge_options_with_defaults(old, new) - self.assertEqual(final["resilience_level"], 3) - res_dict = final["resilience"] - self.assertFalse(res_dict["measure_noise_mitigation"]) - self.assertFalse(res_dict["zne_mitigation"]) - self.assertTrue(res_dict["pec_mitigation"]) - - def test_merge_with_defaults_noiseless_simulator(self): - """Test merge_with_defaults with noiseless simulator.""" - - new = {"measure_noise_mitigation": True} - final = Options._merge_options_with_defaults({}, new, is_simulator=True) - self.assertEqual(final["resilience_level"], 0) - self.assertEqual(final["optimization_level"], 1) - res_dict = final["resilience"] - self.assertTrue(res_dict["measure_noise_mitigation"]) - self.assertFalse(res_dict["zne_mitigation"]) - self.assertFalse(res_dict["pec_mitigation"]) - - def test_merge_with_defaults_noisy_simulator(self): - """Test merge_with_defaults with noisy simulator.""" - - new = {"measure_noise_mitigation": False} - final = Options._merge_options_with_defaults( - {"simulator": {"noise_model": "foo"}}, new, is_simulator=True - ) - self.assertEqual(final["resilience_level"], 1) - self.assertEqual(final["optimization_level"], 3) - res_dict = final["resilience"] - self.assertFalse(res_dict["measure_noise_mitigation"]) - self.assertFalse(res_dict["zne_mitigation"]) - self.assertFalse(res_dict["pec_mitigation"]) From 1d4b36ee764bccd419cf5fb04fe045d3112d7d44 Mon Sep 17 00:00:00 2001 From: Jessie Yu Date: Fri, 3 Nov 2023 16:09:58 -0400 Subject: [PATCH 10/35] lint --- qiskit_ibm_runtime/base_primitive.py | 18 ++++--- qiskit_ibm_runtime/estimator.py | 49 ++++++++++++++----- qiskit_ibm_runtime/options/__init__.py | 2 +- .../options/environment_options.py | 4 +- .../options/estimator_options.py | 37 +++++++++----- .../options/execution_options.py | 24 ++++++--- qiskit_ibm_runtime/options/options.py | 13 +++-- .../options/resilience_options.py | 23 ++++++--- .../options/simulator_options.py | 26 +++++----- .../options/transpilation_options.py | 12 +++-- .../options/twirling_options.py | 5 +- qiskit_ibm_runtime/options/utils.py | 6 ++- .../qiskit/primitives/__init__.py | 2 +- .../qiskit/primitives/base_primitive.py | 2 - qiskit_ibm_runtime/sampler.py | 16 ++++-- test/ibm_test_case.py | 1 + test/unit/test_estimator.py | 7 +-- test/unit/test_estimator_options.py | 32 ++++++------ test/unit/test_ibm_primitives_v2.py | 33 ++++++------- test/unit/test_options.py | 2 +- test/utils.py | 6 ++- 21 files changed, 208 insertions(+), 112 deletions(-) diff --git a/qiskit_ibm_runtime/base_primitive.py b/qiskit_ibm_runtime/base_primitive.py index cd6e4cef8..18d84387e 100644 --- a/qiskit_ibm_runtime/base_primitive.py +++ b/qiskit_ibm_runtime/base_primitive.py @@ -19,9 +19,7 @@ import logging from dataclasses import asdict, replace -from qiskit.providers.options import Options as TerraOptions - -from .options import BaseOptions +from .options import BaseOptions, Options from .options.utils import merge_options, set_default_error_levels from .runtime_job import RuntimeJob from .ibm_backend import IBMBackend @@ -38,7 +36,7 @@ class BasePrimitive(ABC): """Base class for Qiskit Runtime primitives.""" - _OPTIONS_CLASS: BaseOptions = None + _OPTIONS_CLASS: type[BaseOptions] = Options version = 0 def __init__( @@ -71,6 +69,7 @@ def __init__( self._session: Optional[Session] = None self._service: QiskitRuntimeService = None self._backend: Optional[IBMBackend] = None + self._options: dict = {} # Only used by v1 self._initialize_options(options=options) @@ -193,7 +192,8 @@ def set_options(self, **fields: Any) -> None: if self.version == 1: self._options = merge_options(self._options, fields) else: - self.options = self._OPTIONS_CLASS(**merge_options(self.options, fields)) + self.options = self._OPTIONS_CLASS( # pylint: disable=attribute-defined-outside-init + **merge_options(self.options, fields)) def _initialize_options(self, options: Optional[Union[Dict, BaseOptions]] = None): """Initialize the options.""" @@ -211,7 +211,9 @@ def _initialize_options(self, options: Optional[Union[Dict, BaseOptions]] = None default_options = asdict(opt_cls()) self._options = merge_options(default_options, options_copy) else: - raise ValueError(f"Invalid 'options' type. It can only be a dictionary of {opt_cls}") + raise ValueError( + f"Invalid 'options' type. It can only be a dictionary of {opt_cls}" + ) elif self.version == 2: if options is None: self.options = opt_cls() @@ -221,7 +223,9 @@ def _initialize_options(self, options: Optional[Union[Dict, BaseOptions]] = None default_options = opt_cls() self.options = opt_cls(**merge_options(default_options, options)) else: - raise ValueError(f"Invalid 'options' type. It can only be a dictionary of {opt_cls}") + raise ValueError( + f"Invalid 'options' type. It can only be a dictionary of {opt_cls}" + ) else: raise ValueError(f"Invalid primitive version {self.version}") diff --git a/qiskit_ibm_runtime/estimator.py b/qiskit_ibm_runtime/estimator.py index 87022b287..ec672e2bd 100644 --- a/qiskit_ibm_runtime/estimator.py +++ b/qiskit_ibm_runtime/estimator.py @@ -13,7 +13,6 @@ """Estimator primitive.""" from __future__ import annotations -from abc import ABC import os from typing import Optional, Dict, Sequence, Any, Union, Mapping import logging @@ -34,11 +33,11 @@ from .runtime_job import RuntimeJob from .ibm_backend import IBMBackend from .options import Options -from .options.estimator_options import EstimatorOptionsV2, EstimatorOptions -from .options.utils import merge_options +from .options.estimator_options import EstimatorOptions from .base_primitive import BasePrimitive from .utils.qctrl import validate as qctrl_validate from .utils.deprecation import issue_deprecation_msg + # TODO: remove when we have real v2 base estimator from .qiskit.primitives import BaseEstimatorV2 @@ -69,9 +68,21 @@ class Estimator(BasePrimitive): + """Base class for Qiskit Runtime Estimator.""" + _PROGRAM_ID = "estimator" version = 0 + def __init__( + self, + backend: Optional[Union[str, IBMBackend]] = None, + session: Optional[Union[Session, str, IBMBackend]] = None, + options: Optional[Union[Dict, EstimatorOptions]] = None, + ): + """Initializes the Estimator primitive.""" + BasePrimitive.__init__(self, backend=backend, session=session, options=options) + + class EstimatorV2(Estimator, BaseEstimatorV2): """Class for interacting with Qiskit Runtime Estimator primitive service. @@ -119,7 +130,7 @@ class EstimatorV2(Estimator, BaseEstimatorV2): """ _ALLOWED_BASIS: str = "IXYZ01+-rl" - _OPTIONS_CLASS = EstimatorOptionsV2 + _OPTIONS_CLASS = EstimatorOptions version = 2 @@ -145,13 +156,18 @@ def __init__( options: Primitive options, see :class:`Options` for detailed description. The ``backend`` keyword is still supported but is deprecated. + + Raises: + NotImplementedError: If "q-ctrl" channel strategy is used. """ BaseEstimatorV2.__init__(self) - BasePrimitive.__init__(self, backend=backend, session=session, options=options) + Estimator.__init__(self, backend=backend, session=session, options=options) - self.options._is_simulator = self._backend is not None and self._backend.configuration().simulator is True + self.options._is_simulator = ( + self._backend is not None and self._backend.configuration().simulator is True + ) if self._service._channel_strategy == "q-ctrl": - raise NotImplemented("EstimatorV2 is not supported with q-ctrl channel strategy.") + raise NotImplementedError("EstimatorV2 is not supported with q-ctrl channel strategy.") def run( # pylint: disable=arguments-differ self, @@ -188,15 +204,20 @@ def run( # pylint: disable=arguments-differ circuits = self._validate_circuits(circuits=circuits) observables = self._validate_observables(observables=observables) - parameter_values = self._validate_parameter_values(parameter_values=parameter_values, default=[()] * len(circuits),) + parameter_values = self._validate_parameter_values( + parameter_values=parameter_values, + default=[()] * len(circuits), + ) self._cross_validate_circuits_observables(circuits=circuits, observables=observables) - self._cross_validate_circuits_parameter_values(circuits=circuits, parameter_values=parameter_values) + self._cross_validate_circuits_parameter_values( + circuits=circuits, parameter_values=parameter_values + ) return self._run( circuits=circuits, observables=observables, parameter_values=parameter_values, - **user_kwargs + **user_kwargs, ) # return super().run( # circuits=circuits, @@ -228,7 +249,11 @@ def _run( # pylint: disable=arguments-differ Returns: Submitted job """ - logger.debug("Running %s with new options %s", self.__class__.__name__, kwargs.get("_user_kwargs", {})) + logger.debug( + "Running %s with new options %s", + self.__class__.__name__, + kwargs.get("_user_kwargs", {}), + ) inputs = { "circuits": circuits, "observables": observables, @@ -407,7 +432,7 @@ def __init__( # qiskit.providers.Options. We largely ignore this _run_options because we use # a nested dictionary to categorize options. BaseEstimator.__init__(self) - BasePrimitive.__init__(self, backend=backend, session=session, options=options) + Estimator.__init__(self, backend=backend, session=session, options=options) def run( # pylint: disable=arguments-differ self, diff --git a/qiskit_ibm_runtime/options/__init__.py b/qiskit_ibm_runtime/options/__init__.py index 0a5a18ae6..9a7a013f6 100644 --- a/qiskit_ibm_runtime/options/__init__.py +++ b/qiskit_ibm_runtime/options/__init__.py @@ -61,4 +61,4 @@ from .transpilation_options import TranspilationOptions from .resilience_options import ResilienceOptionsV1 as ResilienceOptions from .twirling_options import TwirlingOptions -from .estimator_options import EstimatorOptionsV2 as EstimatorOptions +from .estimator_options import EstimatorOptions diff --git a/qiskit_ibm_runtime/options/environment_options.py b/qiskit_ibm_runtime/options/environment_options.py index c6c7b0524..58c88bfa0 100644 --- a/qiskit_ibm_runtime/options/environment_options.py +++ b/qiskit_ibm_runtime/options/environment_options.py @@ -26,7 +26,9 @@ ] -@pydantic_dataclass(config=ConfigDict(validate_assignment=True, arbitrary_types_allowed=True, extra="forbid")) +@pydantic_dataclass( + config=ConfigDict(validate_assignment=True, arbitrary_types_allowed=True, extra="forbid") +) class EnvironmentOptions: """Options related to the execution environment. diff --git a/qiskit_ibm_runtime/options/estimator_options.py b/qiskit_ibm_runtime/options/estimator_options.py index 07fc2462f..b4a717e8d 100644 --- a/qiskit_ibm_runtime/options/estimator_options.py +++ b/qiskit_ibm_runtime/options/estimator_options.py @@ -19,7 +19,14 @@ from pydantic.dataclasses import dataclass as pydantic_dataclass from pydantic import Field, ConfigDict, model_validator, field_validator -from .utils import Dict, Unset, UnsetType, _remove_dict_unset_values, merge_options, skip_unset_validation +from .utils import ( + Dict, + Unset, + UnsetType, + _remove_dict_unset_values, + merge_options, + skip_unset_validation, +) from .execution_options import ExecutionOptionsV2 from .transpilation_options import TranspilationOptions from .resilience_options import ResilienceOptionsV2 @@ -29,12 +36,10 @@ DDSequenceType = Literal["XX", "XpXm", "XY4"] +@pydantic_dataclass( + config=ConfigDict(validate_assignment=True, arbitrary_types_allowed=True, extra="forbid") +) class EstimatorOptions(OptionsV2): - pass - - -@pydantic_dataclass(config=ConfigDict(validate_assignment=True, arbitrary_types_allowed=True, extra="forbid")) -class EstimatorOptionsV2(EstimatorOptions): """Options for v2 Estimator. Args: @@ -84,6 +89,7 @@ class EstimatorOptionsV2(EstimatorOptions): :class:`SimulatorOptions` for all available options. """ + _version: int = 2 _is_simulator: bool = False @@ -102,10 +108,14 @@ class EstimatorOptionsV2(EstimatorOptions): @field_validator("optimization_level") @classmethod + @skip_unset_validation def _validate_optimization_level(cls, optimization_level: Union[UnsetType, int]): """Validate optimization_leve.""" - if not isinstance(optimization_level, UnsetType) and not (0 <= optimization_level <= 3): - raise ValueError(f"Invalid optimization_level. Valid range is 0-{EstimatorOptionsV2._MAX_OPTIMIZATION_LEVEL}") + if not 0 <= optimization_level <= 3: + raise ValueError( + "Invalid optimization_level. Valid range is " + f"0-{EstimatorOptions._MAX_OPTIMIZATION_LEVEL}" + ) return optimization_level @field_validator("resilience_level") @@ -113,11 +123,14 @@ def _validate_optimization_level(cls, optimization_level: Union[UnsetType, int]) @skip_unset_validation def _validate_resilience_level(cls, resilience_level: Union[UnsetType, int]): """Validate resilience_level.""" - if not (0 <= resilience_level <= 3): - raise ValueError(f"Invalid optimization_level. Valid range is 0-{EstimatorOptionsV2._MAX_RESILIENCE_LEVEL}") + if not 0 <= resilience_level <= 3: + raise ValueError( + "Invalid optimization_level. Valid range is " + f"0-{EstimatorOptions._MAX_RESILIENCE_LEVEL}" + ) return resilience_level - @model_validator(mode='after') + @model_validator(mode="after") def _validate_options(self): """Validate the model.""" # TODO: Server should have different optimization/resilience levels for simulator @@ -173,7 +186,7 @@ def _get_program_inputs(options: dict) -> dict: inputs = merge_options(inputs, options.get("experimental")) inputs["_experimental"] = True - inputs["version"] = EstimatorOptionsV2._version + inputs["version"] = EstimatorOptions._version _remove_dict_unset_values(inputs) return inputs diff --git a/qiskit_ibm_runtime/options/execution_options.py b/qiskit_ibm_runtime/options/execution_options.py index 480d885ba..ab506cc23 100644 --- a/qiskit_ibm_runtime/options/execution_options.py +++ b/qiskit_ibm_runtime/options/execution_options.py @@ -15,12 +15,14 @@ from typing import Union from pydantic.dataclasses import dataclass as pydantic_dataclass -from pydantic import Field, ConfigDict, model_validator, field_validator, ValidationInfo +from pydantic import ConfigDict, model_validator, field_validator, ValidationInfo from .utils import Unset, UnsetType, skip_unset_validation -@pydantic_dataclass(config=ConfigDict(validate_assignment=True, arbitrary_types_allowed=True, extra="forbid")) +@pydantic_dataclass( + config=ConfigDict(validate_assignment=True, arbitrary_types_allowed=True, extra="forbid") +) class ExecutionOptionsV2: """Execution options. @@ -47,6 +49,7 @@ class ExecutionOptionsV2: circuit in order. Default: False """ + shots: Union[UnsetType, int] = Unset init_qubits: bool = True samples: Union[UnsetType, int] = Unset @@ -62,17 +65,26 @@ def _validate_positive_integer(cls, fld: Union[UnsetType, int], info: Validation raise ValueError(f"{info.field_name} must be >= 1") return fld - @model_validator(mode='after') + @model_validator(mode="after") def _validate_options(self): """Validate the model.""" - if all(not isinstance(fld, UnsetType) for fld in [self.shots, self.samples, self.shots_per_sample]) and self.shots != self.samples * self.shots_per_sample: + if ( + all( + not isinstance(fld, UnsetType) + for fld in [self.shots, self.samples, self.shots_per_sample] + ) + and self.shots != self.samples * self.shots_per_sample + ): raise ValueError( - f"Shots ({self.shots}) != samples ({self.samples}) * shots_per_sample ({self.shots_per_sample})" + f"Shots ({self.shots}) != " + f"samples ({self.samples}) * shots_per_sample ({self.shots_per_sample})" ) return self -@pydantic_dataclass(config=ConfigDict(validate_assignment=True, arbitrary_types_allowed=True, extra="forbid")) +@pydantic_dataclass( + config=ConfigDict(validate_assignment=True, arbitrary_types_allowed=True, extra="forbid") +) class ExecutionOptionsV1: """Execution options. diff --git a/qiskit_ibm_runtime/options/options.py b/qiskit_ibm_runtime/options/options.py index ec58df55f..49c6e20d6 100644 --- a/qiskit_ibm_runtime/options/options.py +++ b/qiskit_ibm_runtime/options/options.py @@ -13,8 +13,8 @@ """Primitive options.""" from abc import ABC, abstractmethod -from typing import Optional, Union, ClassVar, Literal, get_args, Any -from dataclasses import dataclass, fields, field, asdict +from typing import Optional, Union, ClassVar +from dataclasses import dataclass, fields, field import copy import warnings @@ -29,16 +29,19 @@ from .transpilation_options import TranspilationOptions from .resilience_options import ResilienceOptionsV1 as ResilienceOptions from ..runtime_options import RuntimeOptions + # TODO use real base options when available from ..qiskit.primitives import BasePrimitiveOptions @dataclass class BaseOptions(ABC, BasePrimitiveOptions): + """Base options class.""" @abstractmethod + @staticmethod def _get_program_inputs(options: dict) -> dict: - raise NotImplemented() + raise NotImplementedError() @staticmethod def _get_runtime_options(options: dict) -> dict: @@ -64,7 +67,9 @@ def _get_runtime_options(options: dict) -> dict: return out -@pydantic_dataclass(config=ConfigDict(validate_assignment=True, arbitrary_types_allowed=True, extra="forbid")) +@pydantic_dataclass( + config=ConfigDict(validate_assignment=True, arbitrary_types_allowed=True, extra="forbid") +) class OptionsV2(BaseOptions): """Base primitive options. diff --git a/qiskit_ibm_runtime/options/resilience_options.py b/qiskit_ibm_runtime/options/resilience_options.py index 74b4adbb3..6653c72bf 100644 --- a/qiskit_ibm_runtime/options/resilience_options.py +++ b/qiskit_ibm_runtime/options/resilience_options.py @@ -45,7 +45,9 @@ ] -@pydantic_dataclass(config=ConfigDict(validate_assignment=True, arbitrary_types_allowed=True, extra="forbid")) +@pydantic_dataclass( + config=ConfigDict(validate_assignment=True, arbitrary_types_allowed=True, extra="forbid") +) class ResilienceOptionsV2: """Resilience options. @@ -127,11 +129,14 @@ def _validate_pec_max_overhead(cls, overhead: Union[UnsetType, float]): raise ValueError("pec_max_overhead must be None or >= 1") return overhead - @model_validator(mode='after') + @model_validator(mode="after") def _validate_options(self): """Validate the model.""" # Validate ZNE noise factors + extrapolator combination - if all(not isinstance(fld, UnsetType) for fld in [self.zne_noise_factors, self.zne_extrapolator]): + if all( + not isinstance(fld, UnsetType) + for fld in [self.zne_noise_factors, self.zne_extrapolator] + ): required_factors = { "exponential": 2, "double_exponential": 4, @@ -141,7 +146,11 @@ def _validate_options(self): "polynomial_degree_3": 4, "polynomial_degree_4": 5, } - extrapolators = [self.zne_extrapolator] if isinstance(self.zne_extrapolator, str) else self.zne_extrapolator + extrapolators = ( + [self.zne_extrapolator] + if isinstance(self.zne_extrapolator, str) + else self.zne_extrapolator + ) for extrap in extrapolators: if len(self.zne_noise_factors) < required_factors[extrap]: raise ValueError( @@ -174,7 +183,9 @@ def _validate_options(self): # pec_max_overhead: float = 100 -@pydantic_dataclass(config=ConfigDict(validate_assignment=True, arbitrary_types_allowed=True, extra="forbid")) +@pydantic_dataclass( + config=ConfigDict(validate_assignment=True, arbitrary_types_allowed=True, extra="forbid") +) class ResilienceOptionsV1: """Resilience options. @@ -200,7 +211,7 @@ class ResilienceOptionsV1: noise_factors: Optional[Sequence[float]] = None extrapolator: Optional[ExtrapolatorType] = None - @model_validator(mode='after') + @model_validator(mode="after") def _validate_options(self): """Validate the model.""" required_factors = { diff --git a/qiskit_ibm_runtime/options/simulator_options.py b/qiskit_ibm_runtime/options/simulator_options.py index b2fb819cd..52e1a47ad 100644 --- a/qiskit_ibm_runtime/options/simulator_options.py +++ b/qiskit_ibm_runtime/options/simulator_options.py @@ -12,8 +12,7 @@ """Simulator options.""" -from typing import Optional, List, Union, Literal, get_args, TYPE_CHECKING -from dataclasses import dataclass +from typing import Optional, List, Union, TYPE_CHECKING from qiskit.exceptions import MissingOptionalLibraryError from qiskit.providers import BackendV1, BackendV2 @@ -21,19 +20,24 @@ from qiskit.transpiler import CouplingMap # pylint: disable=unused-import from pydantic.dataclasses import dataclass as pydantic_dataclass -from pydantic import Field, ConfigDict, model_validator +from pydantic import ConfigDict -try: - import qiskit_aer +if TYPE_CHECKING: + try: + import qiskit_aer - NoiseModel = qiskit_aer.noise.NoiseModel + NoiseModel = qiskit_aer.noise.NoiseModel -except ImportError: - class NoiseModel: - pass + except ImportError: + class NoiseModel: + """Fake noise model class.""" + pass -@pydantic_dataclass(config=ConfigDict(validate_assignment=True, arbitrary_types_allowed=True, extra="forbid")) + +@pydantic_dataclass( + config=ConfigDict(validate_assignment=True, arbitrary_types_allowed=True, extra="forbid") +) class SimulatorOptions: """Simulator options. @@ -67,7 +71,7 @@ def set_backend(self, backend: Union[BackendV1, BackendV2]) -> None: backend: backend to be set. Raises: - MissingOptionalLibraryError if qiskit-aer is not found. + MissingOptionalLibraryError: if qiskit-aer is not found. """ if not optionals.HAS_AER: raise MissingOptionalLibraryError( diff --git a/qiskit_ibm_runtime/options/transpilation_options.py b/qiskit_ibm_runtime/options/transpilation_options.py index cb39d069b..9bb9d1e38 100644 --- a/qiskit_ibm_runtime/options/transpilation_options.py +++ b/qiskit_ibm_runtime/options/transpilation_options.py @@ -15,12 +15,14 @@ from typing import List, Union, Literal from pydantic.dataclasses import dataclass as pydantic_dataclass -from pydantic import Field, ConfigDict, field_validator +from pydantic import ConfigDict, field_validator from .utils import Unset, UnsetType, skip_unset_validation -@pydantic_dataclass(config=ConfigDict(validate_assignment=True, arbitrary_types_allowed=True, extra="forbid")) +@pydantic_dataclass( + config=ConfigDict(validate_assignment=True, arbitrary_types_allowed=True, extra="forbid") +) class TranspilationOptions: """Transpilation options. @@ -44,7 +46,9 @@ class TranspilationOptions: skip_transpilation: bool = False initial_layout: Union[UnsetType, dict, List] = Unset # TODO: Support Layout layout_method: Union[UnsetType, Literal["trivial", "dense", "noise_adaptive", "sabre"]] = Unset - routing_method: Union[UnsetType, Literal["basic", "lookahead", "stochastic", "sabre", "none"]] = Unset + routing_method: Union[ + UnsetType, Literal["basic", "lookahead", "stochastic", "sabre", "none"] + ] = Unset approximation_degree: Union[UnsetType, float] = Unset @field_validator("approximation_degree") @@ -52,7 +56,7 @@ class TranspilationOptions: @skip_unset_validation def _validate_approximation_degree(cls, degree: Union[UnsetType, float]): """Validate approximation_degree.""" - if not (0.0 <= degree <= 1.0): + if not 0.0 <= degree <= 1.0: raise ValueError( "approximation_degree must be between 0.0 (maximal approximation) " "and 1.0 (no approximation)" diff --git a/qiskit_ibm_runtime/options/twirling_options.py b/qiskit_ibm_runtime/options/twirling_options.py index b34d67028..97b35871c 100644 --- a/qiskit_ibm_runtime/options/twirling_options.py +++ b/qiskit_ibm_runtime/options/twirling_options.py @@ -28,7 +28,9 @@ ] -@pydantic_dataclass(config=ConfigDict(validate_assignment=True, arbitrary_types_allowed=True, extra="forbid")) +@pydantic_dataclass( + config=ConfigDict(validate_assignment=True, arbitrary_types_allowed=True, extra="forbid") +) class TwirlingOptions: """Twirling options. @@ -59,4 +61,3 @@ class TwirlingOptions: gates: Union[UnsetType, bool] = Unset measure: Union[UnsetType, bool] = Unset strategy: Union[UnsetType, TwirlingStrategyType] = Unset - diff --git a/qiskit_ibm_runtime/options/utils.py b/qiskit_ibm_runtime/options/utils.py index dc34f1745..744659084 100644 --- a/qiskit_ibm_runtime/options/utils.py +++ b/qiskit_ibm_runtime/options/utils.py @@ -79,7 +79,9 @@ def _to_obj(cls_, data): # type: ignore ) -def merge_options(old_options: Union[dict, "BaseOptions"], new_options: Optional[dict] = None) -> dict: +def merge_options( + old_options: Union[dict, "BaseOptions"], new_options: Optional[dict] = None +) -> dict: """Merge current options with the new ones. Args: @@ -123,6 +125,7 @@ def _update_options(old: dict, new: dict, matched: Optional[dict] = None) -> Non def skip_unset_validation(func: Callable) -> Callable: """Decorator used to skip unset value""" + @functools.wraps(func) def wrapper(cls, val, *args, **kwargs) -> Callable: if isinstance(val, UnsetType): @@ -131,6 +134,7 @@ def wrapper(cls, val, *args, **kwargs) -> Callable: return wrapper + class Dict: """Fake Dict type. diff --git a/qiskit_ibm_runtime/qiskit/primitives/__init__.py b/qiskit_ibm_runtime/qiskit/primitives/__init__.py index 4dc147c5b..639cdcbd5 100644 --- a/qiskit_ibm_runtime/qiskit/primitives/__init__.py +++ b/qiskit_ibm_runtime/qiskit/primitives/__init__.py @@ -1,2 +1,2 @@ from .base_estimator import BaseEstimatorV2 -from .base_primitive import BasePrimitiveOptions \ No newline at end of file +from .base_primitive import BasePrimitiveOptions diff --git a/qiskit_ibm_runtime/qiskit/primitives/base_primitive.py b/qiskit_ibm_runtime/qiskit/primitives/base_primitive.py index 82719bf1c..7a6ec2fa4 100644 --- a/qiskit_ibm_runtime/qiskit/primitives/base_primitive.py +++ b/qiskit_ibm_runtime/qiskit/primitives/base_primitive.py @@ -23,7 +23,6 @@ @dataclass class BasePrimitiveOptions: - def __call__(self): return replace(self) @@ -46,7 +45,6 @@ def set_options(self, **fields): """ raise NotImplementedError() - @staticmethod def _validate_circuits( circuits: Sequence[QuantumCircuit] | QuantumCircuit, diff --git a/qiskit_ibm_runtime/sampler.py b/qiskit_ibm_runtime/sampler.py index fa3d99cbb..68a9f2ab4 100644 --- a/qiskit_ibm_runtime/sampler.py +++ b/qiskit_ibm_runtime/sampler.py @@ -33,13 +33,23 @@ logger = logging.getLogger(__name__) -class Sampler(BasePrimitive, BaseSampler): +class Sampler(BasePrimitive): """Base type for Sampelr.""" version = 0 + _OPTIONS_CLASS = Options + + def __init__( + self, + backend: Optional[Union[str, IBMBackend]] = None, + session: Optional[Union[Session, str, IBMBackend]] = None, + options: Optional[Union[Dict, Options]] = None, + ): + """Initializes the Sampler primitive.""" + BasePrimitive.__init__(self, backend=backend, session=session, options=options) -class SamplerV1(Sampler): +class SamplerV1(Sampler, BaseSampler): """Class for interacting with Qiskit Runtime Sampler primitive service. Qiskit Runtime Sampler primitive service calculates quasi-probability distribution @@ -101,7 +111,7 @@ def __init__( # qiskit.providers.Options. We largely ignore this _run_options because we use # a nested dictionary to categorize options. BaseSampler.__init__(self) - BasePrimitive.__init__(self, backend=backend, session=session, options=options) + Sampler.__init__(self, backend=backend, session=session, options=options) def run( # pylint: disable=arguments-differ self, diff --git a/test/ibm_test_case.py b/test/ibm_test_case.py index d301fc413..85d563d46 100644 --- a/test/ibm_test_case.py +++ b/test/ibm_test_case.py @@ -32,6 +32,7 @@ logging.getLogger("qiskit_ibm_runtime").setLevel("DEBUG") + class IBMTestCase(unittest.TestCase): """Custom TestCase for use with qiskit-ibm-runtime.""" diff --git a/test/unit/test_estimator.py b/test/unit/test_estimator.py index d507b6104..e1863d9f2 100644 --- a/test/unit/test_estimator.py +++ b/test/unit/test_estimator.py @@ -12,7 +12,7 @@ """Tests for estimator class.""" -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock from qiskit import QuantumCircuit from qiskit.quantum_info import SparsePauliOp, Pauli, random_hermitian, random_pauli_list @@ -110,8 +110,9 @@ def test_run_default_options(self): f"{inputs} and {expected} not partially equal.", ) - @data({"zne_extrapolator": "bad_extrapolator"}, - {"zne_extrapolator": "double_exponential", "zne_noise_factors": [1]}, + @data( + {"zne_extrapolator": "bad_extrapolator"}, + {"zne_extrapolator": "double_exponential", "zne_noise_factors": [1]}, ) def test_invalid_resilience_options(self, res_opt): """Test invalid resilience options.""" diff --git a/test/unit/test_estimator_options.py b/test/unit/test_estimator_options.py index 8dbfa5143..13b4858df 100644 --- a/test/unit/test_estimator_options.py +++ b/test/unit/test_estimator_options.py @@ -118,7 +118,9 @@ def test_init_options_with_dictionary(self): ) # Make sure the structure didn't change. - self.assertTrue(dict_keys_equal(asdict(EstimatorOptions()), options), f"options={options}") + self.assertTrue( + dict_keys_equal(asdict(EstimatorOptions()), options), f"options={options}" + ) def test_kwargs_options(self): """Test specifying arbitrary options.""" @@ -142,20 +144,22 @@ def test_coupling_map_options(self): resulting_cmap = inputs["transpilation"]["coupling_map"] self.assertEqual(coupling_map, set(map(tuple, resulting_cmap))) - @data({"optimization_level": 99}, - {"resilience_level": 99}, - {"dynamical_decoupling": "foo"}, - {"transpilation": {"skip_transpilation": "foo"}}, - {"execution": {"shots": 0}}, - {"twirling": {"strategy": "foo"}}, - {"transpilation": {"foo": "bar"}}, - {"resilience_level": 3, "_is_simulator": True}, - {"zne_noise_factors": [0.5]}, - {"noise_factors": [1, 3, 5]}, - {"zne_extrapolator": "exponential", "zne_noise_factors": [1]}, - {"zne_mitigation": True, "pec_mitigation": True} - ) + @data( + {"optimization_level": 99}, + {"resilience_level": 99}, + {"dynamical_decoupling": "foo"}, + {"transpilation": {"skip_transpilation": "foo"}}, + {"execution": {"shots": 0}}, + {"twirling": {"strategy": "foo"}}, + {"transpilation": {"foo": "bar"}}, + {"resilience_level": 3, "_is_simulator": True}, + {"zne_noise_factors": [0.5]}, + {"noise_factors": [1, 3, 5]}, + {"zne_extrapolator": "exponential", "zne_noise_factors": [1]}, + {"zne_mitigation": True, "pec_mitigation": True}, + ) def test_bad_inputs(self, val): + """Test invalid inputs.""" with self.assertRaises(ValidationError) as exc: EstimatorOptions(**val) self.assertIn(list(val.keys())[0], str(exc.exception)) diff --git a/test/unit/test_ibm_primitives_v2.py b/test/unit/test_ibm_primitives_v2.py index 687498c5a..3d35c6e65 100644 --- a/test/unit/test_ibm_primitives_v2.py +++ b/test/unit/test_ibm_primitives_v2.py @@ -13,11 +13,9 @@ """Tests for primitive classes.""" import sys -import os -from unittest.mock import MagicMock, patch from dataclasses import asdict -from typing import Dict from unittest import skip +from unittest.mock import MagicMock, patch from ddt import data, ddt from qiskit import transpile @@ -44,7 +42,7 @@ dict_keys_equal, create_faulty_backend, combine, - MockSession + MockSession, ) @@ -79,7 +77,13 @@ def test_dict_options(self, primitive): inst = primitive(session=MagicMock(spec=MockSession), options=options) self.assertTrue(dict_paritally_equal(asdict(inst.options()), options)) - @combine(primitive=[EstimatorV2], env_var=[{"log_level": "DEBUG"}, {"job_tags": ["foo", "bar"]},]) + @combine( + primitive=[EstimatorV2], + env_var=[ + {"log_level": "DEBUG"}, + {"job_tags": ["foo", "bar"]}, + ], + ) def test_runtime_options(self, primitive, env_var): """Test RuntimeOptions specified as primitive options.""" session = MagicMock(spec=MockSession) @@ -104,9 +108,7 @@ def test_init_with_backend_str(self, primitive): """Test initializing a primitive with a backend name.""" backend_name = "ibm_gotham" - with patch( - "qiskit_ibm_runtime.base_primitive.QiskitRuntimeService" - ) as mock_service: + with patch("qiskit_ibm_runtime.base_primitive.QiskitRuntimeService") as mock_service: mock_service.reset_mock() mock_service_inst = MagicMock() mock_service.return_value = mock_service_inst @@ -128,9 +130,7 @@ def test_init_with_session_backend_str(self, primitive): """Test initializing a primitive with a backend name using session.""" backend_name = "ibm_gotham" - with patch( - "qiskit_ibm_runtime.base_primitive.QiskitRuntimeService" - ): + with patch("qiskit_ibm_runtime.base_primitive.QiskitRuntimeService"): with self.assertRaises(ValueError) as exc: inst = primitive(session=backend_name) self.assertIsNone(inst.session) @@ -174,9 +174,7 @@ def test_init_with_backend_session(self, primitive): @data(EstimatorV2) def test_init_with_no_backend_session_cloud(self, primitive): """Test initializing a primitive without backend or session for cloud channel.""" - with patch( - "qiskit_ibm_runtime.base_primitive.QiskitRuntimeService" - ) as mock_service: + with patch("qiskit_ibm_runtime.base_primitive.QiskitRuntimeService") as mock_service: mock_service_inst = MagicMock() mock_service_inst.channel = "ibm_cloud" mock_service.return_value = mock_service_inst @@ -190,9 +188,7 @@ def test_init_with_no_backend_session_cloud(self, primitive): def test_init_with_no_backend_session_quantum(self, primitive): """Test initializing a primitive without backend or session for quantum channel.""" - with patch( - "qiskit_ibm_runtime.base_primitive.QiskitRuntimeService" - ) as mock_service: + with patch("qiskit_ibm_runtime.base_primitive.QiskitRuntimeService") as mock_service: mock_service.reset_mock() with self.assertRaises(ValueError): _ = primitive() @@ -311,8 +307,7 @@ def test_run_overwrite_runtime_options(self, primitive): rt_options = session.run.call_args.kwargs["options"] self._assert_dict_partially_equal(rt_options, options) - @combine(primitive=[EstimatorV2], - exp_opt=[{"foo": "bar"}, {"transpilation": {"foo": "bar"}}]) + @combine(primitive=[EstimatorV2], exp_opt=[{"foo": "bar"}, {"transpilation": {"foo": "bar"}}]) def test_run_experimental_options(self, primitive, exp_opt): """Test specifying arbitrary options in run.""" # FIXME diff --git a/test/unit/test_options.py b/test/unit/test_options.py index ee3573139..31a3b2d8e 100644 --- a/test/unit/test_options.py +++ b/test/unit/test_options.py @@ -170,7 +170,7 @@ def test_unsupported_options(self): temp_options[opt] = {"aaa": "bbb"} with self.assertRaises(ValidationError) as exc: Options.validate_options(temp_options) - self.assertIn(f"bbb", str(exc.exception)) + self.assertIn("bbb", str(exc.exception)) def test_coupling_map_options(self): """Check that coupling_map is processed correctly for various types""" diff --git a/test/utils.py b/test/utils.py index eb679b52d..98f3b4138 100644 --- a/test/utils.py +++ b/test/utils.py @@ -280,16 +280,18 @@ def deco(func): def get_primitive_inputs(primitive, num_sets=1): + """Return primitive specific inputs.""" circ = QuantumCircuit(2, 2) circ.h(0) circ.cx(0, 1) obs = SparsePauliOp.from_list([("IZ", 1)]) if isinstance(primitive, Estimator): - return {"circuits": [circ]*num_sets, "observables": [obs]*num_sets} + return {"circuits": [circ] * num_sets, "observables": [obs] * num_sets} circ.measure_all() - return {"circuits": [circ]*num_sets} + return {"circuits": [circ] * num_sets} + class MockSession(Session): """Mock for session class""" From 4198fad9b0171f72e8f1f83e7181765c586a3cd4 Mon Sep 17 00:00:00 2001 From: Jessie Yu Date: Fri, 3 Nov 2023 17:17:34 -0400 Subject: [PATCH 11/35] fix merge issues --- qiskit_ibm_runtime/base_primitive.py | 1 - qiskit_ibm_runtime/options/options.py | 35 ++++++++++-- .../options/simulator_options.py | 9 ++-- .../options/transpilation_options.py | 20 +++++-- ...t-resilience-options-7929458af000314f.yaml | 10 ---- test/ibm_test_case.py | 3 -- test/unit/test_estimator.py | 1 - test/unit/test_estimator_options.py | 53 ------------------- test/unit/test_ibm_primitives.py | 9 +++- test/unit/test_options.py | 1 + 10 files changed, 62 insertions(+), 80 deletions(-) delete mode 100644 releasenotes/notes/default-resilience-options-7929458af000314f.yaml diff --git a/qiskit_ibm_runtime/base_primitive.py b/qiskit_ibm_runtime/base_primitive.py index 18d84387e..56b52191b 100644 --- a/qiskit_ibm_runtime/base_primitive.py +++ b/qiskit_ibm_runtime/base_primitive.py @@ -69,7 +69,6 @@ def __init__( self._session: Optional[Session] = None self._service: QiskitRuntimeService = None self._backend: Optional[IBMBackend] = None - self._options: dict = {} # Only used by v1 self._initialize_options(options=options) diff --git a/qiskit_ibm_runtime/options/options.py b/qiskit_ibm_runtime/options/options.py index db168ac42..d09e264e5 100644 --- a/qiskit_ibm_runtime/options/options.py +++ b/qiskit_ibm_runtime/options/options.py @@ -38,9 +38,10 @@ class BaseOptions(ABC, BasePrimitiveOptions): """Base options class.""" - @abstractmethod @staticmethod + @abstractmethod def _get_program_inputs(options: dict) -> dict: + """Convert the input options to program compatible inputs.""" raise NotImplementedError() @staticmethod @@ -71,7 +72,7 @@ def _get_runtime_options(options: dict) -> dict: config=ConfigDict(validate_assignment=True, arbitrary_types_allowed=True, extra="forbid") ) class OptionsV2(BaseOptions): - """Base primitive options. + """Base primitive options, used by v2 primitives. Args: max_execution_time: Maximum execution time in seconds, which is based @@ -99,7 +100,7 @@ class OptionsV2(BaseOptions): @dataclass class Options(BaseOptions): - """Options for the primitives. + """Options for the primitives, used by v1 primitives. Args: optimization_level: How much optimization to perform on the circuits. @@ -258,6 +259,34 @@ def validate_options(options: dict) -> None: ExecutionOptions(**options.get("execution", {})) SimulatorOptions(**options.get("simulator", {})) + @staticmethod + def _remove_none_values(options: dict) -> dict: + """Remove `None` values from the options dictionary.""" + new_options = {} + for key, value in options.items(): + if value is not None: + if isinstance(value, dict): + new_suboptions = {} + for subkey, subvalue in value.items(): + if subvalue is not None: + new_suboptions[subkey] = subvalue + new_options[key] = new_suboptions + else: + new_options[key] = value + + return new_options + + @staticmethod + def _set_default_resilience_options(options: dict) -> dict: + """Set default resilience options for resilience level 2.""" + if options["resilience_level"] == 2: + if not options["resilience"]["noise_factors"]: + options["resilience"]["noise_factors"] = (1, 3, 5) + if not options["resilience"]["extrapolator"]: + options["resilience"]["extrapolator"] = "LinearExtrapolator" + + return options + @staticmethod def _merge_options(old_options: dict, new_options: Optional[dict] = None) -> dict: """Merge current options with the new ones. diff --git a/qiskit_ibm_runtime/options/simulator_options.py b/qiskit_ibm_runtime/options/simulator_options.py index 52e1a47ad..8618e1381 100644 --- a/qiskit_ibm_runtime/options/simulator_options.py +++ b/qiskit_ibm_runtime/options/simulator_options.py @@ -22,6 +22,10 @@ from pydantic.dataclasses import dataclass as pydantic_dataclass from pydantic import ConfigDict +class NoiseModel: + """Fake noise model class.""" + pass + if TYPE_CHECKING: try: import qiskit_aer @@ -29,10 +33,7 @@ NoiseModel = qiskit_aer.noise.NoiseModel except ImportError: - - class NoiseModel: - """Fake noise model class.""" - pass + pass @pydantic_dataclass( diff --git a/qiskit_ibm_runtime/options/transpilation_options.py b/qiskit_ibm_runtime/options/transpilation_options.py index 9bb9d1e38..36cfa5be1 100644 --- a/qiskit_ibm_runtime/options/transpilation_options.py +++ b/qiskit_ibm_runtime/options/transpilation_options.py @@ -19,6 +19,20 @@ from .utils import Unset, UnsetType, skip_unset_validation +LayoutMethodType = Literal[ + "trivial", + "dense", + "noise_adaptive", + "sabre", +] +RoutingMethodType = Literal[ + "basic", + "lookahead", + "stochastic", + "sabre", + "none", +] + @pydantic_dataclass( config=ConfigDict(validate_assignment=True, arbitrary_types_allowed=True, extra="forbid") @@ -45,10 +59,8 @@ class TranspilationOptions: skip_transpilation: bool = False initial_layout: Union[UnsetType, dict, List] = Unset # TODO: Support Layout - layout_method: Union[UnsetType, Literal["trivial", "dense", "noise_adaptive", "sabre"]] = Unset - routing_method: Union[ - UnsetType, Literal["basic", "lookahead", "stochastic", "sabre", "none"] - ] = Unset + layout_method: Union[UnsetType, LayoutMethodType] = Unset + routing_method: Union[UnsetType, RoutingMethodType] = Unset approximation_degree: Union[UnsetType, float] = Unset @field_validator("approximation_degree") diff --git a/releasenotes/notes/default-resilience-options-7929458af000314f.yaml b/releasenotes/notes/default-resilience-options-7929458af000314f.yaml deleted file mode 100644 index 74c9e9dc9..000000000 --- a/releasenotes/notes/default-resilience-options-7929458af000314f.yaml +++ /dev/null @@ -1,10 +0,0 @@ ---- -fixes: - - | - The ``noise_factors`` and ``extrapolator`` options in :class:`qiskit_ibm_runtime.options.ResilienceOptions` - will now default to ``None`` unless ``resilience_level`` is set to 2. - Only options relevant to the resilience level will be set, so when using ``resilience_level`` - 2, ``noise_factors`` will still default to ``(1, 3, 5)`` and ``extrapolator`` will default to - ``LinearExtrapolator``. Additionally, options with a value of ``None`` will no longer be sent to - the server. - diff --git a/test/ibm_test_case.py b/test/ibm_test_case.py index 85d563d46..2878597d4 100644 --- a/test/ibm_test_case.py +++ b/test/ibm_test_case.py @@ -30,9 +30,6 @@ from .templates import RUNTIME_PROGRAM, RUNTIME_PROGRAM_METADATA, PROGRAM_PREFIX -logging.getLogger("qiskit_ibm_runtime").setLevel("DEBUG") - - class IBMTestCase(unittest.TestCase): """Custom TestCase for use with qiskit-ibm-runtime.""" diff --git a/test/unit/test_estimator.py b/test/unit/test_estimator.py index 520484544..e1863d9f2 100644 --- a/test/unit/test_estimator.py +++ b/test/unit/test_estimator.py @@ -43,7 +43,6 @@ def test_unsupported_values_for_estimator_options(self): {"optimization_level": 4, "resilience_level": 2}, ] - with Session( service=FakeRuntimeService(channel="ibm_quantum", token="abc"), backend="common_backend", diff --git a/test/unit/test_estimator_options.py b/test/unit/test_estimator_options.py index 13b4858df..eb3b27005 100644 --- a/test/unit/test_estimator_options.py +++ b/test/unit/test_estimator_options.py @@ -196,56 +196,3 @@ def test_simulator_set_backend(self, fake_backend): } self.assertDictEqual(asdict(options), asdict(expected_options)) - - def test_qctrl_overrides(self): - """Test override of options""" - all_test_options = [ - ( - { - "optimization_level": 2, - "transpilation": {"approximation_degree": 1}, - "resilience_level": 3, - "resilience": { - "noise_factors": (1, 3, 5), - "extrapolator": "Linear", - }, - }, - { - "optimization_level": 3, - "transpilation": {"approximation_degree": 0}, - "resilience_level": 1, - "resilience": { - "noise_factors": None, - "extrapolator": None, - }, - }, - ), - ( - { - "optimization_level": 0, - "transpilation": {"approximation_degree": 1, "skip_transpilation": True}, - "resilience_level": 1, - }, - { - "optimization_level": 3, - "transpilation": {"approximation_degree": 0, "skip_transpilation": False}, - "resilience_level": 1, - }, - ), - ( - { - "optimization_level": 0, - "transpilation": {"skip_transpilation": True}, - "resilience_level": 1, - }, - { - "optimization_level": 3, - "transpilation": {"skip_transpilation": False}, - "resilience_level": 1, - }, - ), - ] - for option, expected_ in all_test_options: - with self.subTest(msg=f"{option}"): - _warn_and_clean_options(option) - self.assertEqual(expected_, option) diff --git a/test/unit/test_ibm_primitives.py b/test/unit/test_ibm_primitives.py index 6e8ab5735..c7cb5ca45 100644 --- a/test/unit/test_ibm_primitives.py +++ b/test/unit/test_ibm_primitives.py @@ -13,6 +13,7 @@ """Tests for primitive classes.""" import sys +import copy import os from unittest.mock import MagicMock, patch from dataclasses import asdict @@ -80,7 +81,9 @@ def test_dict_options(self): for options in options_vars: with self.subTest(primitive=cls, options=options): inst = cls(session=MagicMock(spec=MockSession), options=options) - self.assertTrue(dict_paritally_equal(inst.options.__dict__, options)) + expected = asdict(Options()) + self._update_dict(expected, copy.deepcopy(options)) + self.assertDictEqual(expected, inst.options.__dict__) def test_runtime_options(self): """Test RuntimeOptions specified as primitive options.""" @@ -483,6 +486,10 @@ def test_accept_level_1_options(self): # Make sure the values are equal. inst1_options = inst1.options.__dict__ expected_dict = inst2.options.__dict__ + self.assertTrue( + dict_paritally_equal(inst1_options, expected_dict), + f"inst_options={inst1_options}, options={opts}", + ) # Make sure the structure didn't change. self.assertTrue( dict_keys_equal(inst1_options, expected_dict), diff --git a/test/unit/test_options.py b/test/unit/test_options.py index 31a3b2d8e..6f7113666 100644 --- a/test/unit/test_options.py +++ b/test/unit/test_options.py @@ -130,6 +130,7 @@ def test_init_options_with_dictionary(self): {"resilience": {"noise_factors": (0, 2, 4)}}, {"environment": {"log_level": "ERROR"}}, ] + for opts_dict in options_dicts: with self.subTest(opts_dict=opts_dict): options = asdict(Options(**opts_dict)) From 9c3e359c458279ccd74713bebd55b13a178638bd Mon Sep 17 00:00:00 2001 From: Jessie Yu Date: Fri, 3 Nov 2023 17:27:50 -0400 Subject: [PATCH 12/35] black --- qiskit_ibm_runtime/base_primitive.py | 3 ++- qiskit_ibm_runtime/options/options.py | 6 ++++-- qiskit_ibm_runtime/options/simulator_options.py | 3 +++ 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/qiskit_ibm_runtime/base_primitive.py b/qiskit_ibm_runtime/base_primitive.py index 56b52191b..e68abbeee 100644 --- a/qiskit_ibm_runtime/base_primitive.py +++ b/qiskit_ibm_runtime/base_primitive.py @@ -192,7 +192,8 @@ def set_options(self, **fields: Any) -> None: self._options = merge_options(self._options, fields) else: self.options = self._OPTIONS_CLASS( # pylint: disable=attribute-defined-outside-init - **merge_options(self.options, fields)) + **merge_options(self.options, fields) + ) def _initialize_options(self, options: Optional[Union[Dict, BaseOptions]] = None): """Initialize the options.""" diff --git a/qiskit_ibm_runtime/options/options.py b/qiskit_ibm_runtime/options/options.py index d09e264e5..6dff8e1e2 100644 --- a/qiskit_ibm_runtime/options/options.py +++ b/qiskit_ibm_runtime/options/options.py @@ -251,8 +251,10 @@ def validate_options(options: dict) -> None: if execution_time is not None: if execution_time > Options._MAX_EXECUTION_TIME: raise ValueError( - f"max_execution_time must be below " f"{Options._MAX_EXECUTION_TIME} seconds." - f"max_execution_time must be below " f"{Options._MAX_EXECUTION_TIME} seconds." + f"max_execution_time must be below " + f"{Options._MAX_EXECUTION_TIME} seconds." + f"max_execution_time must be below " + f"{Options._MAX_EXECUTION_TIME} seconds." ) EnvironmentOptions(**options.get("environment", {})) diff --git a/qiskit_ibm_runtime/options/simulator_options.py b/qiskit_ibm_runtime/options/simulator_options.py index 8618e1381..ffd547559 100644 --- a/qiskit_ibm_runtime/options/simulator_options.py +++ b/qiskit_ibm_runtime/options/simulator_options.py @@ -22,10 +22,13 @@ from pydantic.dataclasses import dataclass as pydantic_dataclass from pydantic import ConfigDict + class NoiseModel: """Fake noise model class.""" + pass + if TYPE_CHECKING: try: import qiskit_aer From 4ec1b9e23868f299d58254bb1b418259978ea738 Mon Sep 17 00:00:00 2001 From: Jessie Yu Date: Fri, 3 Nov 2023 18:15:38 -0400 Subject: [PATCH 13/35] fix noise model type --- qiskit_ibm_runtime/base_primitive.py | 4 +- .../options/estimator_options.py | 6 ++- .../options/execution_options.py | 2 +- .../options/simulator_options.py | 54 ++++++++++++------- qiskit_ibm_runtime/options/utils.py | 8 +-- test/unit/test_estimator_options.py | 2 +- 6 files changed, 48 insertions(+), 28 deletions(-) diff --git a/qiskit_ibm_runtime/base_primitive.py b/qiskit_ibm_runtime/base_primitive.py index e68abbeee..fc1139ff4 100644 --- a/qiskit_ibm_runtime/base_primitive.py +++ b/qiskit_ibm_runtime/base_primitive.py @@ -189,7 +189,9 @@ def set_options(self, **fields: Any) -> None: **fields: The fields to update the options """ if self.version == 1: - self._options = merge_options(self._options, fields) + self._options = merge_options( # pylint: disable=attribute-defined-outside-init + self._options, fields + ) else: self.options = self._OPTIONS_CLASS( # pylint: disable=attribute-defined-outside-init **merge_options(self.options, fields) diff --git a/qiskit_ibm_runtime/options/estimator_options.py b/qiskit_ibm_runtime/options/estimator_options.py index b4a717e8d..7bed8225c 100644 --- a/qiskit_ibm_runtime/options/estimator_options.py +++ b/qiskit_ibm_runtime/options/estimator_options.py @@ -136,7 +136,11 @@ def _validate_options(self): # TODO: Server should have different optimization/resilience levels for simulator # TODO: Allow bypasing validation - if self.resilience_level == 3 and self._is_simulator and not self.simulator.coupling_map: + if ( + self.resilience_level == 3 + and self._is_simulator + and isinstance(self.simulator.coupling_map, UnsetType) + ): raise ValueError( "When the backend is a simulator and resilience_level == 3," "a coupling map is required." diff --git a/qiskit_ibm_runtime/options/execution_options.py b/qiskit_ibm_runtime/options/execution_options.py index ab506cc23..bf1210cc1 100644 --- a/qiskit_ibm_runtime/options/execution_options.py +++ b/qiskit_ibm_runtime/options/execution_options.py @@ -54,7 +54,7 @@ class ExecutionOptionsV2: init_qubits: bool = True samples: Union[UnsetType, int] = Unset shots_per_sample: Union[UnsetType, int] = Unset - interleave_samples: bool = False + interleave_samples: Union[UnsetType, bool] = Unset @field_validator("shots", "samples", "shots_per_sample") @classmethod diff --git a/qiskit_ibm_runtime/options/simulator_options.py b/qiskit_ibm_runtime/options/simulator_options.py index ffd547559..311b0a1a1 100644 --- a/qiskit_ibm_runtime/options/simulator_options.py +++ b/qiskit_ibm_runtime/options/simulator_options.py @@ -12,7 +12,7 @@ """Simulator options.""" -from typing import Optional, List, Union, TYPE_CHECKING +from typing import List, Union from qiskit.exceptions import MissingOptionalLibraryError from qiskit.providers import BackendV1, BackendV2 @@ -20,25 +20,17 @@ from qiskit.transpiler import CouplingMap # pylint: disable=unused-import from pydantic.dataclasses import dataclass as pydantic_dataclass -from pydantic import ConfigDict +from pydantic import ConfigDict, field_validator + +from .utils import Unset, UnsetType, skip_unset_validation class NoiseModel: - """Fake noise model class.""" + """Fake noise model class for pydantic.""" pass -if TYPE_CHECKING: - try: - import qiskit_aer - - NoiseModel = qiskit_aer.noise.NoiseModel - - except ImportError: - pass - - @pydantic_dataclass( config=ConfigDict(validate_assignment=True, arbitrary_types_allowed=True, extra="forbid") ) @@ -59,13 +51,33 @@ class SimulatorOptions: e.g: ``[[0, 1], [0, 3], [1, 2], [1, 5], [2, 5], [4, 1], [5, 3]]`` basis_gates: List of basis gate names to unroll to. For example, - ``['u1', 'u2', 'u3', 'cx']``. If ``None``, do not unroll. + ``['u1', 'u2', 'u3', 'cx']``. Unrolling is not done if not set. """ - noise_model: Optional[Union[dict, NoiseModel]] = None - seed_simulator: Optional[int] = None - coupling_map: Optional[Union[List[List[int]], CouplingMap]] = None - basis_gates: Optional[List[str]] = None + noise_model: Union[UnsetType, dict, NoiseModel] = Unset + seed_simulator: Union[UnsetType, int] = Unset + coupling_map: Union[UnsetType, List[List[int]], CouplingMap] = Unset + basis_gates: Union[UnsetType, List[str]] = Unset + + @field_validator("noise_model", mode="plain") + @classmethod + @skip_unset_validation + def _validate_noise_model(cls, model: Union[dict, NoiseModel]) -> Union[dict, NoiseModel]: + if not isinstance(model, dict): + if not optionals.HAS_AER: + raise ValueError( + "'noise_model' can only be a dictionary or qiskit_aer.noise.NoiseModel." + ) + + from qiskit_aer.noise import ( + NoiseModel as AerNoiseModel, + ) # pylint:disable=import-outside-toplevel + + if not isinstance(model, AerNoiseModel): + raise ValueError( + "'noise_model' can only be a dictionary or qiskit_aer.noise.NoiseModel." + ) + return model def set_backend(self, backend: Union[BackendV1, BackendV2]) -> None: """Set backend for simulation. @@ -82,9 +94,11 @@ def set_backend(self, backend: Union[BackendV1, BackendV2]) -> None: "qiskit-aer", "Aer provider", "pip install qiskit-aer" ) - from qiskit_aer.noise import NoiseModel # pylint:disable=import-outside-toplevel + from qiskit_aer.noise import ( + NoiseModel as AerNoiseModel, + ) # pylint:disable=import-outside-toplevel - self.noise_model = NoiseModel.from_backend(backend) + self.noise_model = AerNoiseModel.from_backend(backend) if isinstance(backend, BackendV1): self.coupling_map = backend.configuration().coupling_map diff --git a/qiskit_ibm_runtime/options/utils.py b/qiskit_ibm_runtime/options/utils.py index 744659084..bbdd926e2 100644 --- a/qiskit_ibm_runtime/options/utils.py +++ b/qiskit_ibm_runtime/options/utils.py @@ -16,7 +16,6 @@ import functools import copy from dataclasses import is_dataclass, asdict -from functools import cache from ..ibm_backend import IBMBackend @@ -151,9 +150,10 @@ class UnsetType: def __repr__(self) -> str: return "Unset" - @cache - def __new__(cls) -> "UnsetType": - return super().__new__(cls) + def __new__(cls): + if not hasattr(cls, "_instance"): + cls._instance = super().__new__(cls) + return cls._instance Unset: UnsetType = UnsetType() diff --git a/test/unit/test_estimator_options.py b/test/unit/test_estimator_options.py index eb3b27005..10ca3a9dc 100644 --- a/test/unit/test_estimator_options.py +++ b/test/unit/test_estimator_options.py @@ -23,7 +23,6 @@ from qiskit_ibm_runtime.options.utils import merge_options from qiskit_ibm_runtime.options import EstimatorOptions -from qiskit_ibm_runtime.utils.qctrl import _warn_and_clean_options from ..ibm_test_case import IBMTestCase from ..utils import dict_keys_equal, dict_paritally_equal, flat_dict_partially_equal @@ -157,6 +156,7 @@ def test_coupling_map_options(self): {"noise_factors": [1, 3, 5]}, {"zne_extrapolator": "exponential", "zne_noise_factors": [1]}, {"zne_mitigation": True, "pec_mitigation": True}, + {"simulator": {"noise_model": "foo"}}, ) def test_bad_inputs(self, val): """Test invalid inputs.""" From 3c9261c4bf147ea391e98d0c55ce3395a05238a3 Mon Sep 17 00:00:00 2001 From: Jessie Yu Date: Fri, 3 Nov 2023 18:26:54 -0400 Subject: [PATCH 14/35] lint again --- qiskit_ibm_runtime/options/simulator_options.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/qiskit_ibm_runtime/options/simulator_options.py b/qiskit_ibm_runtime/options/simulator_options.py index 311b0a1a1..5946424f7 100644 --- a/qiskit_ibm_runtime/options/simulator_options.py +++ b/qiskit_ibm_runtime/options/simulator_options.py @@ -69,9 +69,9 @@ def _validate_noise_model(cls, model: Union[dict, NoiseModel]) -> Union[dict, No "'noise_model' can only be a dictionary or qiskit_aer.noise.NoiseModel." ) - from qiskit_aer.noise import ( + from qiskit_aer.noise import ( # pylint:disable=import-outside-toplevel NoiseModel as AerNoiseModel, - ) # pylint:disable=import-outside-toplevel + ) if not isinstance(model, AerNoiseModel): raise ValueError( @@ -94,9 +94,9 @@ def set_backend(self, backend: Union[BackendV1, BackendV2]) -> None: "qiskit-aer", "Aer provider", "pip install qiskit-aer" ) - from qiskit_aer.noise import ( + from qiskit_aer.noise import ( # pylint:disable=import-outside-toplevel NoiseModel as AerNoiseModel, - ) # pylint:disable=import-outside-toplevel + ) self.noise_model = AerNoiseModel.from_backend(backend) From 636919115e452472e5fa50c8117b744df080e472 Mon Sep 17 00:00:00 2001 From: Jessie Yu Date: Mon, 6 Nov 2023 08:22:42 -0500 Subject: [PATCH 15/35] fix header --- qiskit_ibm_runtime/qiskit/primitives/__init__.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/qiskit_ibm_runtime/qiskit/primitives/__init__.py b/qiskit_ibm_runtime/qiskit/primitives/__init__.py index 639cdcbd5..93daccbbb 100644 --- a/qiskit_ibm_runtime/qiskit/primitives/__init__.py +++ b/qiskit_ibm_runtime/qiskit/primitives/__init__.py @@ -1,2 +1,14 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2022. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + from .base_estimator import BaseEstimatorV2 from .base_primitive import BasePrimitiveOptions From 548005a0d297e220b71122e51d0b78534433818f Mon Sep 17 00:00:00 2001 From: Jessie Yu Date: Mon, 6 Nov 2023 11:38:30 -0500 Subject: [PATCH 16/35] fix mypy --- qiskit_ibm_runtime/base_primitive.py | 257 +++++++++++++----- qiskit_ibm_runtime/estimator.py | 44 +-- .../options/estimator_options.py | 14 +- .../options/execution_options.py | 6 +- qiskit_ibm_runtime/options/options.py | 1 + .../options/resilience_options.py | 18 +- .../options/simulator_options.py | 4 +- .../options/transpilation_options.py | 2 +- qiskit_ibm_runtime/options/utils.py | 36 ++- qiskit_ibm_runtime/qiskit/__init__.py | 13 + .../qiskit/primitives/__init__.py | 6 +- .../qiskit/primitives/base_estimator.py | 162 ++++++++++- .../qiskit/primitives/base_primitive.py | 80 +++++- qiskit_ibm_runtime/qiskit/primitives/utils.py | 58 ++++ qiskit_ibm_runtime/sampler.py | 19 +- 15 files changed, 563 insertions(+), 157 deletions(-) create mode 100644 qiskit_ibm_runtime/qiskit/__init__.py create mode 100644 qiskit_ibm_runtime/qiskit/primitives/utils.py diff --git a/qiskit_ibm_runtime/base_primitive.py b/qiskit_ibm_runtime/base_primitive.py index fc1139ff4..fe3e6fb90 100644 --- a/qiskit_ibm_runtime/base_primitive.py +++ b/qiskit_ibm_runtime/base_primitive.py @@ -33,11 +33,11 @@ logger = logging.getLogger(__name__) -class BasePrimitive(ABC): +class BasePrimitiveV2(ABC): """Base class for Qiskit Runtime primitives.""" _OPTIONS_CLASS: type[BaseOptions] = Options - version = 0 + version = 2 def __init__( self, @@ -70,7 +70,16 @@ def __init__( self._service: QiskitRuntimeService = None self._backend: Optional[IBMBackend] = None - self._initialize_options(options=options) + opt_cls = self._OPTIONS_CLASS + if options is None: + self.options = opt_cls() + elif isinstance(options, opt_cls): + self.options = replace(options) + elif isinstance(options, dict): + default_options = opt_cls() + self.options = opt_cls(**merge_options(default_options, options)) + else: + raise ValueError(f"Invalid 'options' type. It can only be a dictionary of {opt_cls}") if isinstance(session, Session): self._session = session @@ -119,31 +128,13 @@ def _run_primitive(self, primitive_inputs: Dict, user_kwargs: Dict) -> RuntimeJo Returns: Submitted job. """ - my_options = self._options if self.version == 1 else self.options - logger.debug("Merging current options %s with %s", my_options, user_kwargs) - combined = merge_options(my_options, user_kwargs) - - if self.version == 1: - if self._backend: - combined = set_default_error_levels( - combined, - self._backend, - self._OPTIONS_CLASS._DEFAULT_OPTIMIZATION_LEVEL, - self._OPTIONS_CLASS._DEFAULT_RESILIENCE_LEVEL, - ) - else: - combined["optimization_level"] = self._OPTIONS_CLASS._DEFAULT_OPTIMIZATION_LEVEL - combined["resilience_level"] = self._OPTIONS_CLASS._DEFAULT_RESILIENCE_LEVEL - - self._validate_options(combined) - combined = self._OPTIONS_CLASS._set_default_resilience_options(combined) - combined = self._OPTIONS_CLASS._remove_none_values(combined) - primitive_inputs.update(self._OPTIONS_CLASS._get_program_inputs(combined)) - runtime_options = self._OPTIONS_CLASS._get_runtime_options(combined) - else: - self._validate_options(combined) - primitive_inputs.update(my_options._get_program_inputs(combined)) - runtime_options = my_options._get_runtime_options(combined) + logger.debug("Merging current options %s with %s", self.options, user_kwargs) + combined = merge_options(self.options, user_kwargs) + + self._validate_options(combined) + + primitive_inputs.update(self._OPTIONS_CLASS._get_program_inputs(combined)) + runtime_options = self._OPTIONS_CLASS._get_runtime_options(combined) if self._backend and combined["transpilation"]["skip_transpilation"]: for circ in primitive_inputs["circuits"]: @@ -188,48 +179,186 @@ def set_options(self, **fields: Any) -> None: Args: **fields: The fields to update the options """ - if self.version == 1: - self._options = merge_options( # pylint: disable=attribute-defined-outside-init - self._options, fields - ) + self.options = self._OPTIONS_CLASS( # pylint: disable=attribute-defined-outside-init + **merge_options(self.options, fields) + ) + + @abstractmethod + def _validate_options(self, options: dict) -> None: + """Validate that program inputs (options) are valid + + Raises: + ValueError: if resilience_level is out of the allowed range. + """ + raise NotImplementedError() + + @classmethod + @abstractmethod + def _program_id(cls) -> str: + """Return the program ID.""" + raise NotImplementedError() + + +class BasePrimitiveV1(ABC): + """Base class for Qiskit Runtime primitives.""" + + version = 0 + + def __init__( + self, + backend: Optional[Union[str, IBMBackend]] = None, + session: Optional[Union[Session, str, IBMBackend]] = None, + options: Optional[Union[Dict, BaseOptions]] = None, + ): + """Initializes the primitive. + + Args: + + backend: Backend to run the primitive. This can be a backend name or an :class:`IBMBackend` + instance. If a name is specified, the default account (e.g. ``QiskitRuntimeService()``) + is used. + + session: Session in which to call the primitive. + + If both ``session`` and ``backend`` are specified, ``session`` takes precedence. + If neither is specified, and the primitive is created inside a + :class:`qiskit_ibm_runtime.Session` context manager, then the session is used. + Otherwise if IBM Cloud channel is used, a default backend is selected. + + options: Primitive options, see :class:`Options` for detailed description. + The ``backend`` keyword is still supported but is deprecated. + + Raises: + ValueError: Invalid arguments are given. + """ + self._session: Optional[Session] = None + self._service: QiskitRuntimeService = None + self._backend: Optional[IBMBackend] = None + + if options is None: + self._options = asdict(Options()) + elif isinstance(options, Options): + self._options = asdict(copy.deepcopy(options)) else: - self.options = self._OPTIONS_CLASS( # pylint: disable=attribute-defined-outside-init - **merge_options(self.options, fields) - ) + options_copy = copy.deepcopy(options) + default_options = asdict(Options()) + self._options = merge_options(default_options, options_copy) - def _initialize_options(self, options: Optional[Union[Dict, BaseOptions]] = None): - """Initialize the options.""" - opt_cls = self._OPTIONS_CLASS + if isinstance(session, Session): + self._session = session + self._service = self._session.service + self._backend = self._service.backend( + name=self._session.backend(), instance=self._session._instance + ) + return + elif session is not None: + raise ValueError("session must be of type Session or None") - logger.debug("Initializing %s with options %s", self.__class__.__name__, options) - - if self.version == 1: - if options is None: - self._options = asdict(opt_cls()) - elif isinstance(options, opt_cls): - self._options = asdict(copy.deepcopy(options)) - elif isinstance(options, dict): - options_copy = copy.deepcopy(options) - default_options = asdict(opt_cls()) - self._options = merge_options(default_options, options_copy) - else: - raise ValueError( - f"Invalid 'options' type. It can only be a dictionary of {opt_cls}" - ) - elif self.version == 2: - if options is None: - self.options = opt_cls() - elif isinstance(options, opt_cls): - self.options = replace(options) - elif isinstance(options, dict): - default_options = opt_cls() - self.options = opt_cls(**merge_options(default_options, options)) - else: + if isinstance(backend, IBMBackend): + self._service = backend.service + self._backend = backend + elif isinstance(backend, str): + self._service = ( + QiskitRuntimeService() + if QiskitRuntimeService.global_service is None + else QiskitRuntimeService.global_service + ) + self._backend = self._service.backend(backend) + elif get_cm_session(): + self._session = get_cm_session() + self._service = self._session.service + self._backend = self._service.backend( + name=self._session.backend(), instance=self._session._instance + ) + else: + self._service = ( + QiskitRuntimeService() + if QiskitRuntimeService.global_service is None + else QiskitRuntimeService.global_service + ) + if self._service.channel != "ibm_cloud": raise ValueError( - f"Invalid 'options' type. It can only be a dictionary of {opt_cls}" + "A backend or session must be specified when not using ibm_cloud channel." ) + + def _run_primitive(self, primitive_inputs: Dict, user_kwargs: Dict) -> RuntimeJob: + """Run the primitive. + + Args: + primitive_inputs: Inputs to pass to the primitive. + user_kwargs: Individual options to overwrite the default primitive options. + + Returns: + Submitted job. + """ + logger.debug("Merging current options %s with %s", self._options, user_kwargs) + combined = Options._merge_options(self._options, user_kwargs) + + if self._backend: + combined = set_default_error_levels( + combined, + self._backend, + Options._DEFAULT_OPTIMIZATION_LEVEL, + Options._DEFAULT_RESILIENCE_LEVEL, + ) else: - raise ValueError(f"Invalid primitive version {self.version}") + combined["optimization_level"] = Options._DEFAULT_OPTIMIZATION_LEVEL + combined["resilience_level"] = Options._DEFAULT_RESILIENCE_LEVEL + + self._validate_options(combined) + + combined = Options._set_default_resilience_options(combined) + combined = Options._remove_none_values(combined) + + primitive_inputs.update(Options._get_program_inputs(combined)) + + if self._backend and combined["transpilation"]["skip_transpilation"]: + for circ in primitive_inputs["circuits"]: + self._backend.check_faulty(circ) + + logger.info("Submitting job using options %s", combined) + + runtime_options = Options._get_runtime_options(combined) + if self._session: + return self._session.run( + program_id=self._program_id(), + inputs=primitive_inputs, + options=runtime_options, + callback=combined.get("environment", {}).get("callback", None), + result_decoder=DEFAULT_DECODERS.get(self._program_id()), + ) + + if self._backend: + runtime_options["backend"] = self._backend.name + if "instance" not in runtime_options: + runtime_options["instance"] = self._backend._instance + + return self._service.run( + program_id=self._program_id(), + options=runtime_options, + inputs=primitive_inputs, + callback=combined.get("environment", {}).get("callback", None), + result_decoder=DEFAULT_DECODERS.get(self._program_id()), + ) + + @property + def session(self) -> Optional[Session]: + """Return session used by this primitive. + + Returns: + Session used by this primitive, or ``None`` if session is not used. + """ + return self._session + + def set_options(self, **fields: Any) -> None: + """Set options values for the sampler. + + Args: + **fields: The fields to update the options + """ + self._options = merge_options( # pylint: disable=attribute-defined-outside-init + self._options, fields + ) @abstractmethod def _validate_options(self, options: dict) -> None: diff --git a/qiskit_ibm_runtime/estimator.py b/qiskit_ibm_runtime/estimator.py index ec672e2bd..dcde848c2 100644 --- a/qiskit_ibm_runtime/estimator.py +++ b/qiskit_ibm_runtime/estimator.py @@ -34,7 +34,7 @@ from .ibm_backend import IBMBackend from .options import Options from .options.estimator_options import EstimatorOptions -from .base_primitive import BasePrimitive +from .base_primitive import BasePrimitiveV1, BasePrimitiveV2 from .utils.qctrl import validate as qctrl_validate from .utils.deprecation import issue_deprecation_msg @@ -67,23 +67,14 @@ """Parameter types that can be bound to a single circuit.""" -class Estimator(BasePrimitive): +class Estimator: """Base class for Qiskit Runtime Estimator.""" _PROGRAM_ID = "estimator" version = 0 - def __init__( - self, - backend: Optional[Union[str, IBMBackend]] = None, - session: Optional[Union[Session, str, IBMBackend]] = None, - options: Optional[Union[Dict, EstimatorOptions]] = None, - ): - """Initializes the Estimator primitive.""" - BasePrimitive.__init__(self, backend=backend, session=session, options=options) - -class EstimatorV2(Estimator, BaseEstimatorV2): +class EstimatorV2(BasePrimitiveV2, Estimator, BaseEstimatorV2): """Class for interacting with Qiskit Runtime Estimator primitive service. Qiskit Runtime Estimator primitive service estimates expectation values of quantum circuits and @@ -160,8 +151,10 @@ def __init__( Raises: NotImplementedError: If "q-ctrl" channel strategy is used. """ + self.options: EstimatorOptions BaseEstimatorV2.__init__(self) - Estimator.__init__(self, backend=backend, session=session, options=options) + Estimator.__init__(self) + BasePrimitiveV2.__init__(self, backend=backend, session=session, options=options) self.options._is_simulator = ( self._backend is not None and self._backend.configuration().simulator is True @@ -202,29 +195,12 @@ def run( # pylint: disable=arguments-differ # To bypass base class merging of options. user_kwargs = {"_user_kwargs": kwargs} - circuits = self._validate_circuits(circuits=circuits) - observables = self._validate_observables(observables=observables) - parameter_values = self._validate_parameter_values( - parameter_values=parameter_values, - default=[()] * len(circuits), - ) - self._cross_validate_circuits_observables(circuits=circuits, observables=observables) - self._cross_validate_circuits_parameter_values( - circuits=circuits, parameter_values=parameter_values - ) - - return self._run( + return super().run( circuits=circuits, observables=observables, parameter_values=parameter_values, **user_kwargs, ) - # return super().run( - # circuits=circuits, - # observables=observables, - # parameter_values=parameter_values, - # **user_kwargs, - # ) def _run( # pylint: disable=arguments-differ self, @@ -355,7 +331,7 @@ def _program_id(cls) -> str: return "estimator" -class EstimatorV1(Estimator, BaseEstimator): +class EstimatorV1(BasePrimitiveV1, Estimator, BaseEstimator): """Class for interacting with Qiskit Runtime Estimator primitive service. Qiskit Runtime Estimator primitive service estimates expectation values of quantum circuits and @@ -401,7 +377,6 @@ class EstimatorV1(Estimator, BaseEstimator): print(psi1_H23.result()) """ - _OPTIONS_CLASS = Options version = 1 def __init__( @@ -432,7 +407,8 @@ def __init__( # qiskit.providers.Options. We largely ignore this _run_options because we use # a nested dictionary to categorize options. BaseEstimator.__init__(self) - Estimator.__init__(self, backend=backend, session=session, options=options) + Estimator.__init__(self) + BasePrimitiveV1.__init__(self, backend=backend, session=session, options=options) def run( # pylint: disable=arguments-differ self, diff --git a/qiskit_ibm_runtime/options/estimator_options.py b/qiskit_ibm_runtime/options/estimator_options.py index 7bed8225c..1ce408efc 100644 --- a/qiskit_ibm_runtime/options/estimator_options.py +++ b/qiskit_ibm_runtime/options/estimator_options.py @@ -109,7 +109,7 @@ class EstimatorOptions(OptionsV2): @field_validator("optimization_level") @classmethod @skip_unset_validation - def _validate_optimization_level(cls, optimization_level: Union[UnsetType, int]): + def _validate_optimization_level(cls, optimization_level: int) -> int: """Validate optimization_leve.""" if not 0 <= optimization_level <= 3: raise ValueError( @@ -121,7 +121,7 @@ def _validate_optimization_level(cls, optimization_level: Union[UnsetType, int]) @field_validator("resilience_level") @classmethod @skip_unset_validation - def _validate_resilience_level(cls, resilience_level: Union[UnsetType, int]): + def _validate_resilience_level(cls, resilience_level: int) -> int: """Validate resilience_level.""" if not 0 <= resilience_level <= 3: raise ValueError( @@ -131,7 +131,7 @@ def _validate_resilience_level(cls, resilience_level: Union[UnsetType, int]): return resilience_level @model_validator(mode="after") - def _validate_options(self): + def _validate_options(self) -> "EstimatorOptions": """Validate the model.""" # TODO: Server should have different optimization/resilience levels for simulator # TODO: Allow bypasing validation @@ -139,7 +139,7 @@ def _validate_options(self): if ( self.resilience_level == 3 and self._is_simulator - and isinstance(self.simulator.coupling_map, UnsetType) + and isinstance(self.simulator.coupling_map, UnsetType) # type: ignore[union-attr] ): raise ValueError( "When the backend is a simulator and resilience_level == 3," @@ -177,11 +177,11 @@ def _get_program_inputs(options: dict) -> dict: inputs["twirling"] = options.get("twirling", {}) - inputs["execution"] = options.get("execution") + inputs["execution"] = options.get("execution", {}) inputs["execution"].update( { - "noise_model": sim_options.get("noise_model", None), - "seed_simulator": sim_options.get("seed_simulator", None), + "noise_model": sim_options.get("noise_model", Unset), + "seed_simulator": sim_options.get("seed_simulator", Unset), } ) diff --git a/qiskit_ibm_runtime/options/execution_options.py b/qiskit_ibm_runtime/options/execution_options.py index bf1210cc1..b490d7640 100644 --- a/qiskit_ibm_runtime/options/execution_options.py +++ b/qiskit_ibm_runtime/options/execution_options.py @@ -59,21 +59,21 @@ class ExecutionOptionsV2: @field_validator("shots", "samples", "shots_per_sample") @classmethod @skip_unset_validation - def _validate_positive_integer(cls, fld: Union[UnsetType, int], info: ValidationInfo): + def _validate_positive_integer(cls, fld: int, info: ValidationInfo) -> int: """Validate zne_stderr_threshold.""" if fld < 1: raise ValueError(f"{info.field_name} must be >= 1") return fld @model_validator(mode="after") - def _validate_options(self): + def _validate_options(self) -> "ExecutionOptionsV2": """Validate the model.""" if ( all( not isinstance(fld, UnsetType) for fld in [self.shots, self.samples, self.shots_per_sample] ) - and self.shots != self.samples * self.shots_per_sample + and self.shots != self.samples * self.shots_per_sample # type: ignore[operator] ): raise ValueError( f"Shots ({self.shots}) != " diff --git a/qiskit_ibm_runtime/options/options.py b/qiskit_ibm_runtime/options/options.py index 6dff8e1e2..2a65d7574 100644 --- a/qiskit_ibm_runtime/options/options.py +++ b/qiskit_ibm_runtime/options/options.py @@ -229,6 +229,7 @@ def _get_program_inputs(options: dict) -> dict: if key not in known_keys: warnings.warn(f"Key '{key}' is an unrecognized option. It may be ignored.") inputs[key] = options[key] + _remove_dict_unset_values(inputs) return inputs @staticmethod diff --git a/qiskit_ibm_runtime/options/resilience_options.py b/qiskit_ibm_runtime/options/resilience_options.py index 6653c72bf..28d02a83f 100644 --- a/qiskit_ibm_runtime/options/resilience_options.py +++ b/qiskit_ibm_runtime/options/resilience_options.py @@ -105,7 +105,7 @@ class ResilienceOptionsV2: @field_validator("zne_noise_factors") @classmethod @skip_unset_validation - def _validate_zne_noise_factors(cls, factors: Union[UnsetType, Sequence[float]]): + def _validate_zne_noise_factors(cls, factors: Sequence[float]) -> Sequence[float]: """Validate zne_noise_factors.""" if any(i < 1 for i in factors): raise ValueError("zne_noise_factors` option value must all be >= 1") @@ -114,7 +114,7 @@ def _validate_zne_noise_factors(cls, factors: Union[UnsetType, Sequence[float]]) @field_validator("zne_stderr_threshold") @classmethod @skip_unset_validation - def _validate_zne_stderr_threshold(cls, threshold: Union[UnsetType, float]): + def _validate_zne_stderr_threshold(cls, threshold: float) -> float: """Validate zne_stderr_threshold.""" if threshold <= 0: raise ValueError("Invalid zne_stderr_threshold option value must be > 0") @@ -123,14 +123,14 @@ def _validate_zne_stderr_threshold(cls, threshold: Union[UnsetType, float]): @field_validator("pec_max_overhead") @classmethod @skip_unset_validation - def _validate_pec_max_overhead(cls, overhead: Union[UnsetType, float]): + def _validate_pec_max_overhead(cls, overhead: float) -> float: """Validate pec_max_overhead.""" if overhead < 1: raise ValueError("pec_max_overhead must be None or >= 1") return overhead @model_validator(mode="after") - def _validate_options(self): + def _validate_options(self) -> "ResilienceOptionsV2": """Validate the model.""" # Validate ZNE noise factors + extrapolator combination if all( @@ -146,13 +146,13 @@ def _validate_options(self): "polynomial_degree_3": 4, "polynomial_degree_4": 5, } - extrapolators = ( - [self.zne_extrapolator] + extrapolators: Sequence = ( + [self.zne_extrapolator] # type: ignore[assignment] if isinstance(self.zne_extrapolator, str) else self.zne_extrapolator ) for extrap in extrapolators: - if len(self.zne_noise_factors) < required_factors[extrap]: + if len(self.zne_noise_factors) < required_factors[extrap]: # type: ignore[arg-type] raise ValueError( f"{extrap} requires at least {required_factors[extrap]} zne_noise_factors" ) @@ -212,7 +212,7 @@ class ResilienceOptionsV1: extrapolator: Optional[ExtrapolatorType] = None @model_validator(mode="after") - def _validate_options(self): + def _validate_options(self) -> "ResilienceOptionsV1": """Validate the model.""" required_factors = { "QuarticExtrapolator": 5, @@ -221,3 +221,5 @@ def _validate_options(self): req_len = required_factors.get(self.extrapolator, None) if req_len and len(self.noise_factors) < req_len: raise ValueError(f"{self.extrapolator} requires at least {req_len} noise_factors.") + + return self diff --git a/qiskit_ibm_runtime/options/simulator_options.py b/qiskit_ibm_runtime/options/simulator_options.py index 5946424f7..c0dd75d83 100644 --- a/qiskit_ibm_runtime/options/simulator_options.py +++ b/qiskit_ibm_runtime/options/simulator_options.py @@ -12,7 +12,7 @@ """Simulator options.""" -from typing import List, Union +from typing import List, Union, Optional from qiskit.exceptions import MissingOptionalLibraryError from qiskit.providers import BackendV1, BackendV2 @@ -54,7 +54,7 @@ class SimulatorOptions: ``['u1', 'u2', 'u3', 'cx']``. Unrolling is not done if not set. """ - noise_model: Union[UnsetType, dict, NoiseModel] = Unset + noise_model: Optional[Union[UnsetType, dict, NoiseModel]] = Unset seed_simulator: Union[UnsetType, int] = Unset coupling_map: Union[UnsetType, List[List[int]], CouplingMap] = Unset basis_gates: Union[UnsetType, List[str]] = Unset diff --git a/qiskit_ibm_runtime/options/transpilation_options.py b/qiskit_ibm_runtime/options/transpilation_options.py index 36cfa5be1..5ae25f161 100644 --- a/qiskit_ibm_runtime/options/transpilation_options.py +++ b/qiskit_ibm_runtime/options/transpilation_options.py @@ -66,7 +66,7 @@ class TranspilationOptions: @field_validator("approximation_degree") @classmethod @skip_unset_validation - def _validate_approximation_degree(cls, degree: Union[UnsetType, float]): + def _validate_approximation_degree(cls, degree: float) -> float: """Validate approximation_degree.""" if not 0.0 <= degree <= 1.0: raise ValueError( diff --git a/qiskit_ibm_runtime/options/utils.py b/qiskit_ibm_runtime/options/utils.py index bbdd926e2..df44ad37e 100644 --- a/qiskit_ibm_runtime/options/utils.py +++ b/qiskit_ibm_runtime/options/utils.py @@ -12,13 +12,16 @@ """Utility functions for options.""" -from typing import Optional, Union, Callable +from typing import Optional, Union, Callable, TYPE_CHECKING, Any import functools import copy from dataclasses import is_dataclass, asdict from ..ibm_backend import IBMBackend +if TYPE_CHECKING: + from ..options import BaseOptions + def set_default_error_levels( options: dict, @@ -38,19 +41,17 @@ def set_default_error_levels( options with correct error level defaults. """ if options.get("optimization_level") is None: - if ( - backend.configuration().simulator - and options.get("simulator", {}).get("noise_model") is None - ): + if backend.configuration().simulator and options.get("simulator", {}).get( + "noise_model" + ) in [None, Unset]: options["optimization_level"] = 1 else: options["optimization_level"] = default_optimization_level if options.get("resilience_level") is None: - if ( - backend.configuration().simulator - and options.get("simulator", {}).get("noise_model") is None - ): + if backend.configuration().simulator and options.get("simulator", {}).get( + "noise_model" + ) in [None, Unset]: options["resilience_level"] = 0 else: options["resilience_level"] = default_resilience_level @@ -88,6 +89,9 @@ def merge_options( Returns: Merged dictionary. + + Raises: + TypeError: if input type is invalid. """ def _update_options(old: dict, new: dict, matched: Optional[dict] = None) -> None: @@ -108,7 +112,13 @@ def _update_options(old: dict, new: dict, matched: Optional[dict] = None) -> Non for key, val in matched.items(): old[key] = val - combined = asdict(old_options) if is_dataclass(old_options) else copy.deepcopy(old_options) + if is_dataclass(old_options): + combined = asdict(old_options) + elif isinstance(old_options, dict): + combined = copy.deepcopy(old_options) + else: + raise TypeError("'old_options' can only be a dictionary or dataclass.") + if not new_options: return combined new_options_copy = copy.deepcopy(new_options) @@ -126,7 +136,7 @@ def skip_unset_validation(func: Callable) -> Callable: """Decorator used to skip unset value""" @functools.wraps(func) - def wrapper(cls, val, *args, **kwargs) -> Callable: + def wrapper(cls: Any, val: Any, *args: Any, **kwargs: Any) -> Any: if isinstance(val, UnsetType): return val return func(cls, val, *args, **kwargs) @@ -150,10 +160,10 @@ class UnsetType: def __repr__(self) -> str: return "Unset" - def __new__(cls): + def __new__(cls) -> "UnsetType": if not hasattr(cls, "_instance"): cls._instance = super().__new__(cls) return cls._instance -Unset: UnsetType = UnsetType() +Unset = UnsetType() diff --git a/qiskit_ibm_runtime/qiskit/__init__.py b/qiskit_ibm_runtime/qiskit/__init__.py new file mode 100644 index 000000000..d0229ff1b --- /dev/null +++ b/qiskit_ibm_runtime/qiskit/__init__.py @@ -0,0 +1,13 @@ +# 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. + +"""Temporary copy of base primitives""" diff --git a/qiskit_ibm_runtime/qiskit/primitives/__init__.py b/qiskit_ibm_runtime/qiskit/primitives/__init__.py index 93daccbbb..20006ab61 100644 --- a/qiskit_ibm_runtime/qiskit/primitives/__init__.py +++ b/qiskit_ibm_runtime/qiskit/primitives/__init__.py @@ -10,5 +10,7 @@ # copyright notice, and modified files need to carry a notice indicating # that they have been altered from the originals. -from .base_estimator import BaseEstimatorV2 -from .base_primitive import BasePrimitiveOptions +"""Temporary copy of base primitives""" + +from .base_estimator import BaseEstimatorV2 # type: ignore +from .base_primitive import BasePrimitiveOptions # type: ignore diff --git a/qiskit_ibm_runtime/qiskit/primitives/base_estimator.py b/qiskit_ibm_runtime/qiskit/primitives/base_estimator.py index d8f07576e..c04c023f5 100644 --- a/qiskit_ibm_runtime/qiskit/primitives/base_estimator.py +++ b/qiskit_ibm_runtime/qiskit/primitives/base_estimator.py @@ -9,6 +9,7 @@ # 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. +# type: ignore r""" @@ -80,15 +81,168 @@ from __future__ import annotations +from abc import abstractmethod +from collections.abc import Sequence from typing import Generic, TypeVar +import typing +from qiskit.circuit import QuantumCircuit +from qiskit.circuit.parametertable import ParameterView from qiskit.providers import JobV1 as Job +from qiskit.quantum_info.operators import SparsePauliOp +from qiskit.quantum_info.operators.base_operator import BaseOperator -from .base_primitive import BasePrimitiveV2 +from .utils import init_observable +from .base_primitive import BasePrimitiveV2, BasePrimitiveOptions -T = TypeVar("T", bound=Job) +if typing.TYPE_CHECKING: + from qiskit.opflow import PauliSumOp +T = TypeVar("T", bound=Job) # pylint: disable=invalid-name -class BaseEstimatorV2(BasePrimitiveV2, Generic[T]): - version = 2 +class BaseEstimatorV2(BasePrimitiveV2, Generic[T]): + """Estimator base class. + + Base class for Estimator that estimates expectation values of quantum circuits and observables. + """ + + __hash__ = None + + def __init__( + self, + *, + options: dict | BasePrimitiveOptions | None = None, + ): + """ + Creating an instance of an Estimator, or using one in a ``with`` context opens a session that + holds resources until the instance is ``close()`` ed or the context is exited. + + Args: + options: Default options. + """ + self._circuits = [] + self._observables = [] + self._parameters = [] + super().__init__(options) + + def run( # pylint: disable=differing-param-doc + self, + circuits: Sequence[QuantumCircuit] | QuantumCircuit, + observables: Sequence[BaseOperator | PauliSumOp | str] | BaseOperator | PauliSumOp | str, + parameter_values: Sequence[Sequence[float]] | Sequence[float] | float | None = None, + **run_options, + ) -> T: + """Run the job of the estimation of expectation value(s). + + ``circuits``, ``observables``, and ``parameter_values`` should have the same + length. The i-th element of the result is the expectation of observable + + .. code-block:: python + + obs = observables[i] + + for the state prepared by + + .. code-block:: python + + circ = circuits[i] + + with bound parameters + + .. code-block:: python + + values = parameter_values[i]. + + Args: + circuits: one or more circuit objects. + observables: one or more observable objects. Several formats are allowed; + importantly, ``str`` should follow the string representation format for + :class:`~qiskit.quantum_info.Pauli` objects. + parameter_values: concrete parameters to be bound. + run_options: runtime options used for circuit execution. + + Returns: + The job object of EstimatorResult. + + Raises: + TypeError: Invalid argument type given. + ValueError: Invalid argument values given. + """ + # Singular validation + circuits = self._validate_circuits(circuits) + observables = self._validate_observables(observables) + parameter_values = self._validate_parameter_values( + parameter_values, + default=[()] * len(circuits), + ) + + # Cross-validation + self._cross_validate_circuits_parameter_values(circuits, parameter_values) + self._cross_validate_circuits_observables(circuits, observables) + + return self._run(circuits, observables, parameter_values, **run_options) + + @abstractmethod + def _run( + self, + circuits: tuple[QuantumCircuit, ...], + observables: tuple[SparsePauliOp, ...], + parameter_values: tuple[tuple[float, ...], ...], + **run_options, + ) -> T: + raise NotImplementedError("The subclass of BaseEstimator must implment `_run` method.") + + @staticmethod + def _validate_observables( + observables: Sequence[BaseOperator | PauliSumOp | str] | BaseOperator | PauliSumOp | str, + ) -> tuple[SparsePauliOp, ...]: + if isinstance(observables, str) or not isinstance(observables, Sequence): + observables = (observables,) + if len(observables) == 0: + raise ValueError("No observables were provided.") + return tuple(init_observable(obs) for obs in observables) + + @staticmethod + def _cross_validate_circuits_observables( + circuits: tuple[QuantumCircuit, ...], observables: tuple[BaseOperator | PauliSumOp, ...] + ) -> None: + if len(circuits) != len(observables): + raise ValueError( + f"The number of circuits ({len(circuits)}) does not match " + f"the number of observables ({len(observables)})." + ) + for i, (circuit, observable) in enumerate(zip(circuits, observables)): + if circuit.num_qubits != observable.num_qubits: + raise ValueError( + f"The number of qubits of the {i}-th circuit ({circuit.num_qubits}) does " + f"not match the number of qubits of the {i}-th observable " + f"({observable.num_qubits})." + ) + + @property + def circuits(self) -> tuple[QuantumCircuit, ...]: + """Quantum circuits that represents quantum states. + + Returns: + The quantum circuits. + """ + return tuple(self._circuits) + + @property + def observables(self) -> tuple[SparsePauliOp, ...]: + """Observables to be estimated. + + Returns: + The observables. + """ + return tuple(self._observables) + + @property + def parameters(self) -> tuple[ParameterView, ...]: + """Parameters of the quantum circuits. + + Returns: + Parameters, where ``parameters[i][j]`` is the j-th parameter of the i-th circuit. + """ + return tuple(self._parameters) diff --git a/qiskit_ibm_runtime/qiskit/primitives/base_primitive.py b/qiskit_ibm_runtime/qiskit/primitives/base_primitive.py index 7a6ec2fa4..63b64b7ef 100644 --- a/qiskit_ibm_runtime/qiskit/primitives/base_primitive.py +++ b/qiskit_ibm_runtime/qiskit/primitives/base_primitive.py @@ -9,6 +9,7 @@ # 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. +# type: ignore """Primitive abstract base class.""" @@ -18,26 +19,29 @@ from dataclasses import dataclass, replace from collections.abc import Sequence +import numpy as np + from qiskit.circuit import QuantumCircuit @dataclass class BasePrimitiveOptions: - def __call__(self): + """Base primitive options.""" + + def __call__(self) -> "BasePrimitiveOptions": return replace(self) class BasePrimitiveV2(ABC): - """Version 2 of primitive abstract base class.""" + """Primitive abstract base class.""" version = 2 - def __init__(self, options: dict | None | BasePrimitiveOptions = None): - + def __init__(self, options: dict | BasePrimitiveOptions | None = None): pass @abstractmethod - def set_options(self, **fields): + def set_options(self, **fields) -> None: """Set options values for the estimator. Args: @@ -60,3 +64,69 @@ def _validate_circuits( if len(circuits) == 0: raise ValueError("No circuits were provided.") return circuits + + @staticmethod + def _validate_parameter_values( + parameter_values: Sequence[Sequence[float]] | Sequence[float] | float | None, + default: Sequence[Sequence[float]] | Sequence[float] | None = None, + ) -> tuple[tuple[float, ...], ...]: + # Allow optional (if default) + if parameter_values is None: + if default is None: + raise ValueError("No default `parameter_values`, optional input disallowed.") + parameter_values = default + + # Support numpy ndarray + if isinstance(parameter_values, np.ndarray): + parameter_values = parameter_values.tolist() + elif isinstance(parameter_values, Sequence): + parameter_values = tuple( + vector.tolist() if isinstance(vector, np.ndarray) else vector + for vector in parameter_values + ) + + # Allow single value + if _isreal(parameter_values): + parameter_values = ((parameter_values,),) + elif isinstance(parameter_values, Sequence) and not any( + isinstance(vector, Sequence) for vector in parameter_values + ): + parameter_values = (parameter_values,) + + # Validation + if ( + not isinstance(parameter_values, Sequence) + or not all(isinstance(vector, Sequence) for vector in parameter_values) + or not all(all(_isreal(value) for value in vector) for vector in parameter_values) + ): + raise TypeError("Invalid parameter values, expected Sequence[Sequence[float]].") + + return tuple(tuple(float(value) for value in vector) for vector in parameter_values) + + @staticmethod + def _cross_validate_circuits_parameter_values( + circuits: tuple[QuantumCircuit, ...], parameter_values: tuple[tuple[float, ...], ...] + ) -> None: + if len(circuits) != len(parameter_values): + raise ValueError( + f"The number of circuits ({len(circuits)}) does not match " + f"the number of parameter value sets ({len(parameter_values)})." + ) + for i, (circuit, vector) in enumerate(zip(circuits, parameter_values)): + if len(vector) != circuit.num_parameters: + raise ValueError( + f"The number of values ({len(vector)}) does not match " + f"the number of parameters ({circuit.num_parameters}) for the {i}-th circuit." + ) + + +def _isint(obj: Sequence[Sequence[float]] | Sequence[float] | float) -> bool: + """Check if object is int.""" + int_types = (int, np.integer) + return isinstance(obj, int_types) and not isinstance(obj, bool) + + +def _isreal(obj: Sequence[Sequence[float]] | Sequence[float] | float) -> bool: + """Check if object is a real number: int or float except ``±Inf`` and ``NaN``.""" + float_types = (float, np.floating) + return _isint(obj) or isinstance(obj, float_types) and float("-Inf") < obj < float("Inf") diff --git a/qiskit_ibm_runtime/qiskit/primitives/utils.py b/qiskit_ibm_runtime/qiskit/primitives/utils.py new file mode 100644 index 000000000..a8406d7cf --- /dev/null +++ b/qiskit_ibm_runtime/qiskit/primitives/utils.py @@ -0,0 +1,58 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2022. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. +""" +Utility functions for primitives +""" +from __future__ import annotations + +import sys + +from qiskit.circuit import ParameterExpression +from qiskit.quantum_info import SparsePauliOp +from qiskit.quantum_info.operators.base_operator import BaseOperator +from qiskit.quantum_info.operators.symplectic.base_pauli import BasePauli + + +def init_observable(observable: BaseOperator | str) -> SparsePauliOp: + """Initialize observable by converting the input to a :class:`~qiskit.quantum_info.SparsePauliOp`. + + Args: + observable: The observable. + + Returns: + The observable as :class:`~qiskit.quantum_info.SparsePauliOp`. + + Raises: + TypeError: If the observable is a :class:`~qiskit.opflow.PauliSumOp` and has a parameterized + coefficient. + """ + # This dance is to avoid importing the deprecated `qiskit.opflow` if the user hasn't already + # done so. They can't hold a `qiskit.opflow.PauliSumOp` if `qiskit.opflow` hasn't been + # imported, and we don't want unrelated Qiskit library code to be responsible for the first + # import, so the deprecation warnings will show. + if "qiskit.opflow" in sys.modules: + pauli_sum_check = sys.modules["qiskit.opflow"].PauliSumOp + else: + pauli_sum_check = () + + if isinstance(observable, SparsePauliOp): + return observable + elif isinstance(observable, pauli_sum_check): + if isinstance(observable.coeff, ParameterExpression): + raise TypeError( + f"Observable must have numerical coefficient, not {type(observable.coeff)}." + ) + return observable.coeff * observable.primitive + elif isinstance(observable, BaseOperator) and not isinstance(observable, BasePauli): + return SparsePauliOp.from_operator(observable) + else: + return SparsePauliOp(observable) diff --git a/qiskit_ibm_runtime/sampler.py b/qiskit_ibm_runtime/sampler.py index 68a9f2ab4..9e289e8db 100644 --- a/qiskit_ibm_runtime/sampler.py +++ b/qiskit_ibm_runtime/sampler.py @@ -24,7 +24,7 @@ from .options import Options from .runtime_job import RuntimeJob from .ibm_backend import IBMBackend -from .base_primitive import BasePrimitive +from .base_primitive import BasePrimitiveV1 # pylint: disable=unused-import,cyclic-import from .session import Session @@ -33,23 +33,13 @@ logger = logging.getLogger(__name__) -class Sampler(BasePrimitive): +class Sampler: """Base type for Sampelr.""" version = 0 - _OPTIONS_CLASS = Options - - def __init__( - self, - backend: Optional[Union[str, IBMBackend]] = None, - session: Optional[Union[Session, str, IBMBackend]] = None, - options: Optional[Union[Dict, Options]] = None, - ): - """Initializes the Sampler primitive.""" - BasePrimitive.__init__(self, backend=backend, session=session, options=options) -class SamplerV1(Sampler, BaseSampler): +class SamplerV1(BasePrimitiveV1, Sampler, BaseSampler): """Class for interacting with Qiskit Runtime Sampler primitive service. Qiskit Runtime Sampler primitive service calculates quasi-probability distribution @@ -111,7 +101,8 @@ def __init__( # qiskit.providers.Options. We largely ignore this _run_options because we use # a nested dictionary to categorize options. BaseSampler.__init__(self) - Sampler.__init__(self, backend=backend, session=session, options=options) + Sampler.__init__(self) + BasePrimitiveV1.__init__(self, backend=backend, session=session, options=options) def run( # pylint: disable=arguments-differ self, From d05ce59518438ad313c226a47d4e89e56fc76cff Mon Sep 17 00:00:00 2001 From: Jessie Yu Date: Mon, 6 Nov 2023 11:58:28 -0500 Subject: [PATCH 17/35] use v2 as default --- qiskit_ibm_runtime/__init__.py | 6 +++--- qiskit_ibm_runtime/qiskit/primitives/base_primitive.py | 1 + 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/qiskit_ibm_runtime/__init__.py b/qiskit_ibm_runtime/__init__.py index 1cee9a8b4..f386dec52 100644 --- a/qiskit_ibm_runtime/__init__.py +++ b/qiskit_ibm_runtime/__init__.py @@ -160,8 +160,7 @@ def result_callback(job_id, result): :toctree: ../stubs/ QiskitRuntimeService - Estimator - EstimatorV2 + EstimatorV1 Sampler Session IBMBackend @@ -188,7 +187,8 @@ def result_callback(job_id, result): from .utils.utils import setup_logger from .version import __version__ -from .estimator import EstimatorV1 as Estimator, EstimatorV2 +# TODO: Make v1 the default when merging to production +from .estimator import EstimatorV2 as Estimator, EstimatorV1 from .sampler import SamplerV1 as Sampler from .options import Options, EstimatorOptions diff --git a/qiskit_ibm_runtime/qiskit/primitives/base_primitive.py b/qiskit_ibm_runtime/qiskit/primitives/base_primitive.py index 63b64b7ef..4b26f24dd 100644 --- a/qiskit_ibm_runtime/qiskit/primitives/base_primitive.py +++ b/qiskit_ibm_runtime/qiskit/primitives/base_primitive.py @@ -29,6 +29,7 @@ class BasePrimitiveOptions: """Base primitive options.""" def __call__(self) -> "BasePrimitiveOptions": + """Return a copy of the options.""" return replace(self) From 6745067eed5bac590d62be129d27b71b0ba9e189 Mon Sep 17 00:00:00 2001 From: Jessie Yu Date: Mon, 6 Nov 2023 12:36:39 -0500 Subject: [PATCH 18/35] cleanup terra options --- qiskit_ibm_runtime/__init__.py | 3 ++- qiskit_ibm_runtime/base_primitive.py | 16 +++++++++++++++- qiskit_ibm_runtime/estimator.py | 10 ---------- qiskit_ibm_runtime/sampler.py | 10 ---------- 4 files changed, 17 insertions(+), 22 deletions(-) diff --git a/qiskit_ibm_runtime/__init__.py b/qiskit_ibm_runtime/__init__.py index f386dec52..afc5f2bb2 100644 --- a/qiskit_ibm_runtime/__init__.py +++ b/qiskit_ibm_runtime/__init__.py @@ -187,8 +187,9 @@ def result_callback(job_id, result): from .utils.utils import setup_logger from .version import __version__ +from .estimator import EstimatorV2, EstimatorV1 # TODO: Make v1 the default when merging to production -from .estimator import EstimatorV2 as Estimator, EstimatorV1 +from .estimator import EstimatorV2 as Estimator # pylint: disable=reimported from .sampler import SamplerV1 as Sampler from .options import Options, EstimatorOptions diff --git a/qiskit_ibm_runtime/base_primitive.py b/qiskit_ibm_runtime/base_primitive.py index fe3e6fb90..79fe037f2 100644 --- a/qiskit_ibm_runtime/base_primitive.py +++ b/qiskit_ibm_runtime/base_primitive.py @@ -19,6 +19,8 @@ import logging from dataclasses import asdict, replace +from qiskit.providers.options import Options as TerraOptions + from .options import BaseOptions, Options from .options.utils import merge_options, set_default_error_levels from .runtime_job import RuntimeJob @@ -208,7 +210,7 @@ def __init__( self, backend: Optional[Union[str, IBMBackend]] = None, session: Optional[Union[Session, str, IBMBackend]] = None, - options: Optional[Union[Dict, BaseOptions]] = None, + options: Optional[Union[Dict, Options]] = None, ): """Initializes the primitive. @@ -231,6 +233,10 @@ def __init__( Raises: ValueError: Invalid arguments are given. """ + # `self._options` in this class is a Dict. + # The base class, however, uses a `_run_options` which is an instance of + # qiskit.providers.Options. We largely ignore this _run_options because we use + # a nested dictionary to categorize options. self._session: Optional[Session] = None self._service: QiskitRuntimeService = None self._backend: Optional[IBMBackend] = None @@ -350,6 +356,14 @@ def session(self) -> Optional[Session]: """ return self._session + @property + def options(self) -> TerraOptions: + """Return options values for the sampler. + Returns: + options + """ + return TerraOptions(**self._options) + def set_options(self, **fields: Any) -> None: """Set options values for the sampler. diff --git a/qiskit_ibm_runtime/estimator.py b/qiskit_ibm_runtime/estimator.py index dcde848c2..14fe6c04a 100644 --- a/qiskit_ibm_runtime/estimator.py +++ b/qiskit_ibm_runtime/estimator.py @@ -28,7 +28,6 @@ from qiskit.primitives.utils import init_observable from qiskit.circuit import Parameter from qiskit.primitives.base.base_primitive import _isreal -from qiskit.providers.options import Options as TerraOptions from .runtime_job import RuntimeJob from .ibm_backend import IBMBackend @@ -514,15 +513,6 @@ def _validate_options(self, options: dict) -> None: ) Options.validate_options(options) - @property - def options(self) -> TerraOptions: - """Return options values for the sampler. - - Returns: - options - """ - return TerraOptions(**self._options) - @classmethod def _program_id(cls) -> str: """Return the program ID.""" diff --git a/qiskit_ibm_runtime/sampler.py b/qiskit_ibm_runtime/sampler.py index 9e289e8db..caa881096 100644 --- a/qiskit_ibm_runtime/sampler.py +++ b/qiskit_ibm_runtime/sampler.py @@ -19,7 +19,6 @@ from qiskit.circuit import QuantumCircuit from qiskit.primitives import BaseSampler -from qiskit.providers.options import Options as TerraOptions from .options import Options from .runtime_job import RuntimeJob @@ -182,15 +181,6 @@ def _validate_options(self, options: dict) -> None: ) Options.validate_options(options) - @property - def options(self) -> TerraOptions: - """Return options values for the sampler. - - Returns: - options - """ - return TerraOptions(**self._options) - @classmethod def _program_id(cls) -> str: """Return the program ID.""" From 7370b5d67b5b593f394c21d3ad9d3c785693791f Mon Sep 17 00:00:00 2001 From: Jessie Yu Date: Mon, 6 Nov 2023 12:37:04 -0500 Subject: [PATCH 19/35] black --- qiskit_ibm_runtime/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/qiskit_ibm_runtime/__init__.py b/qiskit_ibm_runtime/__init__.py index afc5f2bb2..14a9b78ab 100644 --- a/qiskit_ibm_runtime/__init__.py +++ b/qiskit_ibm_runtime/__init__.py @@ -188,6 +188,7 @@ def result_callback(job_id, result): from .version import __version__ from .estimator import EstimatorV2, EstimatorV1 + # TODO: Make v1 the default when merging to production from .estimator import EstimatorV2 as Estimator # pylint: disable=reimported from .sampler import SamplerV1 as Sampler From a19ccd4a54be7459b381acc7a8b5bb42e0d42c6c Mon Sep 17 00:00:00 2001 From: Jessie Yu Date: Mon, 6 Nov 2023 16:11:56 -0500 Subject: [PATCH 20/35] options need not be callable --- qiskit_ibm_runtime/__init__.py | 5 +---- qiskit_ibm_runtime/qiskit/primitives/base_primitive.py | 6 ++---- test/unit/test_ibm_primitives_v2.py | 10 +++++----- 3 files changed, 8 insertions(+), 13 deletions(-) diff --git a/qiskit_ibm_runtime/__init__.py b/qiskit_ibm_runtime/__init__.py index 14a9b78ab..66aa1c1de 100644 --- a/qiskit_ibm_runtime/__init__.py +++ b/qiskit_ibm_runtime/__init__.py @@ -187,10 +187,7 @@ def result_callback(job_id, result): from .utils.utils import setup_logger from .version import __version__ -from .estimator import EstimatorV2, EstimatorV1 - -# TODO: Make v1 the default when merging to production -from .estimator import EstimatorV2 as Estimator # pylint: disable=reimported +from .estimator import EstimatorV2, EstimatorV1 as Estimator from .sampler import SamplerV1 as Sampler from .options import Options, EstimatorOptions diff --git a/qiskit_ibm_runtime/qiskit/primitives/base_primitive.py b/qiskit_ibm_runtime/qiskit/primitives/base_primitive.py index 4b26f24dd..b18e6d2ce 100644 --- a/qiskit_ibm_runtime/qiskit/primitives/base_primitive.py +++ b/qiskit_ibm_runtime/qiskit/primitives/base_primitive.py @@ -16,7 +16,7 @@ from __future__ import annotations from abc import ABC, abstractmethod -from dataclasses import dataclass, replace +from dataclasses import dataclass from collections.abc import Sequence import numpy as np @@ -28,9 +28,7 @@ class BasePrimitiveOptions: """Base primitive options.""" - def __call__(self) -> "BasePrimitiveOptions": - """Return a copy of the options.""" - return replace(self) + pass class BasePrimitiveV2(ABC): diff --git a/test/unit/test_ibm_primitives_v2.py b/test/unit/test_ibm_primitives_v2.py index 3d35c6e65..3573b55cf 100644 --- a/test/unit/test_ibm_primitives_v2.py +++ b/test/unit/test_ibm_primitives_v2.py @@ -75,7 +75,7 @@ def test_dict_options(self, primitive): ] for options in options_vars: inst = primitive(session=MagicMock(spec=MockSession), options=options) - self.assertTrue(dict_paritally_equal(asdict(inst.options()), options)) + self.assertTrue(dict_paritally_equal(asdict(inst.options), options)) @combine( primitive=[EstimatorV2], @@ -288,7 +288,7 @@ def test_run_overwrite_options(self, primitive): inst.run(self.qx, observables=self.obs, **options) inputs = session.run.call_args.kwargs["inputs"] self._assert_dict_partially_equal(inputs, expected) - self.assertDictEqual(asdict(inst.options()), asdict(opt_cls())) + self.assertDictEqual(asdict(inst.options), asdict(opt_cls())) @data(EstimatorV2) def test_run_overwrite_runtime_options(self, primitive): @@ -328,7 +328,7 @@ def test_run_multiple_different_options(self, primitive): kwargs_list = session.run.call_args_list for idx, shots in zip([0, 1], [100, 200]): self.assertEqual(kwargs_list[idx][1]["inputs"]["execution"]["shots"], shots) - self.assertDictEqual(asdict(inst.options()), asdict(opt_cls())) + self.assertDictEqual(asdict(inst.options), asdict(opt_cls())) def test_run_same_session(self): """Test multiple runs within a session.""" @@ -357,7 +357,7 @@ def test_set_options(self, primitive): inst = primitive(session=session, options=options) inst.set_options(**new_opt) # Make sure the values are equal. - inst_options = asdict(inst.options()) + inst_options = asdict(inst.options) self.assertTrue( flat_dict_partially_equal(inst_options, new_opt), f"inst_options={inst_options}, new_opt={new_opt}", @@ -395,7 +395,7 @@ def test_accept_level_1_options(self, primitive): with self.subTest(options=opts): inst1 = primitive(session=session, options=opts) inst2 = primitive(session=session, options=expected) - self.assertEqual(inst1.options(), inst2.options()) + self.assertEqual(inst1.options, inst2.options) # # Make sure the values are equal. # inst1_options = inst1.options.__dict__ # expected_dict = inst2.options.__dict__ From 52febc958a464e9bca8c5a14336f4c68a311957b Mon Sep 17 00:00:00 2001 From: Jessie Yu Date: Mon, 6 Nov 2023 16:15:29 -0500 Subject: [PATCH 21/35] fix doc --- qiskit_ibm_runtime/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qiskit_ibm_runtime/__init__.py b/qiskit_ibm_runtime/__init__.py index 66aa1c1de..5b11a0070 100644 --- a/qiskit_ibm_runtime/__init__.py +++ b/qiskit_ibm_runtime/__init__.py @@ -160,7 +160,7 @@ def result_callback(job_id, result): :toctree: ../stubs/ QiskitRuntimeService - EstimatorV1 + Estimator Sampler Session IBMBackend From b29a4e56d4942d4f5b0c8d7da3912036b8a39b21 Mon Sep 17 00:00:00 2001 From: Jessie Yu Date: Mon, 6 Nov 2023 16:45:47 -0500 Subject: [PATCH 22/35] fix tests --- test/unit/test_data_serialization.py | 19 ++----------------- 1 file changed, 2 insertions(+), 17 deletions(-) diff --git a/test/unit/test_data_serialization.py b/test/unit/test_data_serialization.py index 604b78cb1..5d768daaf 100644 --- a/test/unit/test_data_serialization.py +++ b/test/unit/test_data_serialization.py @@ -17,6 +17,7 @@ import subprocess import tempfile import warnings +from unittest import skip from datetime import datetime import numpy as np @@ -94,18 +95,6 @@ def test_coder_qc(self): def test_coder_operators(self): """Test runtime encoder and decoder for operators.""" - # filter warnings triggered by opflow imports - with warnings.catch_warnings(): - warnings.filterwarnings( - "ignore", category=DeprecationWarning, module=r"qiskit\.opflow\." - ) - from qiskit.opflow import PauliSumOp # pylint: disable=import-outside-toplevel - - # catch warnings triggered by opflow use - with warnings.catch_warnings(record=True) as w_log: - deprecated_op = PauliSumOp(SparsePauliOp(Pauli("XYZX"), coeffs=[2])) - self.assertTrue(len(w_log) > 0) - coeff_x = Parameter("x") coeff_y = coeff_x + 1 @@ -113,7 +102,6 @@ def test_coder_operators(self): SparsePauliOp(Pauli("XYZX"), coeffs=[2]), SparsePauliOp(Pauli("XYZX"), coeffs=[coeff_y]), SparsePauliOp(Pauli("XYZX"), coeffs=[1 + 2j]), - deprecated_op, ) for operator in subtests: @@ -122,11 +110,7 @@ def test_coder_operators(self): self.assertIsInstance(encoded, str) with warnings.catch_warnings(): - # filter warnings triggered by opflow imports # in L146 of utils/json.py - warnings.filterwarnings( - "ignore", category=DeprecationWarning, module=r"qiskit\.opflow\." - ) warnings.filterwarnings( "ignore", category=DeprecationWarning, @@ -172,6 +156,7 @@ def test_encoder_ndarray(self): decoded = json.loads(encoded, cls=RuntimeDecoder) self.assertTrue(np.array_equal(decoded["ndarray"], obj["ndarray"])) + @skip("Skip until qiskit-ibm-provider/736 is merged") def test_encoder_instruction(self): """Test encoding and decoding instructions""" subtests = ( From 1f8bd7c31f9b20f142aac025e867fe559c4ded67 Mon Sep 17 00:00:00 2001 From: Jessie Yu Date: Mon, 6 Nov 2023 16:56:09 -0500 Subject: [PATCH 23/35] fix version --- qiskit_ibm_runtime/base_primitive.py | 2 +- qiskit_ibm_runtime/estimator.py | 2 -- qiskit_ibm_runtime/options/estimator_options.py | 1 - qiskit_ibm_runtime/sampler.py | 2 -- test/unit/test_data_serialization.py | 1 + 5 files changed, 2 insertions(+), 6 deletions(-) diff --git a/qiskit_ibm_runtime/base_primitive.py b/qiskit_ibm_runtime/base_primitive.py index 79fe037f2..e293eb952 100644 --- a/qiskit_ibm_runtime/base_primitive.py +++ b/qiskit_ibm_runtime/base_primitive.py @@ -204,7 +204,7 @@ def _program_id(cls) -> str: class BasePrimitiveV1(ABC): """Base class for Qiskit Runtime primitives.""" - version = 0 + version = 1 def __init__( self, diff --git a/qiskit_ibm_runtime/estimator.py b/qiskit_ibm_runtime/estimator.py index 14fe6c04a..76c1cf604 100644 --- a/qiskit_ibm_runtime/estimator.py +++ b/qiskit_ibm_runtime/estimator.py @@ -376,8 +376,6 @@ class EstimatorV1(BasePrimitiveV1, Estimator, BaseEstimator): print(psi1_H23.result()) """ - version = 1 - def __init__( self, backend: Optional[Union[str, IBMBackend]] = None, diff --git a/qiskit_ibm_runtime/options/estimator_options.py b/qiskit_ibm_runtime/options/estimator_options.py index 1ce408efc..022348426 100644 --- a/qiskit_ibm_runtime/options/estimator_options.py +++ b/qiskit_ibm_runtime/options/estimator_options.py @@ -134,7 +134,6 @@ def _validate_resilience_level(cls, resilience_level: int) -> int: def _validate_options(self) -> "EstimatorOptions": """Validate the model.""" # TODO: Server should have different optimization/resilience levels for simulator - # TODO: Allow bypasing validation if ( self.resilience_level == 3 diff --git a/qiskit_ibm_runtime/sampler.py b/qiskit_ibm_runtime/sampler.py index caa881096..be093a20b 100644 --- a/qiskit_ibm_runtime/sampler.py +++ b/qiskit_ibm_runtime/sampler.py @@ -70,8 +70,6 @@ class SamplerV1(BasePrimitiveV1, Sampler, BaseSampler): _OPTIONS_CLASS = Options - version = 1 - def __init__( self, backend: Optional[Union[str, IBMBackend]] = None, diff --git a/test/unit/test_data_serialization.py b/test/unit/test_data_serialization.py index 5d768daaf..292d168b3 100644 --- a/test/unit/test_data_serialization.py +++ b/test/unit/test_data_serialization.py @@ -92,6 +92,7 @@ def test_coder_qc(self): decoded = [decoded] self.assertTrue(all(isinstance(item, QuantumCircuit) for item in decoded)) + @skip("Skip until qiskit-ibm-provider/736 is merged") def test_coder_operators(self): """Test runtime encoder and decoder for operators.""" From 027e6ca56f79b04eca17fded757ff80be5291d5e Mon Sep 17 00:00:00 2001 From: Jessie Yu Date: Mon, 6 Nov 2023 21:14:09 -0500 Subject: [PATCH 24/35] add sampler option --- qiskit_ibm_runtime/__init__.py | 4 +- qiskit_ibm_runtime/estimator.py | 1 - qiskit_ibm_runtime/options/__init__.py | 2 + .../options/estimator_options.py | 55 +---- qiskit_ibm_runtime/options/options.py | 59 ++++- .../options/resilience_options.py | 5 +- qiskit_ibm_runtime/options/sampler_options.py | 94 ++++++++ qiskit_ibm_runtime/options/utils.py | 11 +- .../qiskit/primitives/__init__.py | 1 + .../qiskit/primitives/base_sampler.py | 170 +++++++++++++ qiskit_ibm_runtime/sampler.py | 162 ++++++++++++- test/unit/test_estimator_options.py | 198 ---------------- test/unit/test_ibm_primitives_v2.py | 98 ++++---- test/unit/test_options.py | 223 +++++++++++++++++- test/unit/test_sampler.py | 59 ++++- 15 files changed, 822 insertions(+), 320 deletions(-) create mode 100644 qiskit_ibm_runtime/options/sampler_options.py create mode 100644 qiskit_ibm_runtime/qiskit/primitives/base_sampler.py delete mode 100644 test/unit/test_estimator_options.py diff --git a/qiskit_ibm_runtime/__init__.py b/qiskit_ibm_runtime/__init__.py index 5b11a0070..a963f9768 100644 --- a/qiskit_ibm_runtime/__init__.py +++ b/qiskit_ibm_runtime/__init__.py @@ -188,8 +188,8 @@ def result_callback(job_id, result): from .version import __version__ from .estimator import EstimatorV2, EstimatorV1 as Estimator -from .sampler import SamplerV1 as Sampler -from .options import Options, EstimatorOptions +from .sampler import SamplerV2, SamplerV1 as Sampler +from .options import Options, EstimatorOptions, SamplerOptions # Setup the logger for the IBM Quantum Provider package. logger = logging.getLogger(__name__) diff --git a/qiskit_ibm_runtime/estimator.py b/qiskit_ibm_runtime/estimator.py index 76c1cf604..d60348f19 100644 --- a/qiskit_ibm_runtime/estimator.py +++ b/qiskit_ibm_runtime/estimator.py @@ -69,7 +69,6 @@ class Estimator: """Base class for Qiskit Runtime Estimator.""" - _PROGRAM_ID = "estimator" version = 0 diff --git a/qiskit_ibm_runtime/options/__init__.py b/qiskit_ibm_runtime/options/__init__.py index 9a7a013f6..2e8dcae6a 100644 --- a/qiskit_ibm_runtime/options/__init__.py +++ b/qiskit_ibm_runtime/options/__init__.py @@ -51,6 +51,7 @@ SimulatorOptions TwirlingOptions EstimatorOptions + SamplerOptions """ @@ -62,3 +63,4 @@ from .resilience_options import ResilienceOptionsV1 as ResilienceOptions from .twirling_options import TwirlingOptions from .estimator_options import EstimatorOptions +from .sampler_options import SamplerOptions \ No newline at end of file diff --git a/qiskit_ibm_runtime/options/estimator_options.py b/qiskit_ibm_runtime/options/estimator_options.py index 022348426..2f4c7d2b5 100644 --- a/qiskit_ibm_runtime/options/estimator_options.py +++ b/qiskit_ibm_runtime/options/estimator_options.py @@ -13,9 +13,7 @@ """Primitive options.""" from typing import Union, Literal -import copy -from qiskit.transpiler import CouplingMap from pydantic.dataclasses import dataclass as pydantic_dataclass from pydantic import Field, ConfigDict, model_validator, field_validator @@ -23,8 +21,6 @@ Dict, Unset, UnsetType, - _remove_dict_unset_values, - merge_options, skip_unset_validation, ) from .execution_options import ExecutionOptionsV2 @@ -80,7 +76,7 @@ class EstimatorOptions(OptionsV2): resilience: Advanced resilience options to fine tune the resilience strategy. See :class:`ResilienceOptions` for all available options. - execution: Execution time options. See :class:`ExecutionOptions` for all available options. + execution: Execution time options. See :class:`ExecutionOptionsV2` for all available options. environment: Options related to the execution environment. See :class:`EnvironmentOptions` for all available options. @@ -123,7 +119,7 @@ def _validate_optimization_level(cls, optimization_level: int) -> int: @skip_unset_validation def _validate_resilience_level(cls, resilience_level: int) -> int: """Validate resilience_level.""" - if not 0 <= resilience_level <= 3: + if not 0 <= resilience_level <= EstimatorOptions._MAX_RESILIENCE_LEVEL: raise ValueError( "Invalid optimization_level. Valid range is " f"0-{EstimatorOptions._MAX_RESILIENCE_LEVEL}" @@ -147,53 +143,6 @@ def _validate_options(self) -> "EstimatorOptions": return self - @staticmethod - def _get_program_inputs(options: dict) -> dict: - """Convert the input options to program compatible inputs. - - Returns: - Inputs acceptable by primitives. - """ - - sim_options = options.get("simulator", {}) - inputs = {} - inputs["transpilation"] = copy.copy(options.get("transpilation", {})) - inputs["skip_transpilation"] = inputs["transpilation"].pop("skip_transpilation") - coupling_map = sim_options.get("coupling_map", None) - # TODO: We can just move this to json encoder - if isinstance(coupling_map, CouplingMap): - coupling_map = list(map(list, coupling_map.get_edges())) - inputs["transpilation"].update( - { - "optimization_level": options.get("optimization_level"), - "coupling_map": coupling_map, - "basis_gates": sim_options.get("basis_gates", None), - } - ) - - inputs["resilience_level"] = options.get("resilience_level") - inputs["resilience"] = options.get("resilience", {}) - - inputs["twirling"] = options.get("twirling", {}) - - inputs["execution"] = options.get("execution", {}) - inputs["execution"].update( - { - "noise_model": sim_options.get("noise_model", Unset), - "seed_simulator": sim_options.get("seed_simulator", Unset), - } - ) - - # Add arbitrary experimental options - if isinstance(options.get("experimental", None), dict): - inputs = merge_options(inputs, options.get("experimental")) - - inputs["_experimental"] = True - inputs["version"] = EstimatorOptions._version - _remove_dict_unset_values(inputs) - - return inputs - # @dataclass(frozen=True) # class _ResilienceLevel0Options: diff --git a/qiskit_ibm_runtime/options/options.py b/qiskit_ibm_runtime/options/options.py index 2a65d7574..602439c3c 100644 --- a/qiskit_ibm_runtime/options/options.py +++ b/qiskit_ibm_runtime/options/options.py @@ -22,7 +22,7 @@ from pydantic.dataclasses import dataclass as pydantic_dataclass from pydantic import Field, ConfigDict -from .utils import Dict, _to_obj, UnsetType, Unset, _remove_dict_unset_values +from .utils import Dict, _to_obj, UnsetType, Unset, _remove_dict_unset_values, merge_options from .environment_options import EnvironmentOptions from .execution_options import ExecutionOptionsV1 as ExecutionOptions from .simulator_options import SimulatorOptions @@ -93,10 +93,67 @@ class OptionsV2(BaseOptions): :class:`SimulatorOptions` for all available options. """ + _version: int = 2 + + # Options not really related to primitives. max_execution_time: Union[UnsetType, int] = Unset environment: Union[EnvironmentOptions, Dict] = Field(default_factory=EnvironmentOptions) simulator: Union[SimulatorOptions, Dict] = Field(default_factory=SimulatorOptions) + @staticmethod + def _get_program_inputs(options: dict) -> dict: + """Convert the input options to program compatible inputs. + + Returns: + Inputs acceptable by primitives. + """ + + def _set_if_exists(name: str, _inputs: dict, _options: dict) -> None: + if name in _options: + _inputs[name] = _options[name] + + sim_options = options.get("simulator", {}) + inputs = {} + inputs["transpilation"] = copy.copy(options.get("transpilation", {})) + inputs["skip_transpilation"] = inputs["transpilation"].pop("skip_transpilation") + coupling_map = sim_options.get("coupling_map", None) + # TODO: We can just move this to json encoder + if isinstance(coupling_map, CouplingMap): + coupling_map = list(map(list, coupling_map.get_edges())) + inputs["transpilation"].update( + { + "optimization_level": options.get("optimization_level"), + "coupling_map": coupling_map, + "basis_gates": sim_options.get("basis_gates", None), + } + ) + + for fld in ["resilience_level", "resilience", "twirling", "dynamical_decoupling"]: + _set_if_exists(fld, inputs, options) + + inputs["execution"] = options.get("execution", {}) + inputs["execution"].update( + { + "noise_model": sim_options.get("noise_model", Unset), + "seed_simulator": sim_options.get("seed_simulator", Unset), + } + ) + + # Add arbitrary experimental options + if isinstance(options.get("experimental", None), dict): + inputs = merge_options(inputs, options.get("experimental")) + + inputs["_experimental"] = True + inputs["version"] = OptionsV2._version + _remove_dict_unset_values(inputs) + + # Remove empty dictionaries + for key, val in list(inputs.items()): + if isinstance(val, dict) and not val: + del inputs[key] + + return inputs + @dataclass class Options(BaseOptions): diff --git a/qiskit_ibm_runtime/options/resilience_options.py b/qiskit_ibm_runtime/options/resilience_options.py index 28d02a83f..5237631f5 100644 --- a/qiskit_ibm_runtime/options/resilience_options.py +++ b/qiskit_ibm_runtime/options/resilience_options.py @@ -92,10 +92,7 @@ class ResilienceOptionsV2: # ZNE zne_mitigation: Union[UnsetType, bool] = Unset zne_noise_factors: Union[UnsetType, Sequence[float]] = Unset - zne_extrapolator: Union[UnsetType, ZneExtrapolatorType, Sequence[ZneExtrapolatorType]] = ( - "exponential", - "linear", - ) + zne_extrapolator: Union[UnsetType, ZneExtrapolatorType, Sequence[ZneExtrapolatorType]] = Unset zne_stderr_threshold: Union[UnsetType, float] = Unset # PEC diff --git a/qiskit_ibm_runtime/options/sampler_options.py b/qiskit_ibm_runtime/options/sampler_options.py new file mode 100644 index 000000000..83a26d264 --- /dev/null +++ b/qiskit_ibm_runtime/options/sampler_options.py @@ -0,0 +1,94 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2022. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +"""Primitive options.""" + +from typing import Union, Literal + +from pydantic.dataclasses import dataclass as pydantic_dataclass +from pydantic import Field, ConfigDict, field_validator + +from .utils import ( + Dict, + Unset, + UnsetType, + skip_unset_validation, +) +from .execution_options import ExecutionOptionsV2 +from .transpilation_options import TranspilationOptions +from .twirling_options import TwirlingOptions +from .options import OptionsV2 + +DDSequenceType = Literal["XX", "XpXm", "XY4"] + + +@pydantic_dataclass( + config=ConfigDict(validate_assignment=True, arbitrary_types_allowed=True, extra="forbid") +) +class SamplerOptions(OptionsV2): + """Options for v2 Sampler. + + Args: + optimization_level: How much optimization to perform on the circuits. + Higher levels generate more optimized circuits, + at the expense of longer transpilation times. This is based on the + ``optimization_level`` parameter in qiskit-terra but may include + backend-specific optimization. Default: 1. + + * 0: no optimization + * 1: light optimization + * 2: heavy optimization + * 3: even heavier optimization + + dynamical_decoupling: Optional, specify a dynamical decoupling sequence to use. + Allowed values are ``"XX"``, ``"XpXm"``, ``"XY4"``. + Default: None + + transpilation: Transpilation options. See :class:`TranspilationOptions` for all + available options. + + execution: Execution time options. See :class:`ExecutionOptionsV2` for all available options. + + twirling: Pauli-twirling related options. See :class:`TwirlingOptions` for all available options. + + environment: Options related to the execution environment. See + :class:`EnvironmentOptions` for all available options. + + simulator: Simulator options. See + :class:`SimulatorOptions` for all available options. + + """ + + _version: int = 2 + _is_simulator: bool = False + + _MAX_OPTIMIZATION_LEVEL = 3 + + # Sadly we cannot use pydantic's built in validation because it won't work on Unset. + optimization_level: Union[UnsetType, int] = Unset + dynamical_decoupling: Union[UnsetType, DDSequenceType] = Unset + transpilation: Union[TranspilationOptions, Dict] = Field(default_factory=TranspilationOptions) + execution: Union[ExecutionOptionsV2, Dict] = Field(default_factory=ExecutionOptionsV2) + twirling: Union[TwirlingOptions, Dict] = Field(default_factory=TwirlingOptions) + experimental: Union[UnsetType, dict] = Unset + + @field_validator("optimization_level") + @classmethod + @skip_unset_validation + def _validate_optimization_level(cls, optimization_level: int) -> int: + """Validate optimization_leve.""" + if not 0 <= optimization_level <= SamplerOptions._MAX_OPTIMIZATION_LEVEL: + raise ValueError( + "Invalid optimization_level. Valid range is " + f"0-{SamplerOptions._MAX_OPTIMIZATION_LEVEL}" + ) + return optimization_level diff --git a/qiskit_ibm_runtime/options/utils.py b/qiskit_ibm_runtime/options/utils.py index df44ad37e..31cd13257 100644 --- a/qiskit_ibm_runtime/options/utils.py +++ b/qiskit_ibm_runtime/options/utils.py @@ -41,17 +41,13 @@ def set_default_error_levels( options with correct error level defaults. """ if options.get("optimization_level") is None: - if backend.configuration().simulator and options.get("simulator", {}).get( - "noise_model" - ) in [None, Unset]: + if backend.configuration().simulator and not options.get("simulator", {}).get("noise_model"): options["optimization_level"] = 1 else: options["optimization_level"] = default_optimization_level if options.get("resilience_level") is None: - if backend.configuration().simulator and options.get("simulator", {}).get( - "noise_model" - ) in [None, Unset]: + if backend.configuration().simulator and not options.get("simulator", {}).get("noise_model"): options["resilience_level"] = 0 else: options["resilience_level"] = default_resilience_level @@ -165,5 +161,8 @@ def __new__(cls) -> "UnsetType": cls._instance = super().__new__(cls) return cls._instance + def __bool__(self) -> bool: + return False + Unset = UnsetType() diff --git a/qiskit_ibm_runtime/qiskit/primitives/__init__.py b/qiskit_ibm_runtime/qiskit/primitives/__init__.py index 20006ab61..5f21b57b9 100644 --- a/qiskit_ibm_runtime/qiskit/primitives/__init__.py +++ b/qiskit_ibm_runtime/qiskit/primitives/__init__.py @@ -14,3 +14,4 @@ from .base_estimator import BaseEstimatorV2 # type: ignore from .base_primitive import BasePrimitiveOptions # type: ignore +from .base_sampler import BaseSamplerV2 # type: ignore \ No newline at end of file diff --git a/qiskit_ibm_runtime/qiskit/primitives/base_sampler.py b/qiskit_ibm_runtime/qiskit/primitives/base_sampler.py new file mode 100644 index 000000000..db322aea7 --- /dev/null +++ b/qiskit_ibm_runtime/qiskit/primitives/base_sampler.py @@ -0,0 +1,170 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2022. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. +# type: ignore + +r""" +=================== +Overview of Sampler +=================== + +Sampler class calculates probabilities or quasi-probabilities of bitstrings from quantum circuits. + +A sampler is initialized with an empty parameter set. The sampler is used to +create a :class:`~qiskit.providers.JobV1`, via the :meth:`qiskit.primitives.Sampler.run()` +method. This method is called with the following parameters + +* quantum circuits (:math:`\psi_i(\theta)`): list of (parameterized) quantum circuits. + (a list of :class:`~qiskit.circuit.QuantumCircuit` objects) + +* parameter values (:math:`\theta_k`): list of sets of parameter values + to be bound to the parameters of the quantum circuits. + (list of list of float) + +The method returns a :class:`~qiskit.providers.JobV1` object, calling +:meth:`qiskit.providers.JobV1.result()` yields a :class:`~qiskit.primitives.SamplerResult` +object, which contains probabilities or quasi-probabilities of bitstrings, +plus optional metadata like error bars in the samples. + +Here is an example of how sampler is used. + +.. code-block:: python + + from qiskit.primitives import Sampler + from qiskit import QuantumCircuit + from qiskit.circuit.library import RealAmplitudes + + # a Bell circuit + bell = QuantumCircuit(2) + bell.h(0) + bell.cx(0, 1) + bell.measure_all() + + # two parameterized circuits + pqc = RealAmplitudes(num_qubits=2, reps=2) + pqc.measure_all() + pqc2 = RealAmplitudes(num_qubits=2, reps=3) + pqc2.measure_all() + + theta1 = [0, 1, 1, 2, 3, 5] + theta2 = [0, 1, 2, 3, 4, 5, 6, 7] + + # initialization of the sampler + sampler = Sampler() + + # Sampler runs a job on the Bell circuit + job = sampler.run(circuits=[bell], parameter_values=[[]], parameters=[[]]) + job_result = job.result() + print([q.binary_probabilities() for q in job_result.quasi_dists]) + + # Sampler runs a job on the parameterized circuits + job2 = sampler.run( + circuits=[pqc, pqc2], + parameter_values=[theta1, theta2], + parameters=[pqc.parameters, pqc2.parameters]) + job_result = job2.result() + print([q.binary_probabilities() for q in job_result.quasi_dists]) +""" + +from __future__ import annotations + +from abc import abstractmethod +from collections.abc import Sequence +from typing import Generic, TypeVar + +from qiskit.circuit import QuantumCircuit +from qiskit.circuit.parametertable import ParameterView +from qiskit.providers import JobV1 as Job + +from .base_primitive import BasePrimitiveV2, BasePrimitiveOptions + +T = TypeVar("T", bound=Job) # pylint: disable=invalid-name + + +class BaseSamplerV2(BasePrimitiveV2, Generic[T]): + """Sampler base class + + Base class of Sampler that calculates quasi-probabilities of bitstrings from quantum circuits. + """ + + __hash__ = None + + def __init__( + self, + *, + options: dict | BasePrimitiveOptions | None = None, + ): + """ + Args: + options: Default options. + """ + self._circuits = [] + self._parameters = [] + super().__init__(options) + + def run( # pylint: disable=differing-param-doc + self, + circuits: Sequence[QuantumCircuit] | QuantumCircuit, + parameter_values: Sequence[Sequence[float]] | Sequence[float] | float | None = None, + **run_options, + ) -> T: + """Run the job of the sampling of bitstrings. + + Args: + circuits: One of more circuit objects. + parameter_values: Parameters to be bound to the circuit. + run_options: Backend runtime options used for circuit execution. + + Returns: + The job object of the result of the sampler. The i-th result corresponds to + ``circuits[i]`` evaluated with parameters bound as ``parameter_values[i]``. + + Raises: + ValueError: Invalid arguments are given. + """ + # Singular validation + circuits = self._validate_circuits(circuits) + parameter_values = self._validate_parameter_values( + parameter_values, + default=[()] * len(circuits), + ) + + # Cross-validation + self._cross_validate_circuits_parameter_values(circuits, parameter_values) + + return self._run(circuits, parameter_values, **run_options) + + @abstractmethod + def _run( + self, + circuits: tuple[QuantumCircuit, ...], + parameter_values: tuple[tuple[float, ...], ...], + **run_options, + ) -> T: + raise NotImplementedError("The subclass of BaseEstimator must implment `_run` method.") + + @property + def circuits(self) -> tuple[QuantumCircuit, ...]: + """Quantum circuits that represents quantum states. + + Returns: + The quantum circuits. + """ + return tuple(self._circuits) + + @property + def parameters(self) -> tuple[ParameterView, ...]: + """Parameters of the quantum circuits. + + Returns: + Parameters, where ``parameters[i][j]`` is the j-th parameter of the i-th circuit. + """ + return tuple(self._parameters) diff --git a/qiskit_ibm_runtime/sampler.py b/qiskit_ibm_runtime/sampler.py index be093a20b..e8962b0bf 100644 --- a/qiskit_ibm_runtime/sampler.py +++ b/qiskit_ibm_runtime/sampler.py @@ -23,11 +23,15 @@ from .options import Options from .runtime_job import RuntimeJob from .ibm_backend import IBMBackend -from .base_primitive import BasePrimitiveV1 +from .base_primitive import BasePrimitiveV1, BasePrimitiveV2 # pylint: disable=unused-import,cyclic-import from .session import Session from .utils.qctrl import validate as qctrl_validate +from .options import SamplerOptions + +# TODO: remove when we have real v2 base estimator +from .qiskit.primitives import BaseSamplerV2 logger = logging.getLogger(__name__) @@ -38,6 +42,162 @@ class Sampler: version = 0 +class SamplerV2(BasePrimitiveV2, Sampler, BaseSamplerV2): + """Class for interacting with Qiskit Runtime Sampler primitive service. + + Qiskit Runtime Sampler primitive service calculates quasi-probability distribution + of bitstrings from quantum circuits. + + The :meth:`run` method can be used to submit circuits and parameters to the Sampler primitive. + + You are encouraged to use :class:`~qiskit_ibm_runtime.Session` to open a session, + during which you can invoke one or more primitives. Jobs submitted within a session + are prioritized by the scheduler, and data is cached for efficiency. + + Example:: + + from qiskit.test.reference_circuits import ReferenceCircuits + from qiskit_ibm_runtime import QiskitRuntimeService, Session, Sampler + + service = QiskitRuntimeService(channel="ibm_cloud") + bell = ReferenceCircuits.bell() + + with Session(service, backend="ibmq_qasm_simulator") as session: + sampler = Sampler(session=session) + + job = sampler.run(bell, shots=1024) + print(f"Job ID: {job.job_id()}") + print(f"Job result: {job.result()}") + + # You can run more jobs inside the session + """ + + _OPTIONS_CLASS = SamplerOptions + + version = 2 + + def __init__( + self, + backend: Optional[Union[str, IBMBackend]] = None, + session: Optional[Union[Session, str, IBMBackend]] = None, + options: Optional[Union[Dict, SamplerOptions]] = None, + ): + """Initializes the Sampler primitive. + + Args: + backend: Backend to run the primitive. This can be a backend name or an :class:`IBMBackend` + instance. If a name is specified, the default account (e.g. ``QiskitRuntimeService()``) + is used. + + session: Session in which to call the primitive. + + If both ``session`` and ``backend`` are specified, ``session`` takes precedence. + If neither is specified, and the primitive is created inside a + :class:`qiskit_ibm_runtime.Session` context manager, then the session is used. + Otherwise if IBM Cloud channel is used, a default backend is selected. + + options: Primitive options, see :class:`Options` for detailed description. + The ``backend`` keyword is still supported but is deprecated. + + Raises: + NotImplementedError: If "q-ctrl" channel strategy is used. + """ + self.options: SamplerOptions + BaseSamplerV2.__init__(self) + Sampler.__init__(self) + BasePrimitiveV2.__init__(self, backend=backend, session=session, options=options) + + self.options._is_simulator = ( + self._backend is not None and self._backend.configuration().simulator is True + ) + if self._service._channel_strategy == "q-ctrl": + raise NotImplementedError("SamplerV2 is not supported with q-ctrl channel strategy.") + + def run( # pylint: disable=arguments-differ + self, + circuits: QuantumCircuit | Sequence[QuantumCircuit], + parameter_values: Sequence[float] | Sequence[Sequence[float]] | None = None, + **kwargs: Any, + ) -> RuntimeJob: + """Submit a request to the estimator primitive. + + Args: + circuits: a (parameterized) :class:`~qiskit.circuit.QuantumCircuit` or + a list of (parameterized) :class:`~qiskit.circuit.QuantumCircuit`. + + observables: Observable objects. + + parameter_values: Concrete parameters to be bound. + + **kwargs: Individual options to overwrite the default primitive options. + These include the runtime options in :class:`qiskit_ibm_runtime.RuntimeOptions`. + + Returns: + Submitted job. + The result of the job is an instance of :class:`qiskit.primitives.EstimatorResult`. + + Raises: + ValueError: Invalid arguments are given. + """ + # To bypass base class merging of options. + user_kwargs = {"_user_kwargs": kwargs} + + return super().run( + circuits=circuits, + parameter_values=parameter_values, + **user_kwargs, + ) + + def _run( # pylint: disable=arguments-differ + self, + circuits: Sequence[QuantumCircuit], + parameter_values: tuple[tuple[float, ...], ...], + **kwargs: Any, + ) -> RuntimeJob: + """Submit a request to the estimator primitive. + + Args: + circuits: a (parameterized) :class:`~qiskit.circuit.QuantumCircuit` or + a list of (parameterized) :class:`~qiskit.circuit.QuantumCircuit`. + + observables: A list of observable objects. + + parameter_values: An optional list of concrete parameters to be bound. + + **kwargs: Individual options to overwrite the default primitive options. + These include the runtime options in :class:`~qiskit_ibm_runtime.RuntimeOptions`. + + Returns: + Submitted job + """ + logger.debug( + "Running %s with new options %s", + self.__class__.__name__, + kwargs.get("_user_kwargs", {}), + ) + inputs = { + "circuits": circuits, + "parameters": [circ.parameters for circ in circuits], + "parameter_values": parameter_values, + } + return self._run_primitive( + primitive_inputs=inputs, user_kwargs=kwargs.get("_user_kwargs", {}) + ) + + def _validate_options(self, options: dict) -> None: + """Validate that program inputs (options) are valid + + Raises: + ValidationError: if validation fails. + """ + self._OPTIONS_CLASS(**options) + + @classmethod + def _program_id(cls) -> str: + """Return the program ID.""" + return "sampler" + + class SamplerV1(BasePrimitiveV1, Sampler, BaseSampler): """Class for interacting with Qiskit Runtime Sampler primitive service. diff --git a/test/unit/test_estimator_options.py b/test/unit/test_estimator_options.py deleted file mode 100644 index 10ca3a9dc..000000000 --- a/test/unit/test_estimator_options.py +++ /dev/null @@ -1,198 +0,0 @@ -# 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 Options class.""" - -from dataclasses import asdict - -from ddt import data, ddt -from pydantic import ValidationError -from qiskit.providers import BackendV1 -from qiskit.providers.fake_provider import FakeManila, FakeNairobiV2 -from qiskit.transpiler import CouplingMap -from qiskit_aer.noise import NoiseModel - -from qiskit_ibm_runtime.options.utils import merge_options -from qiskit_ibm_runtime.options import EstimatorOptions - -from ..ibm_test_case import IBMTestCase -from ..utils import dict_keys_equal, dict_paritally_equal, flat_dict_partially_equal - - -@ddt -class TestEStimatorOptions(IBMTestCase): - """Class for testing the Sampler class.""" - - def test_merge_options(self): - """Test merging options.""" - options_vars = [ - {}, - {"resilience_level": 9}, - {"resilience_level": 8, "transpilation": {"initial_layout": [1, 2]}}, - {"shots": 99, "seed_simulator": 42}, - {"resilience_level": 99, "shots": 98, "initial_layout": [3, 4]}, - { - "initial_layout": [1, 2], - "transpilation": {"layout_method": "trivial"}, - "log_level": "INFO", - }, - ] - for new_ops in options_vars: - with self.subTest(new_ops=new_ops): - options = EstimatorOptions() - combined = merge_options(asdict(options), new_ops) - - # Make sure the values are equal. - self.assertTrue( - flat_dict_partially_equal(combined, new_ops), - f"new_ops={new_ops}, combined={combined}", - ) - # Make sure the structure didn't change. - self.assertTrue( - dict_keys_equal(combined, asdict(options)), - f"options={options}, combined={combined}", - ) - - def test_program_inputs(self): - """Test converting to program inputs from estimator options.""" - noise_model = NoiseModel.from_backend(FakeNairobiV2()) - options = EstimatorOptions( # pylint: disable=unexpected-keyword-arg - optimization_level=1, - resilience_level=2, - transpilation={"initial_layout": [1, 2], "skip_transpilation": True}, - execution={"shots": 100}, - environment={"log_level": "DEBUG"}, - simulator={"noise_model": noise_model}, - resilience={"zne_noise_factors": (1, 2, 4)}, - ) - inputs = EstimatorOptions._get_program_inputs(asdict(options)) - - expected = { - "execution": {"shots": 100, "noise_model": noise_model}, - "skip_transpilation": True, - "transpilation": { - "optimization_level": 1, - "initial_layout": [1, 2], - }, - "resilience_level": 2, - "resilience": { - "zne_noise_factors": (1.0, 2.0, 4.0), - }, - } - self.assertTrue( - dict_paritally_equal(inputs, expected), - f"inputs={inputs}, expected={expected}", - ) - - def test_init_options_with_dictionary(self): - """Test initializing options with dictionaries.""" - - options_dicts = [ - {}, - {"resilience_level": 1}, - {"simulator": {"seed_simulator": 42}}, - {"resilience_level": 1, "environment": {"log_level": "WARNING"}}, - { - "transpilation": {"initial_layout": [1, 2], "layout_method": "trivial"}, - "execution": {"shots": 100}, - }, - {"environment": {"log_level": "ERROR"}}, - ] - - for opts_dict in options_dicts: - with self.subTest(opts_dict=opts_dict): - options = asdict(EstimatorOptions(**opts_dict)) - self.assertTrue( - dict_paritally_equal(options, opts_dict), - f"options={options}, opts_dict={opts_dict}", - ) - - # Make sure the structure didn't change. - self.assertTrue( - dict_keys_equal(asdict(EstimatorOptions()), options), f"options={options}" - ) - - def test_kwargs_options(self): - """Test specifying arbitrary options.""" - with self.assertRaises(ValidationError) as exc: - _ = EstimatorOptions(foo="foo") # pylint: disable=unexpected-keyword-arg - self.assertIn("foo", str(exc.exception)) - - def test_coupling_map_options(self): - """Check that coupling_map is processed correctly for various types""" - coupling_map = {(1, 0), (2, 1), (0, 1), (1, 2)} - coupling_maps = [ - coupling_map, - list(map(list, coupling_map)), - CouplingMap(coupling_map), - ] - for variant in coupling_maps: - with self.subTest(opts_dict=variant): - options = EstimatorOptions() - options.simulator.coupling_map = variant - inputs = EstimatorOptions._get_program_inputs(asdict(options)) - resulting_cmap = inputs["transpilation"]["coupling_map"] - self.assertEqual(coupling_map, set(map(tuple, resulting_cmap))) - - @data( - {"optimization_level": 99}, - {"resilience_level": 99}, - {"dynamical_decoupling": "foo"}, - {"transpilation": {"skip_transpilation": "foo"}}, - {"execution": {"shots": 0}}, - {"twirling": {"strategy": "foo"}}, - {"transpilation": {"foo": "bar"}}, - {"resilience_level": 3, "_is_simulator": True}, - {"zne_noise_factors": [0.5]}, - {"noise_factors": [1, 3, 5]}, - {"zne_extrapolator": "exponential", "zne_noise_factors": [1]}, - {"zne_mitigation": True, "pec_mitigation": True}, - {"simulator": {"noise_model": "foo"}}, - ) - def test_bad_inputs(self, val): - """Test invalid inputs.""" - with self.assertRaises(ValidationError) as exc: - EstimatorOptions(**val) - self.assertIn(list(val.keys())[0], str(exc.exception)) - - @data(FakeManila(), FakeNairobiV2()) - def test_simulator_set_backend(self, fake_backend): - """Test Options.simulator.set_backend method.""" - - options = EstimatorOptions() - options.simulator.seed_simulator = 42 - options.simulator.set_backend(fake_backend) - - noise_model = NoiseModel.from_backend(fake_backend) - basis_gates = ( - fake_backend.configuration().basis_gates - if isinstance(fake_backend, BackendV1) - else fake_backend.operation_names - ) - coupling_map = ( - fake_backend.configuration().coupling_map - if isinstance(fake_backend, BackendV1) - else fake_backend.coupling_map - ) - - self.assertEqual(options.simulator.coupling_map, coupling_map) - self.assertEqual(options.simulator.noise_model, noise_model) - - expected_options = EstimatorOptions() - expected_options.simulator = { - "noise_model": noise_model, - "basis_gates": basis_gates, - "coupling_map": coupling_map, - "seed_simulator": 42, - } - - self.assertDictEqual(asdict(options), asdict(expected_options)) diff --git a/test/unit/test_ibm_primitives_v2.py b/test/unit/test_ibm_primitives_v2.py index 3573b55cf..adab7bdba 100644 --- a/test/unit/test_ibm_primitives_v2.py +++ b/test/unit/test_ibm_primitives_v2.py @@ -32,7 +32,7 @@ ) from qiskit_ibm_runtime.ibm_backend import IBMBackend import qiskit_ibm_runtime.session as session_pkg -from qiskit_ibm_runtime import EstimatorV2 +from qiskit_ibm_runtime import EstimatorV2, SamplerV2 from qiskit_ibm_runtime.estimator import Estimator as IBMBaseEstimator from ..ibm_test_case import IBMTestCase @@ -43,6 +43,7 @@ create_faulty_backend, combine, MockSession, + get_primitive_inputs, ) @@ -60,13 +61,13 @@ def tearDown(self) -> None: super().tearDown() session_pkg._DEFAULT_SESSION.set(None) - @data(EstimatorV2) + @data(EstimatorV2, SamplerV2) def test_dict_options(self, primitive): """Test passing a dictionary as options.""" options_vars = [ {}, { - "resilience_level": 1, + "max_execution_time": 100, "transpilation": {"initial_layout": [1, 2]}, "execution": {"shots": 100, "init_qubits": True}, }, @@ -78,7 +79,7 @@ def test_dict_options(self, primitive): self.assertTrue(dict_paritally_equal(asdict(inst.options), options)) @combine( - primitive=[EstimatorV2], + primitive=[EstimatorV2, SamplerV2], env_var=[ {"log_level": "DEBUG"}, {"job_tags": ["foo", "bar"]}, @@ -89,12 +90,12 @@ def test_runtime_options(self, primitive, env_var): session = MagicMock(spec=MockSession) options = primitive._OPTIONS_CLASS(environment=env_var) inst = primitive(session=session, options=options) - inst.run(self.qx, observables=self.obs) + inst.run(**get_primitive_inputs(inst)) run_options = session.run.call_args.kwargs["options"] for key, val in env_var.items(): self.assertEqual(run_options[key], val) - @data(EstimatorV2) + @data(EstimatorV2, SamplerV2) def test_options_copied(self, primitive): """Test modifying original options does not affect primitives.""" options = primitive._OPTIONS_CLASS() @@ -103,7 +104,7 @@ def test_options_copied(self, primitive): options.max_execution_time = 200 self.assertEqual(inst.options.max_execution_time, 100) - @data(EstimatorV2) + @data(EstimatorV2, SamplerV2) def test_init_with_backend_str(self, primitive): """Test initializing a primitive with a backend name.""" backend_name = "ibm_gotham" @@ -125,7 +126,7 @@ def test_init_with_backend_str(self, primitive): runtime_options = mock_service_inst.run.call_args.kwargs["options"] self.assertEqual(runtime_options["backend"], backend_name) - @data(EstimatorV2) + @data(EstimatorV2, SamplerV2) def test_init_with_session_backend_str(self, primitive): """Test initializing a primitive with a backend name using session.""" backend_name = "ibm_gotham" @@ -136,7 +137,7 @@ def test_init_with_session_backend_str(self, primitive): self.assertIsNone(inst.session) self.assertIn("session must be of type Session or None", str(exc.exception)) - @data(EstimatorV2) + @data(EstimatorV2, SamplerV2) def test_init_with_backend_instance(self, primitive): """Test initializing a primitive with a backend instance.""" service = MagicMock() @@ -159,7 +160,7 @@ def test_init_with_backend_instance(self, primitive): self.assertIsNone(inst.session) self.assertIn("session must be of type Session or None", str(exc.exception)) - @data(EstimatorV2) + @data(EstimatorV2, SamplerV2) def test_init_with_backend_session(self, primitive): """Test initializing a primitive with both backend and session.""" session = MagicMock(spec=MockSession) @@ -171,7 +172,7 @@ def test_init_with_backend_session(self, primitive): inst.run(self.qx, observables=self.obs) session.run.assert_called_once() - @data(EstimatorV2) + @data(EstimatorV2, SamplerV2) def test_init_with_no_backend_session_cloud(self, primitive): """Test initializing a primitive without backend or session for cloud channel.""" with patch("qiskit_ibm_runtime.base_primitive.QiskitRuntimeService") as mock_service: @@ -184,7 +185,7 @@ def test_init_with_no_backend_session_cloud(self, primitive): mock_service.assert_called_once() self.assertIsNone(inst.session) - @data(EstimatorV2) + @data(EstimatorV2, SamplerV2) def test_init_with_no_backend_session_quantum(self, primitive): """Test initializing a primitive without backend or session for quantum channel.""" @@ -193,7 +194,7 @@ def test_init_with_no_backend_session_quantum(self, primitive): with self.assertRaises(ValueError): _ = primitive() - @data(EstimatorV2) + @data(EstimatorV2, SamplerV2) def test_default_session_context_manager(self, primitive): """Test getting default session within context manager.""" service = MagicMock() @@ -204,7 +205,7 @@ def test_default_session_context_manager(self, primitive): self.assertEqual(inst.session, session) self.assertEqual(inst.session.backend(), backend) - @data(EstimatorV2) + @data(EstimatorV2, SamplerV2) def test_default_session_cm_new_backend(self, primitive): """Test using a different backend within context manager.""" cm_backend = "ibm_metropolis" @@ -220,12 +221,12 @@ def test_default_session_cm_new_backend(self, primitive): with Session(service=service, backend=cm_backend): inst = primitive(backend=backend) self.assertIsNone(inst.session) - inst.run(self.qx, observables=self.obs) + inst.run(**get_primitive_inputs(inst)) service.run.assert_called_once() runtime_options = service.run.call_args.kwargs["options"] self.assertEqual(runtime_options["backend"], backend.name) - @data(EstimatorV2) + @data(EstimatorV2, SamplerV2) def test_no_session(self, primitive): """Test running without session.""" model_backend = FakeManila() @@ -236,20 +237,20 @@ def test_no_session(self, primitive): api_client=MagicMock(), ) inst = primitive(backend) - inst.run(self.qx, observables=self.obs) + inst.run(**get_primitive_inputs(inst)) self.assertIsNone(inst.session) service.run.assert_called_once() kwargs_list = service.run.call_args.kwargs self.assertNotIn("session_id", kwargs_list) self.assertNotIn("start_session", kwargs_list) - @data(EstimatorV2) + @data(EstimatorV2, SamplerV2) def test_run_updated_default_options(self, primitive): """Test run using updated default options.""" session = MagicMock(spec=MockSession) inst = primitive(session=session) inst.set_options(skip_transpilation=True, optimization_level=2, shots=99) - inst.run(self.qx, observables=self.obs) + inst.run(**get_primitive_inputs(inst)) inputs = session.run.call_args.kwargs["inputs"] self._assert_dict_partially_equal( inputs, @@ -260,12 +261,12 @@ def test_run_updated_default_options(self, primitive): }, ) - @data(EstimatorV2) + @data(EstimatorV2, SamplerV2) def test_run_overwrite_options(self, primitive): """Test run using overwritten options.""" session = MagicMock(spec=MockSession) options_vars = [ - ({"resilience_level": 1}, {"resilience_level": 1}), + ({"dynamical_decoupling": "XY4"}, {"dynamical_decoupling": "XY4"}), ({"shots": 200}, {"execution": {"shots": 200}}), ( {"optimization_level": 3}, @@ -285,12 +286,12 @@ def test_run_overwrite_options(self, primitive): for options, expected in options_vars: with self.subTest(options=options): inst = primitive(session=session) - inst.run(self.qx, observables=self.obs, **options) + inst.run(**get_primitive_inputs(inst), **options) inputs = session.run.call_args.kwargs["inputs"] self._assert_dict_partially_equal(inputs, expected) self.assertDictEqual(asdict(inst.options), asdict(opt_cls())) - @data(EstimatorV2) + @data(EstimatorV2, SamplerV2) def test_run_overwrite_runtime_options(self, primitive): """Test run using overwritten runtime options.""" session = MagicMock(spec=MockSession) @@ -307,24 +308,35 @@ def test_run_overwrite_runtime_options(self, primitive): rt_options = session.run.call_args.kwargs["options"] self._assert_dict_partially_equal(rt_options, options) - @combine(primitive=[EstimatorV2], exp_opt=[{"foo": "bar"}, {"transpilation": {"foo": "bar"}}]) + @combine(primitive=[EstimatorV2, SamplerV2], exp_opt=[{"foo": "bar"}, {"transpilation": {"foo": "bar"}}]) def test_run_experimental_options(self, primitive, exp_opt): """Test specifying arbitrary options in run.""" - # FIXME session = MagicMock(spec=MockSession) inst = primitive(session=session) - inst.run(self.qx, observables=self.obs, experimental=exp_opt) + inst.run(**get_primitive_inputs(inst), experimental=exp_opt) inputs = session.run.call_args.kwargs["inputs"] self._assert_dict_partially_equal(inputs, exp_opt) - @data(EstimatorV2) + @data(EstimatorV2, SamplerV2) + def test_run_unset_options(self, primitive): + """Test running with unset options.""" + session = MagicMock(spec=MockSession) + inst = primitive(session=session) + inst.run(**get_primitive_inputs(inst)) + inputs = session.run.call_args.kwargs["inputs"] + for fld in ["circuits", "observables", "parameters", "parameter_values", "_experimental"]: + inputs.pop(fld, None) + expected = {'skip_transpilation': False, 'execution': {'init_qubits': True}, 'version': 2} + self.assertDictEqual(inputs, expected) + + @data(EstimatorV2, SamplerV2) def test_run_multiple_different_options(self, primitive): """Test multiple runs with different options.""" opt_cls = primitive._OPTIONS_CLASS session = MagicMock(spec=MockSession) inst = primitive(session=session) - inst.run(self.qx, observables=self.obs, shots=100) - inst.run(self.qx, observables=self.obs, shots=200) + inst.run(**get_primitive_inputs(inst), shots=100) + inst.run(**get_primitive_inputs(inst), shots=200) kwargs_list = session.run.call_args_list for idx, shots in zip([0, 1], [100, 200]): self.assertEqual(kwargs_list[idx][1]["inputs"]["execution"]["shots"], shots) @@ -333,15 +345,15 @@ def test_run_multiple_different_options(self, primitive): def test_run_same_session(self): """Test multiple runs within a session.""" num_runs = 5 - primitives = [EstimatorV2] + primitives = [EstimatorV2, SamplerV2] session = MagicMock(spec=MockSession) for idx in range(num_runs): cls = primitives[idx % len(primitives)] inst = cls(session=session) - inst.run(self.qx, observables=self.obs) + inst.run(**get_primitive_inputs(inst)) self.assertEqual(session.run.call_count, num_runs) - @data(EstimatorV2) + @data(EstimatorV2, SamplerV2) def test_set_options(self, primitive): """Test set options.""" opt_cls = primitive._OPTIONS_CLASS @@ -368,7 +380,7 @@ def test_set_options(self, primitive): f"inst_options={inst_options}, new_str={new_str}", ) - @data(EstimatorV2) + @data(EstimatorV2, SamplerV2) def test_accept_level_1_options(self, primitive): """Test initializing options properly when given on level 1.""" @@ -396,14 +408,6 @@ def test_accept_level_1_options(self, primitive): inst1 = primitive(session=session, options=opts) inst2 = primitive(session=session, options=expected) self.assertEqual(inst1.options, inst2.options) - # # Make sure the values are equal. - # inst1_options = inst1.options.__dict__ - # expected_dict = inst2.options.__dict__ - # # Make sure the structure didn't change. - # self.assertTrue( - # dict_keys_equal(inst1_options, expected_dict), - # f"inst_options={inst1_options}, expected={expected_dict}", - # ) @skip("We don't change default error level anymore") def test_default_error_levels(self): @@ -468,7 +472,7 @@ def test_default_error_levels(self): ) self.assertEqual(inputs["resilience_level"], 0) - @data(EstimatorV2) + @data(EstimatorV2, SamplerV2) def test_raise_faulty_qubits(self, primitive): """Test faulty qubits is raised.""" fake_backend = FakeManila() @@ -496,7 +500,7 @@ def test_raise_faulty_qubits(self, primitive): inst.run(**inputs, skip_transpilation=True) self.assertIn(f"faulty qubit {faulty_qubit}", str(err.exception)) - @data(EstimatorV2) + @data(EstimatorV2, SamplerV2) def test_raise_faulty_qubits_many(self, primitive): """Test faulty qubits is raised if one circuit uses it.""" fake_backend = FakeManila() @@ -528,7 +532,7 @@ def test_raise_faulty_qubits_many(self, primitive): inst.run(**inputs, skip_transpilation=True) self.assertIn(f"faulty qubit {faulty_qubit}", str(err.exception)) - @data(EstimatorV2) + @data(EstimatorV2, SamplerV2) def test_raise_faulty_edge(self, primitive): """Test faulty edge is raised.""" fake_backend = FakeManila() @@ -557,7 +561,7 @@ def test_raise_faulty_edge(self, primitive): self.assertIn("cx", str(err.exception)) self.assertIn(f"faulty edge {tuple(edge_qubits)}", str(err.exception)) - @data(EstimatorV2) + @data(EstimatorV2, SamplerV2) def test_faulty_qubit_not_used(self, primitive): """Test faulty qubit is not raise if not used.""" fake_backend = FakeManila() @@ -585,7 +589,7 @@ def test_faulty_qubit_not_used(self, primitive): inst.run(**inputs, skip_transpilation=True) mock_run.assert_called_once() - @data(EstimatorV2) + @data(EstimatorV2, SamplerV2) def test_faulty_edge_not_used(self, primitive): """Test faulty edge is not raised if not used.""" fake_backend = FakeManila() @@ -615,7 +619,7 @@ def test_faulty_edge_not_used(self, primitive): inst.run(**inputs, skip_transpilation=True) mock_run.assert_called_once() - @data(EstimatorV2) + @data(EstimatorV2, SamplerV2) def test_no_raise_skip_transpilation(self, primitive): """Test faulty qubits and edges are not raise if not skipping.""" fake_backend = FakeManila() diff --git a/test/unit/test_options.py b/test/unit/test_options.py index 6f7113666..031b5b094 100644 --- a/test/unit/test_options.py +++ b/test/unit/test_options.py @@ -23,19 +23,18 @@ from qiskit_ibm_runtime import Options, RuntimeOptions from qiskit_ibm_runtime.options.utils import merge_options -from qiskit_ibm_runtime.options import EstimatorOptions +from qiskit_ibm_runtime.options import EstimatorOptions, SamplerOptions from qiskit_ibm_runtime.utils.qctrl import _warn_and_clean_options from ..ibm_test_case import IBMTestCase -from ..utils import dict_keys_equal, dict_paritally_equal, flat_dict_partially_equal +from ..utils import dict_keys_equal, dict_paritally_equal, flat_dict_partially_equal, combine @ddt class TestOptions(IBMTestCase): - """Class for testing the Sampler class.""" + """Class for testing the Options class.""" - @data(Options, EstimatorOptions) - def test_merge_options(self, opt_cls): + def test_merge_options(self): """Test merging options.""" options_vars = [ {}, @@ -51,7 +50,7 @@ def test_merge_options(self, opt_cls): ] for new_ops in options_vars: with self.subTest(new_ops=new_ops): - options = opt_cls() + options = Options() combined = merge_options(asdict(options), new_ops) # Make sure the values are equal. @@ -270,3 +269,215 @@ def test_qctrl_overrides(self): with self.subTest(msg=f"{option}"): _warn_and_clean_options(option) self.assertEqual(expected_, option) + + +@ddt +class TestOptionsV2(IBMTestCase): + """Class for testing the v2 Options class.""" + + def test_merge_options(self): + """Test merging options.""" + options_vars = [ + {}, + {"resilience_level": 9}, + {"resilience_level": 8, "transpilation": {"initial_layout": [1, 2]}}, + {"shots": 99, "seed_simulator": 42}, + {"resilience_level": 99, "shots": 98, "initial_layout": [3, 4]}, + { + "initial_layout": [1, 2], + "transpilation": {"layout_method": "trivial"}, + "log_level": "INFO", + }, + ] + for new_ops in options_vars: + with self.subTest(new_ops=new_ops): + options = EstimatorOptions() + combined = merge_options(asdict(options), new_ops) + + # Make sure the values are equal. + self.assertTrue( + flat_dict_partially_equal(combined, new_ops), + f"new_ops={new_ops}, combined={combined}", + ) + # Make sure the structure didn't change. + self.assertTrue( + dict_keys_equal(combined, asdict(options)), + f"options={options}, combined={combined}", + ) + + @data(EstimatorOptions, SamplerOptions) + def test_runtime_options(self, opt_cls): + """Test converting runtime options.""" + full_options = RuntimeOptions( + backend="ibm_gotham", + image="foo:bar", + log_level="DEBUG", + instance="h/g/p", + job_tags=["foo", "bar"], + max_execution_time=600, + ) + partial_options = RuntimeOptions(backend="foo", log_level="DEBUG") + + for rt_options in [full_options, partial_options]: + with self.subTest(rt_options=rt_options): + self.assertGreaterEqual( + vars(rt_options).items(), + opt_cls._get_runtime_options(vars(rt_options)).items(), + ) + + @data(EstimatorOptions, SamplerOptions) + def test_program_inputs(self, opt_cls): + """Test converting to program inputs from v2 options.""" + + noise_model = NoiseModel.from_backend(FakeManila()) + transpilation = {"skip_transpilation": False, "initial_layout": [1, 2], "layout_method": "trivial", "routing_method": "basic", "approximation_degree": 0.5} + simulator ={"noise_model": noise_model, "seed_simulator": 42, "coupling_map": [[0, 1]], "basis_gates": ['u1']} + execution={"shots": 400, "init_qubits": True, "samples": 20, "shots_per_sample": 20, "interleave_samples": True} + optimization_level = 2 + twirling = {"gates": True, "measure": True, "strategy": "all"} + resilience = {"measure_noise_mitigation": True, "zne_mitigation": True, "zne_noise_factors": [1, 3], "zne_extrapolator": "exponential", "zne_stderr_threshold": 1, "pec_mitigation": False, "pec_max_overhead": 2} + + estimator_extra = {} + if isinstance(opt_cls, EstimatorOptions): + estimator_extra = {"resilience_level": 3, + "resilience": resilience} + + opt = opt_cls( + max_execution_time=100, + simulator=simulator, + optimization_level=optimization_level, + dynamical_decoupling="XX", + transpilation=transpilation, + execution=execution, + twirling=twirling, + experimental={"foo": "bar"}, **estimator_extra) + + transpilation.pop("skip_transpilation") + transpilation.update({"optimization_level": optimization_level, "coupling_map": simulator.pop("coupling_map"), "basis_gates": simulator.pop("basis_gates")}) + execution.update({"noise_model": simulator.pop("noise_model"), "seed_simulator": simulator.pop("seed_simulator")}) + expected = { + "transpilation": transpilation, + "skip_transpilation": False, + "twirling": twirling, + "dynamical_decoupling": "XX", + "execution": execution, + "foo": "bar", + "version": 2, + **estimator_extra + } + + inputs = opt_cls._get_program_inputs(asdict(opt)) + inputs.pop("_experimental", None) + self.assertDictEqual(inputs, expected) + self.assertFalse(simulator, f"simulator not empty: {simulator}") + + @data(EstimatorOptions, SamplerOptions) + def test_init_options_with_dictionary(self, opt_cls): + """Test initializing options with dictionaries.""" + + options_dicts = [ + {}, + {"dynamical_decoupling": "XX"}, + {"simulator": {"seed_simulator": 42}}, + {"optimization_level": 2, "environment": {"log_level": "WARNING"}}, + { + "transpilation": {"initial_layout": [1, 2], "layout_method": "trivial"}, + "execution": {"shots": 100}, + }, + {"twirling": {"gates": True, "strategy": "active"}}, + {"environment": {"log_level": "ERROR"}}, + ] + + for opts_dict in options_dicts: + with self.subTest(opts_dict=opts_dict): + options = asdict(opt_cls(**opts_dict)) + self.assertTrue( + dict_paritally_equal(options, opts_dict), + f"options={options}, opts_dict={opts_dict}", + ) + + # Make sure the structure didn't change. + self.assertTrue(dict_keys_equal(asdict(opt_cls()), options), f"options={options}") + + @data(EstimatorOptions, SamplerOptions) + def test_kwargs_options(self, opt_cls): + """Test specifying arbitrary options.""" + with self.assertRaises(ValidationError) as exc: + _ = opt_cls(foo="foo") # pylint: disable=unexpected-keyword-arg + self.assertIn("foo", str(exc.exception)) + + @data(EstimatorOptions, SamplerOptions) + def test_coupling_map_options(self, opt_cls): + """Check that coupling_map is processed correctly for various types""" + coupling_map = {(1, 0), (2, 1), (0, 1), (1, 2)} + coupling_maps = [ + coupling_map, + list(map(list, coupling_map)), + CouplingMap(coupling_map), + ] + for variant in coupling_maps: + with self.subTest(opts_dict=variant): + options = opt_cls() + options.simulator.coupling_map = variant + inputs = opt_cls._get_program_inputs(asdict(options)) + resulting_cmap = inputs["transpilation"]["coupling_map"] + self.assertEqual(coupling_map, set(map(tuple, resulting_cmap))) + + @combine(opt_cls=[EstimatorOptions, SamplerOptions], fake_backend=[FakeManila(), FakeNairobiV2()]) + def test_simulator_set_backend(self, opt_cls, fake_backend): + """Test Options.simulator.set_backend method.""" + + options = opt_cls() + options.simulator.seed_simulator = 42 + options.simulator.set_backend(fake_backend) + + noise_model = NoiseModel.from_backend(fake_backend) + basis_gates = ( + fake_backend.configuration().basis_gates + if isinstance(fake_backend, BackendV1) + else fake_backend.operation_names + ) + coupling_map = ( + fake_backend.configuration().coupling_map + if isinstance(fake_backend, BackendV1) + else fake_backend.coupling_map + ) + + self.assertEqual(options.simulator.coupling_map, coupling_map) + self.assertEqual(options.simulator.noise_model, noise_model) + + expected_options = opt_cls() + expected_options.simulator = { + "noise_model": noise_model, + "basis_gates": basis_gates, + "coupling_map": coupling_map, + "seed_simulator": 42, + } + + self.assertDictEqual(asdict(options), asdict(expected_options)) + + @combine( + opt_cls=[EstimatorOptions, SamplerOptions], + opt=[ + {"optimization_level": 99}, + {"resilience_level": 99}, + {"dynamical_decoupling": "foo"}, + {"transpilation": {"skip_transpilation": "foo"}}, + {"execution": {"shots": 0}}, + {"twirling": {"strategy": "foo"}}, + {"transpilation": {"foo": "bar"}}, + {"resilience_level": 3, "_is_simulator": True}, + {"zne_noise_factors": [0.5]}, + {"noise_factors": [1, 3, 5]}, + {"zne_extrapolator": "exponential", "zne_noise_factors": [1]}, + {"zne_mitigation": True, "pec_mitigation": True}, + {"simulator": {"noise_model": "foo"}}, + {"shots": 1, "samples": 99, "shots_per_sample": 99}, + {"shots": 0} + ] + ) + def test_invalid_options(self, opt_cls, opt): + """Test invalid inputs.""" + with self.assertRaises(ValidationError) as exc: + opt_cls(**opt) + self.assertIn(list(opt.keys())[0], str(exc.exception)) diff --git a/test/unit/test_sampler.py b/test/unit/test_sampler.py index 2d32766f2..914d713c2 100644 --- a/test/unit/test_sampler.py +++ b/test/unit/test_sampler.py @@ -12,11 +12,17 @@ """Tests for sampler class.""" +from unittest.mock import MagicMock + +from qiskit import QuantumCircuit from qiskit.test.reference_circuits import ReferenceCircuits -from qiskit_ibm_runtime import Sampler, Session +from qiskit_ibm_runtime import Sampler, Session, SamplerV2, SamplerOptions + +from ddt import data, ddt from ..ibm_test_case import IBMTestCase from .mock.fake_runtime_service import FakeRuntimeService +from ..utils import MockSession, dict_paritally_equal class TestSampler(IBMTestCase): @@ -39,3 +45,54 @@ def test_unsupported_values_for_sampler_options(self): with self.assertRaises(ValueError) as exc: _ = inst.run(circuit, **bad_opt) self.assertIn(list(bad_opt.keys())[0], str(exc.exception)) + + +@ddt +class TestSamplerV2(IBMTestCase): + """Class for testing the Estimator class.""" + + def setUp(self) -> None: + super().setUp() + self.circuit = QuantumCircuit(1, 1) + + @data({"optimization_level": 4}, {"resilience_level": 1}, {"resilience": {"zne_mitigation": True}}) + def test_unsupported_values_for_sampler_options(self, opt): + """Test exception when options levels are not supported.""" + with Session( + service=FakeRuntimeService(channel="ibm_quantum", token="abc"), + backend="common_backend", + ) as session: + inst = SamplerV2(session=session) + with self.assertRaises(ValueError) as exc: + _ = inst.run(self.circuit, **opt) + self.assertIn(list(opt.keys())[0], str(exc.exception)) + + def test_run_default_options(self): + """Test run using default options.""" + session = MagicMock(spec=MockSession) + options_vars = [ + (SamplerOptions(dynamical_decoupling="XX"), {"dynamical_decoupling": "XX"}), + ( + SamplerOptions(optimization_level=3), + {"transpilation": {"optimization_level": 3}}, + ), + ( + { + "transpilation": {"initial_layout": [1, 2]}, + "execution": {"shots": 100}, + }, + { + "transpilation": {"initial_layout": [1, 2]}, + "execution": {"shots": 100}, + }, + ), + ] + for options, expected in options_vars: + with self.subTest(options=options): + inst = SamplerV2(session=session, options=options) + inst.run(self.circuit) + inputs = session.run.call_args.kwargs["inputs"] + self.assertTrue( + dict_paritally_equal(inputs, expected), + f"{inputs} and {expected} not partially equal.", + ) From 260f7c8057f1f7d7dd20aa3f4e1f0a3f38ba3921 Mon Sep 17 00:00:00 2001 From: Jessie Yu Date: Tue, 7 Nov 2023 08:31:04 -0500 Subject: [PATCH 25/35] lint --- qiskit_ibm_runtime/options/__init__.py | 2 +- qiskit_ibm_runtime/options/utils.py | 8 ++- .../qiskit/primitives/__init__.py | 2 +- qiskit_ibm_runtime/sampler.py | 2 - test/unit/test_ibm_primitives_v2.py | 7 +- test/unit/test_options.py | 65 +++++++++++++++---- test/unit/test_sampler.py | 8 ++- 7 files changed, 70 insertions(+), 24 deletions(-) diff --git a/qiskit_ibm_runtime/options/__init__.py b/qiskit_ibm_runtime/options/__init__.py index 2e8dcae6a..06bf824a8 100644 --- a/qiskit_ibm_runtime/options/__init__.py +++ b/qiskit_ibm_runtime/options/__init__.py @@ -63,4 +63,4 @@ from .resilience_options import ResilienceOptionsV1 as ResilienceOptions from .twirling_options import TwirlingOptions from .estimator_options import EstimatorOptions -from .sampler_options import SamplerOptions \ No newline at end of file +from .sampler_options import SamplerOptions diff --git a/qiskit_ibm_runtime/options/utils.py b/qiskit_ibm_runtime/options/utils.py index 31cd13257..17aa4bdd9 100644 --- a/qiskit_ibm_runtime/options/utils.py +++ b/qiskit_ibm_runtime/options/utils.py @@ -41,13 +41,17 @@ def set_default_error_levels( options with correct error level defaults. """ if options.get("optimization_level") is None: - if backend.configuration().simulator and not options.get("simulator", {}).get("noise_model"): + if backend.configuration().simulator and not options.get("simulator", {}).get( + "noise_model" + ): options["optimization_level"] = 1 else: options["optimization_level"] = default_optimization_level if options.get("resilience_level") is None: - if backend.configuration().simulator and not options.get("simulator", {}).get("noise_model"): + if backend.configuration().simulator and not options.get("simulator", {}).get( + "noise_model" + ): options["resilience_level"] = 0 else: options["resilience_level"] = default_resilience_level diff --git a/qiskit_ibm_runtime/qiskit/primitives/__init__.py b/qiskit_ibm_runtime/qiskit/primitives/__init__.py index 5f21b57b9..f378306f2 100644 --- a/qiskit_ibm_runtime/qiskit/primitives/__init__.py +++ b/qiskit_ibm_runtime/qiskit/primitives/__init__.py @@ -14,4 +14,4 @@ from .base_estimator import BaseEstimatorV2 # type: ignore from .base_primitive import BasePrimitiveOptions # type: ignore -from .base_sampler import BaseSamplerV2 # type: ignore \ No newline at end of file +from .base_sampler import BaseSamplerV2 # type: ignore diff --git a/qiskit_ibm_runtime/sampler.py b/qiskit_ibm_runtime/sampler.py index e8962b0bf..4e71f35f7 100644 --- a/qiskit_ibm_runtime/sampler.py +++ b/qiskit_ibm_runtime/sampler.py @@ -125,8 +125,6 @@ def run( # pylint: disable=arguments-differ circuits: a (parameterized) :class:`~qiskit.circuit.QuantumCircuit` or a list of (parameterized) :class:`~qiskit.circuit.QuantumCircuit`. - observables: Observable objects. - parameter_values: Concrete parameters to be bound. **kwargs: Individual options to overwrite the default primitive options. diff --git a/test/unit/test_ibm_primitives_v2.py b/test/unit/test_ibm_primitives_v2.py index adab7bdba..ff260a63d 100644 --- a/test/unit/test_ibm_primitives_v2.py +++ b/test/unit/test_ibm_primitives_v2.py @@ -308,7 +308,10 @@ def test_run_overwrite_runtime_options(self, primitive): rt_options = session.run.call_args.kwargs["options"] self._assert_dict_partially_equal(rt_options, options) - @combine(primitive=[EstimatorV2, SamplerV2], exp_opt=[{"foo": "bar"}, {"transpilation": {"foo": "bar"}}]) + @combine( + primitive=[EstimatorV2, SamplerV2], + exp_opt=[{"foo": "bar"}, {"transpilation": {"foo": "bar"}}], + ) def test_run_experimental_options(self, primitive, exp_opt): """Test specifying arbitrary options in run.""" session = MagicMock(spec=MockSession) @@ -326,7 +329,7 @@ def test_run_unset_options(self, primitive): inputs = session.run.call_args.kwargs["inputs"] for fld in ["circuits", "observables", "parameters", "parameter_values", "_experimental"]: inputs.pop(fld, None) - expected = {'skip_transpilation': False, 'execution': {'init_qubits': True}, 'version': 2} + expected = {"skip_transpilation": False, "execution": {"init_qubits": True}, "version": 2} self.assertDictEqual(inputs, expected) @data(EstimatorV2, SamplerV2) diff --git a/test/unit/test_options.py b/test/unit/test_options.py index 031b5b094..36bba39cf 100644 --- a/test/unit/test_options.py +++ b/test/unit/test_options.py @@ -330,17 +330,41 @@ def test_program_inputs(self, opt_cls): """Test converting to program inputs from v2 options.""" noise_model = NoiseModel.from_backend(FakeManila()) - transpilation = {"skip_transpilation": False, "initial_layout": [1, 2], "layout_method": "trivial", "routing_method": "basic", "approximation_degree": 0.5} - simulator ={"noise_model": noise_model, "seed_simulator": 42, "coupling_map": [[0, 1]], "basis_gates": ['u1']} - execution={"shots": 400, "init_qubits": True, "samples": 20, "shots_per_sample": 20, "interleave_samples": True} + transpilation = { + "skip_transpilation": False, + "initial_layout": [1, 2], + "layout_method": "trivial", + "routing_method": "basic", + "approximation_degree": 0.5, + } + simulator = { + "noise_model": noise_model, + "seed_simulator": 42, + "coupling_map": [[0, 1]], + "basis_gates": ["u1"], + } + execution = { + "shots": 400, + "init_qubits": True, + "samples": 20, + "shots_per_sample": 20, + "interleave_samples": True, + } optimization_level = 2 twirling = {"gates": True, "measure": True, "strategy": "all"} - resilience = {"measure_noise_mitigation": True, "zne_mitigation": True, "zne_noise_factors": [1, 3], "zne_extrapolator": "exponential", "zne_stderr_threshold": 1, "pec_mitigation": False, "pec_max_overhead": 2} + resilience = { + "measure_noise_mitigation": True, + "zne_mitigation": True, + "zne_noise_factors": [1, 3], + "zne_extrapolator": "exponential", + "zne_stderr_threshold": 1, + "pec_mitigation": False, + "pec_max_overhead": 2, + } estimator_extra = {} if isinstance(opt_cls, EstimatorOptions): - estimator_extra = {"resilience_level": 3, - "resilience": resilience} + estimator_extra = {"resilience_level": 3, "resilience": resilience} opt = opt_cls( max_execution_time=100, @@ -350,11 +374,24 @@ def test_program_inputs(self, opt_cls): transpilation=transpilation, execution=execution, twirling=twirling, - experimental={"foo": "bar"}, **estimator_extra) + experimental={"foo": "bar"}, + **estimator_extra, + ) transpilation.pop("skip_transpilation") - transpilation.update({"optimization_level": optimization_level, "coupling_map": simulator.pop("coupling_map"), "basis_gates": simulator.pop("basis_gates")}) - execution.update({"noise_model": simulator.pop("noise_model"), "seed_simulator": simulator.pop("seed_simulator")}) + transpilation.update( + { + "optimization_level": optimization_level, + "coupling_map": simulator.pop("coupling_map"), + "basis_gates": simulator.pop("basis_gates"), + } + ) + execution.update( + { + "noise_model": simulator.pop("noise_model"), + "seed_simulator": simulator.pop("seed_simulator"), + } + ) expected = { "transpilation": transpilation, "skip_transpilation": False, @@ -363,7 +400,7 @@ def test_program_inputs(self, opt_cls): "execution": execution, "foo": "bar", "version": 2, - **estimator_extra + **estimator_extra, } inputs = opt_cls._get_program_inputs(asdict(opt)) @@ -423,7 +460,9 @@ def test_coupling_map_options(self, opt_cls): resulting_cmap = inputs["transpilation"]["coupling_map"] self.assertEqual(coupling_map, set(map(tuple, resulting_cmap))) - @combine(opt_cls=[EstimatorOptions, SamplerOptions], fake_backend=[FakeManila(), FakeNairobiV2()]) + @combine( + opt_cls=[EstimatorOptions, SamplerOptions], fake_backend=[FakeManila(), FakeNairobiV2()] + ) def test_simulator_set_backend(self, opt_cls, fake_backend): """Test Options.simulator.set_backend method.""" @@ -473,8 +512,8 @@ def test_simulator_set_backend(self, opt_cls, fake_backend): {"zne_mitigation": True, "pec_mitigation": True}, {"simulator": {"noise_model": "foo"}}, {"shots": 1, "samples": 99, "shots_per_sample": 99}, - {"shots": 0} - ] + {"shots": 0}, + ], ) def test_invalid_options(self, opt_cls, opt): """Test invalid inputs.""" diff --git a/test/unit/test_sampler.py b/test/unit/test_sampler.py index 914d713c2..2e50df7e0 100644 --- a/test/unit/test_sampler.py +++ b/test/unit/test_sampler.py @@ -14,12 +14,12 @@ from unittest.mock import MagicMock +from ddt import data, ddt + from qiskit import QuantumCircuit from qiskit.test.reference_circuits import ReferenceCircuits from qiskit_ibm_runtime import Sampler, Session, SamplerV2, SamplerOptions -from ddt import data, ddt - from ..ibm_test_case import IBMTestCase from .mock.fake_runtime_service import FakeRuntimeService from ..utils import MockSession, dict_paritally_equal @@ -55,7 +55,9 @@ def setUp(self) -> None: super().setUp() self.circuit = QuantumCircuit(1, 1) - @data({"optimization_level": 4}, {"resilience_level": 1}, {"resilience": {"zne_mitigation": True}}) + @data( + {"optimization_level": 4}, {"resilience_level": 1}, {"resilience": {"zne_mitigation": True}} + ) def test_unsupported_values_for_sampler_options(self, opt): """Test exception when options levels are not supported.""" with Session( From a56cdf083fde894bd0753eadeaaaf6127e4a09ab Mon Sep 17 00:00:00 2001 From: Jessie Yu Date: Tue, 7 Nov 2023 08:43:37 -0500 Subject: [PATCH 26/35] freeze constants --- qiskit_ibm_runtime/options/estimator_options.py | 16 ++++++++-------- qiskit_ibm_runtime/options/resilience_options.py | 5 +---- qiskit_ibm_runtime/options/utils.py | 11 +++++++---- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/qiskit_ibm_runtime/options/estimator_options.py b/qiskit_ibm_runtime/options/estimator_options.py index 022348426..18f7905e2 100644 --- a/qiskit_ibm_runtime/options/estimator_options.py +++ b/qiskit_ibm_runtime/options/estimator_options.py @@ -80,7 +80,7 @@ class EstimatorOptions(OptionsV2): resilience: Advanced resilience options to fine tune the resilience strategy. See :class:`ResilienceOptions` for all available options. - execution: Execution time options. See :class:`ExecutionOptions` for all available options. + execution: Execution time options. See :class:`ExecutionOptionsV2` for all available options. environment: Options related to the execution environment. See :class:`EnvironmentOptions` for all available options. @@ -90,11 +90,11 @@ class EstimatorOptions(OptionsV2): """ - _version: int = 2 - _is_simulator: bool = False + _VERSION: int = Field(2, frozen=True) + _MAX_OPTIMIZATION_LEVEL: int = Field(3, frozen=True) + _MAX_RESILIENCE_LEVEL: int = Field(3, frozen=True) - _MAX_OPTIMIZATION_LEVEL = 3 - _MAX_RESILIENCE_LEVEL = 3 + _is_simulator: bool = False # Sadly we cannot use pydantic's built in validation because it won't work on Unset. optimization_level: Union[UnsetType, int] = Unset @@ -111,7 +111,7 @@ class EstimatorOptions(OptionsV2): @skip_unset_validation def _validate_optimization_level(cls, optimization_level: int) -> int: """Validate optimization_leve.""" - if not 0 <= optimization_level <= 3: + if not 0 <= optimization_level <= EstimatorOptions._MAX_OPTIMIZATION_LEVEL: raise ValueError( "Invalid optimization_level. Valid range is " f"0-{EstimatorOptions._MAX_OPTIMIZATION_LEVEL}" @@ -123,7 +123,7 @@ def _validate_optimization_level(cls, optimization_level: int) -> int: @skip_unset_validation def _validate_resilience_level(cls, resilience_level: int) -> int: """Validate resilience_level.""" - if not 0 <= resilience_level <= 3: + if not 0 <= resilience_level <= EstimatorOptions._MAX_RESILIENCE_LEVEL: raise ValueError( "Invalid optimization_level. Valid range is " f"0-{EstimatorOptions._MAX_RESILIENCE_LEVEL}" @@ -189,7 +189,7 @@ def _get_program_inputs(options: dict) -> dict: inputs = merge_options(inputs, options.get("experimental")) inputs["_experimental"] = True - inputs["version"] = EstimatorOptions._version + inputs["version"] = EstimatorOptions._VERSION _remove_dict_unset_values(inputs) return inputs diff --git a/qiskit_ibm_runtime/options/resilience_options.py b/qiskit_ibm_runtime/options/resilience_options.py index 28d02a83f..5237631f5 100644 --- a/qiskit_ibm_runtime/options/resilience_options.py +++ b/qiskit_ibm_runtime/options/resilience_options.py @@ -92,10 +92,7 @@ class ResilienceOptionsV2: # ZNE zne_mitigation: Union[UnsetType, bool] = Unset zne_noise_factors: Union[UnsetType, Sequence[float]] = Unset - zne_extrapolator: Union[UnsetType, ZneExtrapolatorType, Sequence[ZneExtrapolatorType]] = ( - "exponential", - "linear", - ) + zne_extrapolator: Union[UnsetType, ZneExtrapolatorType, Sequence[ZneExtrapolatorType]] = Unset zne_stderr_threshold: Union[UnsetType, float] = Unset # PEC diff --git a/qiskit_ibm_runtime/options/utils.py b/qiskit_ibm_runtime/options/utils.py index df44ad37e..17aa4bdd9 100644 --- a/qiskit_ibm_runtime/options/utils.py +++ b/qiskit_ibm_runtime/options/utils.py @@ -41,17 +41,17 @@ def set_default_error_levels( options with correct error level defaults. """ if options.get("optimization_level") is None: - if backend.configuration().simulator and options.get("simulator", {}).get( + if backend.configuration().simulator and not options.get("simulator", {}).get( "noise_model" - ) in [None, Unset]: + ): options["optimization_level"] = 1 else: options["optimization_level"] = default_optimization_level if options.get("resilience_level") is None: - if backend.configuration().simulator and options.get("simulator", {}).get( + if backend.configuration().simulator and not options.get("simulator", {}).get( "noise_model" - ) in [None, Unset]: + ): options["resilience_level"] = 0 else: options["resilience_level"] = default_resilience_level @@ -165,5 +165,8 @@ def __new__(cls) -> "UnsetType": cls._instance = super().__new__(cls) return cls._instance + def __bool__(self) -> bool: + return False + Unset = UnsetType() From 6b47aeaae13196a2ca61e7c001d8d1bd2b54ba1d Mon Sep 17 00:00:00 2001 From: Jessie Yu Date: Tue, 7 Nov 2023 08:54:09 -0500 Subject: [PATCH 27/35] freeze constants --- qiskit_ibm_runtime/options/estimator_options.py | 1 - qiskit_ibm_runtime/options/options.py | 4 ++-- qiskit_ibm_runtime/options/sampler_options.py | 4 +--- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/qiskit_ibm_runtime/options/estimator_options.py b/qiskit_ibm_runtime/options/estimator_options.py index f41e08318..4670aee6b 100644 --- a/qiskit_ibm_runtime/options/estimator_options.py +++ b/qiskit_ibm_runtime/options/estimator_options.py @@ -87,7 +87,6 @@ class EstimatorOptions(OptionsV2): """ - _VERSION: int = Field(2, frozen=True) _MAX_OPTIMIZATION_LEVEL: int = Field(3, frozen=True) _MAX_RESILIENCE_LEVEL: int = Field(3, frozen=True) diff --git a/qiskit_ibm_runtime/options/options.py b/qiskit_ibm_runtime/options/options.py index 602439c3c..f87f01f7c 100644 --- a/qiskit_ibm_runtime/options/options.py +++ b/qiskit_ibm_runtime/options/options.py @@ -93,7 +93,7 @@ class OptionsV2(BaseOptions): :class:`SimulatorOptions` for all available options. """ - _version: int = 2 + _VERSION: int = Field(2, frozen=True) # Options not really related to primitives. max_execution_time: Union[UnsetType, int] = Unset @@ -144,7 +144,7 @@ def _set_if_exists(name: str, _inputs: dict, _options: dict) -> None: inputs = merge_options(inputs, options.get("experimental")) inputs["_experimental"] = True - inputs["version"] = OptionsV2._version + inputs["version"] = OptionsV2._VERSION _remove_dict_unset_values(inputs) # Remove empty dictionaries diff --git a/qiskit_ibm_runtime/options/sampler_options.py b/qiskit_ibm_runtime/options/sampler_options.py index 83a26d264..ad6cdc241 100644 --- a/qiskit_ibm_runtime/options/sampler_options.py +++ b/qiskit_ibm_runtime/options/sampler_options.py @@ -68,11 +68,9 @@ class SamplerOptions(OptionsV2): """ - _version: int = 2 + _MAX_OPTIMIZATION_LEVEL: int = Field(3, frozen=True) _is_simulator: bool = False - _MAX_OPTIMIZATION_LEVEL = 3 - # Sadly we cannot use pydantic's built in validation because it won't work on Unset. optimization_level: Union[UnsetType, int] = Unset dynamical_decoupling: Union[UnsetType, DDSequenceType] = Unset From 01317894ead8f9c21b3a83f4ffd5ad8fb0500c76 Mon Sep 17 00:00:00 2001 From: Jessie Yu Date: Tue, 7 Nov 2023 11:51:49 -0500 Subject: [PATCH 28/35] remove is_simulator --- qiskit_ibm_runtime/estimator.py | 17 +++++++++--- .../options/estimator_options.py | 27 +++---------------- test/unit/test_estimator.py | 11 ++++++++ test/unit/test_estimator_options.py | 1 - 4 files changed, 29 insertions(+), 27 deletions(-) diff --git a/qiskit_ibm_runtime/estimator.py b/qiskit_ibm_runtime/estimator.py index 76c1cf604..8b5aa89c5 100644 --- a/qiskit_ibm_runtime/estimator.py +++ b/qiskit_ibm_runtime/estimator.py @@ -155,9 +155,6 @@ def __init__( Estimator.__init__(self) BasePrimitiveV2.__init__(self, backend=backend, session=session, options=options) - self.options._is_simulator = ( - self._backend is not None and self._backend.configuration().simulator is True - ) if self._service._channel_strategy == "q-ctrl": raise NotImplementedError("EstimatorV2 is not supported with q-ctrl channel strategy.") @@ -244,9 +241,23 @@ def _validate_options(self, options: dict) -> None: Raises: ValidationError: if validation fails. + ValueError: if validation fails. """ self._OPTIONS_CLASS(**options) + # TODO: Server should have different optimization/resilience levels for simulator + + if ( + options["resilience_level"] == 3 + and self._backend is not None + and self._backend.configuration().simulator is True + and not options["simulator"]["coupling_map"] + ): + raise ValueError( + "When the backend is a simulator and resilience_level == 3," + "a coupling map is required." + ) + @staticmethod def _validate_observables( observables: Sequence[ObservablesArrayLike] | ObservablesArrayLike, diff --git a/qiskit_ibm_runtime/options/estimator_options.py b/qiskit_ibm_runtime/options/estimator_options.py index 18f7905e2..a656b2c35 100644 --- a/qiskit_ibm_runtime/options/estimator_options.py +++ b/qiskit_ibm_runtime/options/estimator_options.py @@ -17,7 +17,7 @@ from qiskit.transpiler import CouplingMap from pydantic.dataclasses import dataclass as pydantic_dataclass -from pydantic import Field, ConfigDict, model_validator, field_validator +from pydantic import Field, ConfigDict, field_validator from .utils import ( Dict, @@ -90,11 +90,9 @@ class EstimatorOptions(OptionsV2): """ - _VERSION: int = Field(2, frozen=True) - _MAX_OPTIMIZATION_LEVEL: int = Field(3, frozen=True) - _MAX_RESILIENCE_LEVEL: int = Field(3, frozen=True) - - _is_simulator: bool = False + _VERSION: int = Field(2, frozen=True) # pylint: disable=invalid-name + _MAX_OPTIMIZATION_LEVEL: int = Field(3, frozen=True) # pylint: disable=invalid-name + _MAX_RESILIENCE_LEVEL: int = Field(3, frozen=True) # pylint: disable=invalid-name # Sadly we cannot use pydantic's built in validation because it won't work on Unset. optimization_level: Union[UnsetType, int] = Unset @@ -130,23 +128,6 @@ def _validate_resilience_level(cls, resilience_level: int) -> int: ) return resilience_level - @model_validator(mode="after") - def _validate_options(self) -> "EstimatorOptions": - """Validate the model.""" - # TODO: Server should have different optimization/resilience levels for simulator - - if ( - self.resilience_level == 3 - and self._is_simulator - and isinstance(self.simulator.coupling_map, UnsetType) # type: ignore[union-attr] - ): - raise ValueError( - "When the backend is a simulator and resilience_level == 3," - "a coupling map is required." - ) - - return self - @staticmethod def _get_program_inputs(options: dict) -> dict: """Convert the input options to program compatible inputs. diff --git a/test/unit/test_estimator.py b/test/unit/test_estimator.py index e1863d9f2..0536fafd1 100644 --- a/test/unit/test_estimator.py +++ b/test/unit/test_estimator.py @@ -80,6 +80,17 @@ def test_unsupported_values_for_estimator_options(self): _ = inst.run(self.circuit, observables=self.observables, **bad_opt) self.assertIn(list(bad_opt.keys())[0], str(exc.exception)) + def test_res_level3_simulator(self): + """Test the correct default error levels are used.""" + + session = MagicMock(spec=MockSession) + session.service.backend().configuration().simulator = True + + inst = EstimatorV2(session=session, options={"resilience_level": 3}) + with self.assertRaises(ValueError) as exc: + inst.run(self.circuit, observables=self.observables) + self.assertIn("coupling map", str(exc.exception)) + def test_run_default_options(self): """Test run using default options.""" session = MagicMock(spec=MockSession) diff --git a/test/unit/test_estimator_options.py b/test/unit/test_estimator_options.py index 10ca3a9dc..f27cf45f0 100644 --- a/test/unit/test_estimator_options.py +++ b/test/unit/test_estimator_options.py @@ -151,7 +151,6 @@ def test_coupling_map_options(self): {"execution": {"shots": 0}}, {"twirling": {"strategy": "foo"}}, {"transpilation": {"foo": "bar"}}, - {"resilience_level": 3, "_is_simulator": True}, {"zne_noise_factors": [0.5]}, {"noise_factors": [1, 3, 5]}, {"zne_extrapolator": "exponential", "zne_noise_factors": [1]}, From 665b88121a1a7ccaf2777158b8a67920ed4369b8 Mon Sep 17 00:00:00 2001 From: Jessie Yu Date: Tue, 7 Nov 2023 19:40:39 -0500 Subject: [PATCH 29/35] make image work --- .../options/estimator_options.py | 3 +++ test/unit/test_ibm_primitives_v2.py | 20 +++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/qiskit_ibm_runtime/options/estimator_options.py b/qiskit_ibm_runtime/options/estimator_options.py index a656b2c35..25fadba37 100644 --- a/qiskit_ibm_runtime/options/estimator_options.py +++ b/qiskit_ibm_runtime/options/estimator_options.py @@ -169,6 +169,9 @@ def _get_program_inputs(options: dict) -> dict: if isinstance(options.get("experimental", None), dict): inputs = merge_options(inputs, options.get("experimental")) + # Remove image + inputs.pop("image", None) + inputs["_experimental"] = True inputs["version"] = EstimatorOptions._VERSION _remove_dict_unset_values(inputs) diff --git a/test/unit/test_ibm_primitives_v2.py b/test/unit/test_ibm_primitives_v2.py index 3573b55cf..873ad301a 100644 --- a/test/unit/test_ibm_primitives_v2.py +++ b/test/unit/test_ibm_primitives_v2.py @@ -94,6 +94,26 @@ def test_runtime_options(self, primitive, env_var): for key, val in env_var.items(): self.assertEqual(run_options[key], val) + @combine( + primitive=[EstimatorV2], + opts=[ + {"experimental": {"image": "foo:bar"}}, + {"experimental": {"image": "foo:bar"}, "environment": {"log_level": "INFO"}}, + ], + ) + def test_image(self, primitive, opts): + """Test passing an image to options.""" + session = MagicMock(spec=MockSession) + options = primitive._OPTIONS_CLASS(**opts) + inst = primitive(session=session, options=options) + inst.run(self.qx, observables=self.obs) + run_options = session.run.call_args.kwargs["options"] + input_params = session.run.call_args.kwargs["inputs"] + expected = list(opts.values())[0] + for key, val in expected.items(): + self.assertEqual(run_options[key], val) + self.assertNotIn(key, input_params) + @data(EstimatorV2) def test_options_copied(self, primitive): """Test modifying original options does not affect primitives.""" From d2692ff131db186a4999f6c21c6039b369c623a8 Mon Sep 17 00:00:00 2001 From: Jessie Yu Date: Wed, 8 Nov 2023 14:35:25 -0500 Subject: [PATCH 30/35] fix merge issues --- .../options/estimator_options.py | 1 + qiskit_ibm_runtime/options/options.py | 2 +- qiskit_ibm_runtime/options/sampler_options.py | 2 +- test/unit/test_estimator_options.py | 152 ------------------ 4 files changed, 3 insertions(+), 154 deletions(-) diff --git a/qiskit_ibm_runtime/options/estimator_options.py b/qiskit_ibm_runtime/options/estimator_options.py index c5887ed1c..580856c2f 100644 --- a/qiskit_ibm_runtime/options/estimator_options.py +++ b/qiskit_ibm_runtime/options/estimator_options.py @@ -126,6 +126,7 @@ def _validate_resilience_level(cls, resilience_level: int) -> int: ) return resilience_level + # @dataclass(frozen=True) # class _ResilienceLevel0Options: # resilience_level: int = 0 diff --git a/qiskit_ibm_runtime/options/options.py b/qiskit_ibm_runtime/options/options.py index f87f01f7c..5418fe006 100644 --- a/qiskit_ibm_runtime/options/options.py +++ b/qiskit_ibm_runtime/options/options.py @@ -93,7 +93,7 @@ class OptionsV2(BaseOptions): :class:`SimulatorOptions` for all available options. """ - _VERSION: int = Field(2, frozen=True) + _VERSION: int = Field(2, frozen=True) # pylint: disable=invalid-name # Options not really related to primitives. max_execution_time: Union[UnsetType, int] = Unset diff --git a/qiskit_ibm_runtime/options/sampler_options.py b/qiskit_ibm_runtime/options/sampler_options.py index ad6cdc241..94b673fd5 100644 --- a/qiskit_ibm_runtime/options/sampler_options.py +++ b/qiskit_ibm_runtime/options/sampler_options.py @@ -68,7 +68,7 @@ class SamplerOptions(OptionsV2): """ - _MAX_OPTIMIZATION_LEVEL: int = Field(3, frozen=True) + _MAX_OPTIMIZATION_LEVEL: int = Field(3, frozen=True) # pylint: disable=invalid-name _is_simulator: bool = False # Sadly we cannot use pydantic's built in validation because it won't work on Unset. diff --git a/test/unit/test_estimator_options.py b/test/unit/test_estimator_options.py index f27cf45f0..e8c492c34 100644 --- a/test/unit/test_estimator_options.py +++ b/test/unit/test_estimator_options.py @@ -12,137 +12,18 @@ """Tests for Options class.""" -from dataclasses import asdict - from ddt import data, ddt from pydantic import ValidationError -from qiskit.providers import BackendV1 -from qiskit.providers.fake_provider import FakeManila, FakeNairobiV2 -from qiskit.transpiler import CouplingMap -from qiskit_aer.noise import NoiseModel -from qiskit_ibm_runtime.options.utils import merge_options from qiskit_ibm_runtime.options import EstimatorOptions from ..ibm_test_case import IBMTestCase -from ..utils import dict_keys_equal, dict_paritally_equal, flat_dict_partially_equal @ddt class TestEStimatorOptions(IBMTestCase): """Class for testing the Sampler class.""" - def test_merge_options(self): - """Test merging options.""" - options_vars = [ - {}, - {"resilience_level": 9}, - {"resilience_level": 8, "transpilation": {"initial_layout": [1, 2]}}, - {"shots": 99, "seed_simulator": 42}, - {"resilience_level": 99, "shots": 98, "initial_layout": [3, 4]}, - { - "initial_layout": [1, 2], - "transpilation": {"layout_method": "trivial"}, - "log_level": "INFO", - }, - ] - for new_ops in options_vars: - with self.subTest(new_ops=new_ops): - options = EstimatorOptions() - combined = merge_options(asdict(options), new_ops) - - # Make sure the values are equal. - self.assertTrue( - flat_dict_partially_equal(combined, new_ops), - f"new_ops={new_ops}, combined={combined}", - ) - # Make sure the structure didn't change. - self.assertTrue( - dict_keys_equal(combined, asdict(options)), - f"options={options}, combined={combined}", - ) - - def test_program_inputs(self): - """Test converting to program inputs from estimator options.""" - noise_model = NoiseModel.from_backend(FakeNairobiV2()) - options = EstimatorOptions( # pylint: disable=unexpected-keyword-arg - optimization_level=1, - resilience_level=2, - transpilation={"initial_layout": [1, 2], "skip_transpilation": True}, - execution={"shots": 100}, - environment={"log_level": "DEBUG"}, - simulator={"noise_model": noise_model}, - resilience={"zne_noise_factors": (1, 2, 4)}, - ) - inputs = EstimatorOptions._get_program_inputs(asdict(options)) - - expected = { - "execution": {"shots": 100, "noise_model": noise_model}, - "skip_transpilation": True, - "transpilation": { - "optimization_level": 1, - "initial_layout": [1, 2], - }, - "resilience_level": 2, - "resilience": { - "zne_noise_factors": (1.0, 2.0, 4.0), - }, - } - self.assertTrue( - dict_paritally_equal(inputs, expected), - f"inputs={inputs}, expected={expected}", - ) - - def test_init_options_with_dictionary(self): - """Test initializing options with dictionaries.""" - - options_dicts = [ - {}, - {"resilience_level": 1}, - {"simulator": {"seed_simulator": 42}}, - {"resilience_level": 1, "environment": {"log_level": "WARNING"}}, - { - "transpilation": {"initial_layout": [1, 2], "layout_method": "trivial"}, - "execution": {"shots": 100}, - }, - {"environment": {"log_level": "ERROR"}}, - ] - - for opts_dict in options_dicts: - with self.subTest(opts_dict=opts_dict): - options = asdict(EstimatorOptions(**opts_dict)) - self.assertTrue( - dict_paritally_equal(options, opts_dict), - f"options={options}, opts_dict={opts_dict}", - ) - - # Make sure the structure didn't change. - self.assertTrue( - dict_keys_equal(asdict(EstimatorOptions()), options), f"options={options}" - ) - - def test_kwargs_options(self): - """Test specifying arbitrary options.""" - with self.assertRaises(ValidationError) as exc: - _ = EstimatorOptions(foo="foo") # pylint: disable=unexpected-keyword-arg - self.assertIn("foo", str(exc.exception)) - - def test_coupling_map_options(self): - """Check that coupling_map is processed correctly for various types""" - coupling_map = {(1, 0), (2, 1), (0, 1), (1, 2)} - coupling_maps = [ - coupling_map, - list(map(list, coupling_map)), - CouplingMap(coupling_map), - ] - for variant in coupling_maps: - with self.subTest(opts_dict=variant): - options = EstimatorOptions() - options.simulator.coupling_map = variant - inputs = EstimatorOptions._get_program_inputs(asdict(options)) - resulting_cmap = inputs["transpilation"]["coupling_map"] - self.assertEqual(coupling_map, set(map(tuple, resulting_cmap))) - @data( {"optimization_level": 99}, {"resilience_level": 99}, @@ -162,36 +43,3 @@ def test_bad_inputs(self, val): with self.assertRaises(ValidationError) as exc: EstimatorOptions(**val) self.assertIn(list(val.keys())[0], str(exc.exception)) - - @data(FakeManila(), FakeNairobiV2()) - def test_simulator_set_backend(self, fake_backend): - """Test Options.simulator.set_backend method.""" - - options = EstimatorOptions() - options.simulator.seed_simulator = 42 - options.simulator.set_backend(fake_backend) - - noise_model = NoiseModel.from_backend(fake_backend) - basis_gates = ( - fake_backend.configuration().basis_gates - if isinstance(fake_backend, BackendV1) - else fake_backend.operation_names - ) - coupling_map = ( - fake_backend.configuration().coupling_map - if isinstance(fake_backend, BackendV1) - else fake_backend.coupling_map - ) - - self.assertEqual(options.simulator.coupling_map, coupling_map) - self.assertEqual(options.simulator.noise_model, noise_model) - - expected_options = EstimatorOptions() - expected_options.simulator = { - "noise_model": noise_model, - "basis_gates": basis_gates, - "coupling_map": coupling_map, - "seed_simulator": 42, - } - - self.assertDictEqual(asdict(options), asdict(expected_options)) From ef332ba268970eb4aa397fba9db02edf3df696db Mon Sep 17 00:00:00 2001 From: Jessie Yu Date: Wed, 8 Nov 2023 15:25:53 -0500 Subject: [PATCH 31/35] fix tests --- .../options/estimator_options.py | 3 --- qiskit_ibm_runtime/options/options.py | 22 +++++++++++-------- test/unit/test_ibm_primitives_v2.py | 8 +++---- test/unit/test_options.py | 1 - 4 files changed, 17 insertions(+), 17 deletions(-) diff --git a/qiskit_ibm_runtime/options/estimator_options.py b/qiskit_ibm_runtime/options/estimator_options.py index 580856c2f..1da05f3cc 100644 --- a/qiskit_ibm_runtime/options/estimator_options.py +++ b/qiskit_ibm_runtime/options/estimator_options.py @@ -77,7 +77,6 @@ class EstimatorOptions(OptionsV2): See :class:`ResilienceOptions` for all available options. execution: Execution time options. See :class:`ExecutionOptionsV2` for all available options. - execution: Execution time options. See :class:`ExecutionOptionsV2` for all available options. environment: Options related to the execution environment. See :class:`EnvironmentOptions` for all available options. @@ -90,8 +89,6 @@ class EstimatorOptions(OptionsV2): _MAX_OPTIMIZATION_LEVEL: int = Field(3, frozen=True) # pylint: disable=invalid-name _MAX_RESILIENCE_LEVEL: int = Field(3, frozen=True) # pylint: disable=invalid-name - _is_simulator: bool = False - # Sadly we cannot use pydantic's built in validation because it won't work on Unset. optimization_level: Union[UnsetType, int] = Unset resilience_level: Union[UnsetType, int] = Unset diff --git a/qiskit_ibm_runtime/options/options.py b/qiskit_ibm_runtime/options/options.py index 5418fe006..0ae0f24bf 100644 --- a/qiskit_ibm_runtime/options/options.py +++ b/qiskit_ibm_runtime/options/options.py @@ -112,26 +112,27 @@ def _set_if_exists(name: str, _inputs: dict, _options: dict) -> None: if name in _options: _inputs[name] = _options[name] - sim_options = options.get("simulator", {}) + options_copy = copy.deepcopy(options) + sim_options = options_copy.get("simulator", {}) inputs = {} - inputs["transpilation"] = copy.copy(options.get("transpilation", {})) + inputs["transpilation"] = options_copy.get("transpilation", {}) inputs["skip_transpilation"] = inputs["transpilation"].pop("skip_transpilation") - coupling_map = sim_options.get("coupling_map", None) + coupling_map = sim_options.get("coupling_map", Unset) # TODO: We can just move this to json encoder if isinstance(coupling_map, CouplingMap): coupling_map = list(map(list, coupling_map.get_edges())) inputs["transpilation"].update( { - "optimization_level": options.get("optimization_level"), + "optimization_level": options_copy.get("optimization_level", Unset), "coupling_map": coupling_map, - "basis_gates": sim_options.get("basis_gates", None), + "basis_gates": sim_options.get("basis_gates", Unset), } ) for fld in ["resilience_level", "resilience", "twirling", "dynamical_decoupling"]: - _set_if_exists(fld, inputs, options) + _set_if_exists(fld, inputs, options_copy) - inputs["execution"] = options.get("execution", {}) + inputs["execution"] = options_copy.get("execution", {}) inputs["execution"].update( { "noise_model": sim_options.get("noise_model", Unset), @@ -140,8 +141,11 @@ def _set_if_exists(name: str, _inputs: dict, _options: dict) -> None: ) # Add arbitrary experimental options - if isinstance(options.get("experimental", None), dict): - inputs = merge_options(inputs, options.get("experimental")) + if isinstance(options_copy.get("experimental", None), dict): + inputs = merge_options(inputs, options_copy.get("experimental")) + + # Remove image + inputs.pop("image", None) inputs["_experimental"] = True inputs["version"] = OptionsV2._VERSION diff --git a/test/unit/test_ibm_primitives_v2.py b/test/unit/test_ibm_primitives_v2.py index 72b40e2e4..2ee404497 100644 --- a/test/unit/test_ibm_primitives_v2.py +++ b/test/unit/test_ibm_primitives_v2.py @@ -141,7 +141,7 @@ def test_init_with_backend_str(self, primitive): inst = primitive(backend=backend_name) mock_service.assert_called_once() self.assertIsNone(inst.session) - inst.run(self.qx, observables=self.obs) + inst.run(**get_primitive_inputs(inst)) mock_service_inst.run.assert_called_once() runtime_options = mock_service_inst.run.call_args.kwargs["options"] self.assertEqual(runtime_options["backend"], backend_name) @@ -170,7 +170,7 @@ def test_init_with_backend_instance(self, primitive): service.reset_mock() inst = primitive(backend=backend) self.assertIsNone(inst.session) - inst.run(self.qx, observables=self.obs) + inst.run(**get_primitive_inputs(inst)) service.run.assert_called_once() runtime_options = service.run.call_args.kwargs["options"] self.assertEqual(runtime_options["backend"], backend.name) @@ -189,7 +189,7 @@ def test_init_with_backend_session(self, primitive): session.reset_mock() inst = primitive(session=session, backend=backend_name) self.assertIsNotNone(inst.session) - inst.run(self.qx, observables=self.obs) + inst.run(**get_primitive_inputs(inst)) session.run.assert_called_once() @data(EstimatorV2, SamplerV2) @@ -324,7 +324,7 @@ def test_run_overwrite_runtime_options(self, primitive): for options in options_vars: with self.subTest(options=options): inst = primitive(session=session) - inst.run(self.qx, observables=self.obs, **options) + inst.run(**get_primitive_inputs(inst), **options) rt_options = session.run.call_args.kwargs["options"] self._assert_dict_partially_equal(rt_options, options) diff --git a/test/unit/test_options.py b/test/unit/test_options.py index 36bba39cf..5eb5aad50 100644 --- a/test/unit/test_options.py +++ b/test/unit/test_options.py @@ -505,7 +505,6 @@ def test_simulator_set_backend(self, opt_cls, fake_backend): {"execution": {"shots": 0}}, {"twirling": {"strategy": "foo"}}, {"transpilation": {"foo": "bar"}}, - {"resilience_level": 3, "_is_simulator": True}, {"zne_noise_factors": [0.5]}, {"noise_factors": [1, 3, 5]}, {"zne_extrapolator": "exponential", "zne_noise_factors": [1]}, From 9dd2c7d30d4f294e61bcdb6b339d492f11dd3069 Mon Sep 17 00:00:00 2001 From: Jessie Yu Date: Thu, 16 Nov 2023 11:30:24 -0500 Subject: [PATCH 32/35] fix tests --- qiskit_ibm_runtime/estimator.py | 2 +- test/unit/test_estimator.py | 3 -- test/unit/test_ibm_primitives_v2.py | 4 +-- test/utils.py | 56 +---------------------------- 4 files changed, 4 insertions(+), 61 deletions(-) diff --git a/qiskit_ibm_runtime/estimator.py b/qiskit_ibm_runtime/estimator.py index abf84c538..b180a760c 100644 --- a/qiskit_ibm_runtime/estimator.py +++ b/qiskit_ibm_runtime/estimator.py @@ -273,7 +273,7 @@ def _check_and_init(obs: Any) -> Any: return tuple(_check_and_init(obs_) for obs_ in obs) elif not isinstance(obs, (Pauli, SparsePauliOp)) and isinstance(obs, BaseOperator): issue_deprecation_msg( - msg="Only Pauli and SparsePauliOp operators can be used as observables.", + msg="Only Pauli and SparsePauliOp operators can be used as observables", version="0.13", remedy="", ) diff --git a/test/unit/test_estimator.py b/test/unit/test_estimator.py index 0536fafd1..4298ae60c 100644 --- a/test/unit/test_estimator.py +++ b/test/unit/test_estimator.py @@ -143,7 +143,6 @@ def test_observable_types_single_circuit(self): SparsePauliOp(["IX", "YZ"]), {"YZ": 1 + 2j}, {Pauli("XX"): 1 + 2j}, - random_hermitian((2, 2)), [["XX", "YY"]], [[Pauli("XX"), Pauli("YY")]], [[SparsePauliOp(["XX"]), SparsePauliOp(["YY"])]], @@ -159,7 +158,6 @@ def test_observable_types_single_circuit(self): {Pauli("YY"): 1 + 2j}, ] ], - [random_pauli_list(2, 2)], ] circuit = QuantumCircuit(2) @@ -188,7 +186,6 @@ def test_observable_types_multi_circuits(self): [[SparsePauliOp(["XX"]), SparsePauliOp(["YY"])]] * num_qx, [[{"XX": 1 + 2j}, {"YY": 1 + 2j}]] * num_qx, [[{Pauli("XX"): 1 + 2j}, {Pauli("YY"): 1 + 2j}]] * num_qx, - [random_pauli_list(2, 2)] * num_qx, ] circuit = QuantumCircuit(2) diff --git a/test/unit/test_ibm_primitives_v2.py b/test/unit/test_ibm_primitives_v2.py index 873ad301a..5e166e76b 100644 --- a/test/unit/test_ibm_primitives_v2.py +++ b/test/unit/test_ibm_primitives_v2.py @@ -31,7 +31,7 @@ Session, ) from qiskit_ibm_runtime.ibm_backend import IBMBackend -import qiskit_ibm_runtime.session as session_pkg +from qiskit_ibm_runtime.utils.default_session import _DEFAULT_SESSION from qiskit_ibm_runtime import EstimatorV2 from qiskit_ibm_runtime.estimator import Estimator as IBMBaseEstimator @@ -58,7 +58,7 @@ def setUpClass(cls): def tearDown(self) -> None: super().tearDown() - session_pkg._DEFAULT_SESSION.set(None) + _DEFAULT_SESSION.set(None) @data(EstimatorV2) def test_dict_options(self, primitive): diff --git a/test/utils.py b/test/utils.py index b8e057655..4bc4e08ce 100644 --- a/test/utils.py +++ b/test/utils.py @@ -23,8 +23,7 @@ from ddt import data, unpack from qiskit.circuit import QuantumCircuit -from qiskit.compiler import transpile, assemble -from qiskit.qobj import QasmQobj +from qiskit.compiler import transpile from qiskit.test.reference_circuits import ReferenceCircuits from qiskit.test.utils import generate_cases from qiskit.providers.jobstatus import JOB_FINAL_STATES, JobStatus @@ -307,59 +306,6 @@ def submit_and_cancel(backend: IBMBackend, logger: logging.Logger) -> RuntimeJob return job -def submit_job_bad_shots(backend: IBMBackend) -> RuntimeJob: - """Submit a job that will fail due to too many shots. - - Args: - backend: Backend to submit the job to. - - Returns: - Submitted job. - """ - qobj = bell_in_qobj(backend=backend) - # Modify the number of shots to be an invalid amount. - qobj.config.shots = backend.configuration().max_shots + 10000 - job_to_fail = backend._submit_job(qobj) - return job_to_fail - - -def submit_job_one_bad_instr(backend: IBMBackend) -> RuntimeJob: - """Submit a job that contains one good and one bad instruction. - - Args: - backend: Backend to submit the job to. - - Returns: - Submitted job. - """ - qc_new = transpile(ReferenceCircuits.bell(), backend) - if backend.configuration().simulator: - # Specify method so it doesn't fail at method selection. - qobj = assemble([qc_new] * 2, backend=backend, method="statevector") - else: - qobj = assemble([qc_new] * 2, backend=backend) - qobj.experiments[1].instructions[1].name = "bad_instruction" - job = backend._submit_job(qobj) - return job - - -def bell_in_qobj(backend: IBMBackend, shots: int = 1024) -> QasmQobj: - """Return a bell circuit in Qobj format. - - Args: - backend: Backend to use for transpiling the circuit. - shots: Number of shots. - - Returns: - A bell circuit in Qobj format. - """ - return assemble( - transpile(ReferenceCircuits.bell(), backend=backend), - backend=backend, - shots=shots, - ) - - def combine(**kwargs): """Decorator to create combinations and tests @combine(level=[0, 1, 2, 3], From d9875722007c7ff100ce205a5d3da90e9b9e54d2 Mon Sep 17 00:00:00 2001 From: Jessie Yu Date: Thu, 16 Nov 2023 11:39:41 -0500 Subject: [PATCH 33/35] lint --- test/unit/test_estimator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/unit/test_estimator.py b/test/unit/test_estimator.py index 4298ae60c..9d8b30cf4 100644 --- a/test/unit/test_estimator.py +++ b/test/unit/test_estimator.py @@ -15,7 +15,7 @@ from unittest.mock import MagicMock from qiskit import QuantumCircuit -from qiskit.quantum_info import SparsePauliOp, Pauli, random_hermitian, random_pauli_list +from qiskit.quantum_info import SparsePauliOp, Pauli from qiskit.circuit import Parameter import numpy as np From 17c3fb765b7f9acc669bd0d22bf998c19ea878e6 Mon Sep 17 00:00:00 2001 From: Jessie Yu Date: Thu, 16 Nov 2023 13:42:15 -0500 Subject: [PATCH 34/35] add seed_estimator --- .../options/estimator_options.py | 63 +------------------ qiskit_ibm_runtime/options/options.py | 8 ++- test/unit/test_options.py | 6 +- 3 files changed, 15 insertions(+), 62 deletions(-) diff --git a/qiskit_ibm_runtime/options/estimator_options.py b/qiskit_ibm_runtime/options/estimator_options.py index 1da05f3cc..abd01ab5d 100644 --- a/qiskit_ibm_runtime/options/estimator_options.py +++ b/qiskit_ibm_runtime/options/estimator_options.py @@ -70,6 +70,8 @@ class EstimatorOptions(OptionsV2): Allowed values are ``"XX"``, ``"XpXm"``, ``"XY4"``. Default: None + seed_estimator: Seed used to control sampling. + transpilation: Transpilation options. See :class:`TranspilationOptions` for all available options. @@ -93,6 +95,7 @@ class EstimatorOptions(OptionsV2): optimization_level: Union[UnsetType, int] = Unset resilience_level: Union[UnsetType, int] = Unset dynamical_decoupling: Union[UnsetType, DDSequenceType] = Unset + seed_estimator: Union[UnsetType, int] = Unset transpilation: Union[TranspilationOptions, Dict] = Field(default_factory=TranspilationOptions) resilience: Union[ResilienceOptionsV2, Dict] = Field(default_factory=ResilienceOptionsV2) execution: Union[ExecutionOptionsV2, Dict] = Field(default_factory=ExecutionOptionsV2) @@ -122,63 +125,3 @@ def _validate_resilience_level(cls, resilience_level: int) -> int: f"0-{EstimatorOptions._MAX_RESILIENCE_LEVEL}" ) return resilience_level - - -# @dataclass(frozen=True) -# class _ResilienceLevel0Options: -# resilience_level: int = 0 -# resilience: ResilienceOptions = field( -# default_factory=lambda: ResilienceOptions( -# measure_noise_mitigation=False, zne_mitigation=False, pec_mitigation=False -# ) -# ) -# twirling: TwirlingOptions = field( -# default_factory=lambda: TwirlingOptions(gates=False, measure=False) -# ) - - -# @dataclass(frozen=True) -# class _ResilienceLevel1Options: -# resilience_level: int = 1 -# resilience: ResilienceOptions = field( -# default_factory=lambda: ResilienceOptions( -# measure_noise_mitigation=True, zne_mitigation=False, pec_mitigation=False -# ) -# ) -# twirling: TwirlingOptions = field( -# default_factory=lambda: TwirlingOptions(gates=False, measure=True, strategy="active-accum") -# ) - - -# @dataclass(frozen=True) -# class _ResilienceLevel2Options: -# resilience_level: int = 2 -# resilience: ResilienceOptions = field( -# default_factory=lambda: ResilienceOptions( -# measure_noise_mitigation=True, pec_mitigation=False, **asdict(_ZneOptions()) -# ) -# ) -# twirling: TwirlingOptions = field( -# default_factory=lambda: TwirlingOptions(gates=True, measure=True, strategy="active-accum") -# ) - - -# @dataclass(frozen=True) -# class _ResilienceLevel3Options: -# resilience_level: int = 3 -# resilience: ResilienceOptions = field( -# default_factory=lambda: ResilienceOptions( -# measure_noise_mitigation=True, zne_mitigation=False, **asdict(_PecOptions()) -# ) -# ) -# twirling: TwirlingOptions = field( -# default_factory=lambda: TwirlingOptions(gates=True, measure=True, strategy="active") -# ) - - -# _DEFAULT_RESILIENCE_LEVEL_OPTIONS = { -# 0: _ResilienceLevel0Options(), -# 1: _ResilienceLevel1Options(), -# 2: _ResilienceLevel2Options(), -# 3: _ResilienceLevel3Options(), -# } diff --git a/qiskit_ibm_runtime/options/options.py b/qiskit_ibm_runtime/options/options.py index 0ae0f24bf..27419acf4 100644 --- a/qiskit_ibm_runtime/options/options.py +++ b/qiskit_ibm_runtime/options/options.py @@ -129,7 +129,13 @@ def _set_if_exists(name: str, _inputs: dict, _options: dict) -> None: } ) - for fld in ["resilience_level", "resilience", "twirling", "dynamical_decoupling"]: + for fld in [ + "resilience_level", + "resilience", + "twirling", + "dynamical_decoupling", + "seed_estimator", + ]: _set_if_exists(fld, inputs, options_copy) inputs["execution"] = options_copy.get("execution", {}) diff --git a/test/unit/test_options.py b/test/unit/test_options.py index 5eb5aad50..ec4d466cc 100644 --- a/test/unit/test_options.py +++ b/test/unit/test_options.py @@ -364,7 +364,11 @@ def test_program_inputs(self, opt_cls): estimator_extra = {} if isinstance(opt_cls, EstimatorOptions): - estimator_extra = {"resilience_level": 3, "resilience": resilience} + estimator_extra = { + "resilience_level": 3, + "resilience": resilience, + "seed_estimator": 42, + } opt = opt_cls( max_execution_time=100, From ab493e5f25251ce799a8a9114601fc6625a435b9 Mon Sep 17 00:00:00 2001 From: Jessie Yu Date: Thu, 16 Nov 2023 13:57:52 -0500 Subject: [PATCH 35/35] resolve merge issues --- qiskit_ibm_runtime/estimator.py | 4 ---- qiskit_ibm_runtime/options/sampler_options.py | 3 +-- qiskit_ibm_runtime/sampler.py | 3 --- test/unit/test_options.py | 3 +-- 4 files changed, 2 insertions(+), 11 deletions(-) diff --git a/qiskit_ibm_runtime/estimator.py b/qiskit_ibm_runtime/estimator.py index 417ed43d3..9b6abb1f1 100644 --- a/qiskit_ibm_runtime/estimator.py +++ b/qiskit_ibm_runtime/estimator.py @@ -37,10 +37,6 @@ from .utils.qctrl import validate as qctrl_validate from .utils.deprecation import issue_deprecation_msg -# TODO: remove when we have real v2 base estimator -from .qiskit.primitives import BaseEstimatorV2 -from .utils.deprecation import issue_deprecation_msg - # TODO: remove when we have real v2 base estimator from .qiskit.primitives import BaseEstimatorV2 diff --git a/qiskit_ibm_runtime/options/sampler_options.py b/qiskit_ibm_runtime/options/sampler_options.py index 94b673fd5..03c4d9359 100644 --- a/qiskit_ibm_runtime/options/sampler_options.py +++ b/qiskit_ibm_runtime/options/sampler_options.py @@ -10,7 +10,7 @@ # copyright notice, and modified files need to carry a notice indicating # that they have been altered from the originals. -"""Primitive options.""" +"""Sampler options.""" from typing import Union, Literal @@ -69,7 +69,6 @@ class SamplerOptions(OptionsV2): """ _MAX_OPTIMIZATION_LEVEL: int = Field(3, frozen=True) # pylint: disable=invalid-name - _is_simulator: bool = False # Sadly we cannot use pydantic's built in validation because it won't work on Unset. optimization_level: Union[UnsetType, int] = Unset diff --git a/qiskit_ibm_runtime/sampler.py b/qiskit_ibm_runtime/sampler.py index 8a8caffef..bddde67fc 100644 --- a/qiskit_ibm_runtime/sampler.py +++ b/qiskit_ibm_runtime/sampler.py @@ -107,9 +107,6 @@ def __init__( Sampler.__init__(self) BasePrimitiveV2.__init__(self, backend=backend, session=session, options=options) - self.options._is_simulator = ( - self._backend is not None and self._backend.configuration().simulator is True - ) if self._service._channel_strategy == "q-ctrl": raise NotImplementedError("SamplerV2 is not supported with q-ctrl channel strategy.") diff --git a/test/unit/test_options.py b/test/unit/test_options.py index 6811d0417..ec4d466cc 100644 --- a/test/unit/test_options.py +++ b/test/unit/test_options.py @@ -34,8 +34,7 @@ class TestOptions(IBMTestCase): """Class for testing the Options class.""" - @data(Options, EstimatorOptions) - def test_merge_options(self, opt_cls): + def test_merge_options(self): """Test merging options.""" options_vars = [ {},