From f5597aea7f32194855866dd4e8d72df0376bdf42 Mon Sep 17 00:00:00 2001 From: Scott Carda Date: Fri, 13 Feb 2026 14:32:49 -0800 Subject: [PATCH 1/6] Initial Generic Qiskit Backend --- .../azure/quantum/qiskit/backends/__init__.py | 8 +- .../azure/quantum/qiskit/backends/generic.py | 85 ++++++++++++ .../azure/quantum/qiskit/provider.py | 124 +++++++++++++----- azure-quantum/tests/test_qiskit.py | 86 +++++++++++- 4 files changed, 264 insertions(+), 39 deletions(-) create mode 100644 azure-quantum/azure/quantum/qiskit/backends/generic.py diff --git a/azure-quantum/azure/quantum/qiskit/backends/__init__.py b/azure-quantum/azure/quantum/qiskit/backends/__init__.py index c401ba374..8f5798048 100644 --- a/azure-quantum/azure/quantum/qiskit/backends/__init__.py +++ b/azure-quantum/azure/quantum/qiskit/backends/__init__.py @@ -37,8 +37,10 @@ QCIQPUBackend, ) +from azure.quantum.qiskit.backends.generic import ( + AzureGenericQirBackend, +) + from .backend import AzureBackendBase -__all__ = [ - "AzureBackendBase" -] \ No newline at end of file +__all__ = ["AzureBackendBase"] diff --git a/azure-quantum/azure/quantum/qiskit/backends/generic.py b/azure-quantum/azure/quantum/qiskit/backends/generic.py new file mode 100644 index 000000000..5e8f00d11 --- /dev/null +++ b/azure-quantum/azure/quantum/qiskit/backends/generic.py @@ -0,0 +1,85 @@ +## +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +## + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Dict, Optional + +from azure.quantum.version import __version__ + +try: + from qiskit.providers import Options + from qsharp import TargetProfile +except ImportError as exc: + raise ImportError( + "Missing optional 'qiskit' dependencies. \ +To install run: pip install azure-quantum[qiskit]" + ) from exc + +from .backend import AzureBackendConfig, AzureQirBackend + +if TYPE_CHECKING: + from azure.quantum.qiskit import AzureQuantumProvider + + +_DEFAULT_SHOTS_COUNT = 500 + + +class AzureGenericQirBackend(AzureQirBackend): + """Fallback QIR backend for arbitrary Azure Quantum workspace targets. + + This backend is created dynamically by :class:`~azure.quantum.qiskit.provider.AzureQuantumProvider` + for targets present in the workspace that do not have a dedicated Qiskit backend class. + + It submits Qiskit circuits using QIR (`qir.v1`) payloads. + """ + + _SHOTS_PARAM_NAME = "shots" + + def __init__( + self, + name: str, + provider: "AzureQuantumProvider", + *, + provider_id: str, + num_qubits: Optional[int] = None, + description: Optional[str] = None, + **kwargs: Any, + ): + self._provider_id = provider_id + + azure_config: Dict[str, Any] = self._azure_config() + azure_config.update({"provider_id": provider_id, "is_default": False}) + + config = AzureBackendConfig.from_dict( + { + "backend_name": name, + "backend_version": __version__, + "simulator": False, + "local": False, + "coupling_map": None, + "description": description + or f"Azure Quantum target '{name}' (generic QIR backend)", + "basis_gates": self._basis_gates(), + "memory": False, + "n_qubits": num_qubits, + "conditional": False, + "max_shots": None, + "open_pulse": False, + "gates": [{"name": "TODO", "parameters": [], "qasm_def": "TODO"}], + "azure": azure_config, + } + ) + + super().__init__(config, provider, **kwargs) + + @classmethod + def _default_options(cls) -> Options: + # Default to the most conservative QIR profile; users can override per-run via + # `target_profile=` in backend.run(...). + return Options( + **{cls._SHOTS_PARAM_NAME: _DEFAULT_SHOTS_COUNT}, + target_profile=TargetProfile.Base, + ) diff --git a/azure-quantum/azure/quantum/qiskit/provider.py b/azure-quantum/azure/quantum/qiskit/provider.py index d95f8ae36..92e8d1b48 100644 --- a/azure-quantum/azure/quantum/qiskit/provider.py +++ b/azure-quantum/azure/quantum/qiskit/provider.py @@ -5,7 +5,7 @@ import warnings import inspect -from typing import Dict, List, Optional, Tuple, Type +from typing import Dict, List, Optional, Tuple, Type, Mapping, Any from abc import ABC from azure.quantum import Workspace @@ -23,24 +23,52 @@ from azure.quantum.qiskit.backends.backend import AzureBackendBase from azure.quantum.qiskit.job import AzureQuantumJob from azure.quantum.qiskit.backends import * +from azure.quantum.qiskit.backends.generic import AzureGenericQirBackend +from azure.quantum._client.models import TargetStatus QISKIT_USER_AGENT = "azure-quantum-qiskit" + class AzureQuantumProvider(ABC): - - def __init__(self, workspace: Optional[Workspace]=None, **kwargs): + + @staticmethod + def _target_status_num_qubits(target_status: TargetStatus) -> Optional[int]: + try: + value = getattr(target_status, "num_qubits") + if isinstance(value, int): + return value + except Exception: + pass + + get_method = getattr(target_status, "get", None) + if callable(get_method): + value = get_method("numQubits", get_method("num_qubits")) + if isinstance(value, int): + return value + + if isinstance(target_status, Mapping): + value = target_status.get("numQubits", target_status.get("num_qubits")) + if isinstance(value, int): + return value + + return None + + def __init__(self, workspace: Optional[Workspace] = None, **kwargs): """Class for interfacing with the Azure Quantum service using Qiskit quantum circuits. - :param workspace: Azure Quantum workspace. If missing it will create a new Workspace passing `kwargs` to the constructor. Defaults to None. + :param workspace: Azure Quantum workspace. If missing it will create a new Workspace passing `kwargs` to the constructor. Defaults to None. :type workspace: Workspace """ if kwargs is not None and len(kwargs) > 0: from warnings import warn - warn(f"""Consider passing \"workspace\" argument explicitly. - The ability to initialize AzureQuantumProvider with arguments {', '.join(f'"{argName}"' for argName in kwargs)} is going to be deprecated in future versions.""", - DeprecationWarning, - stacklevel=2) + + warn( + f"""Consider passing \"workspace\" argument explicitly. + The ability to initialize AzureQuantumProvider with arguments {', '.join(f'"{argName}"' for argName in kwargs)} is going to be deprecated in future versions.""", + DeprecationWarning, + stacklevel=2, + ) if workspace is None: workspace = Workspace(**kwargs) @@ -85,7 +113,7 @@ def get_backend(self, name=None, **kwargs) -> AzureBackendBase: def backends(self, name=None, **kwargs): """Return a list of backends matching the specified filtering. - + Args: name (str): name of the backend. **kwargs: dict used for filtering. @@ -98,15 +126,13 @@ def backends(self, name=None, **kwargs): if self._backends is None: self._backends = self._init_backends() - if name: - if name not in self._backends: - raise QiskitBackendNotFoundError( - f"The '{name}' backend is not installed in your system." - ) - provider_id = kwargs.get("provider_id", None) - allowed_targets = self._get_allowed_targets_from_workspace(name, provider_id) + # Query targets available in the workspace. We'll use this both for workspace + # filtering and to synthesize fallback backends for targets without dedicated + # Qiskit backend classes. + status_by_target = self._get_workspace_target_status_map(name, provider_id) + allowed_targets: List[Tuple[str, str]] = list(status_by_target.keys()) workspace_allowed = lambda backend: self._is_available_in_ws( allowed_targets, backend @@ -115,6 +141,30 @@ def backends(self, name=None, **kwargs): # flatten the available backends backend_list = [x for v in self._backends.values() for x in v] + # Add a generic QIR backend for targets that exist in the workspace but are + # missing from the installed backend classes. + existing_pairs = set() + for backend in backend_list: + try: + config = backend.configuration().to_dict() + except Exception: + continue + azure_cfg = config.get("azure", {}) or {} + existing_pairs.add((backend.name, azure_cfg.get("provider_id"))) + + for target_id, pid in allowed_targets: + if (target_id, pid) in existing_pairs: + continue + status = status_by_target.get((target_id, pid)) + backend_list.append( + AzureGenericQirBackend( + name=target_id, + provider=self, + provider_id=pid, + num_qubits=self._target_status_num_qubits(status), + ) + ) + # filter by properties specified in the kwargs and filter function filtered_backends: List[Backend] = self._filter_backends( backend_list, filters=workspace_allowed, **kwargs @@ -128,17 +178,17 @@ def backends(self, name=None, **kwargs): ), filtered_backends, ) - ) + ) # If default backends were found - return them, otherwise return the filtered_backends collection. - # The latter case could happen where there's no default backend defined for the specified target. - if len(default_backends) > 0: + # The latter case could happen where there's no default backend defined for the specified target. + if len(default_backends) > 0: return default_backends return filtered_backends def get_job(self, job_id) -> AzureQuantumJob: """Returns the Job instance associated with the given id. - + Args: job_id (str): Id of the Job to return. Returns: @@ -159,14 +209,20 @@ def _is_available_in_ws( return True return False - def _get_allowed_targets_from_workspace( - self, name: str, provider_id: str - ) -> List[Tuple[str, str]]: + def _get_workspace_target_status_map( + self, name: Optional[str] = None, provider_id: Optional[str] = None + ) -> Dict[Tuple[str, str], TargetStatus]: + """Return workspace targets keyed by (target_id, provider_id). + + This is a thin wrapper over `Workspace._get_target_status` that preserves + the full status objects so callers can read metadata (e.g. num qubits) + without needing additional workspace queries. + """ target_statuses = self._workspace._get_target_status(name, provider_id) - candidates: List[Tuple[str, str]] = [] - for provider_id, status in target_statuses: - candidates.append((status.id, provider_id)) - return candidates + by_target: Dict[Tuple[str, str], TargetStatus] = {} + for pid, status in target_statuses: + by_target[(status.id, pid)] = status + return by_target def _get_candidate_subclasses(self, subtype: Type[Backend]): if not inspect.isabstract(subtype): @@ -178,7 +234,6 @@ def _get_candidate_subclasses(self, subtype: Type[Backend]): for leaf in self._get_candidate_subclasses(subclass): yield leaf - def _init_backends(self) -> Dict[str, List[Backend]]: instances: Dict[str, List[Backend]] = {} subclasses = list(self._get_candidate_subclasses(subtype=AzureBackendBase)) @@ -218,9 +273,7 @@ def _match_all(self, obj, criteria): def _match_config(self, obj, key, value): """Return True if the criteria matches the base config or azure config.""" - return obj.get(key, None) == value or self._match_azure_config( - obj, key, value - ) + return obj.get(key, None) == value or self._match_azure_config(obj, key, value) def _match_azure_config(self, obj, key, value): """Return True if the criteria matches the azure config.""" @@ -239,7 +292,7 @@ def _filter_backends( or from a boolean callable. The criteria for filtering can be specified via `**kwargs` or as a callable via `filters`, and the backends must fulfill all specified conditions. - + Args: backends (list[Backend]): list of backends. filters (callable): filtering conditions as a callable. @@ -257,7 +310,8 @@ def _filter_backends( # their configuration to be considered for filtering print(f"Looking for {key} with {value}") if any( - self._has_config_value(backend.configuration().to_dict(), key) for backend in backends + self._has_config_value(backend.configuration().to_dict(), key) + for backend in backends ): configuration_filters[key] = value else: @@ -277,9 +331,9 @@ def _filter_backends( warnings.warn( f"Specified filters {unknown_filters} are not supported by the available backends." ) - + backends = list(filter(filters, backends)) - + return backends def __eq__(self, other): diff --git a/azure-quantum/tests/test_qiskit.py b/azure-quantum/tests/test_qiskit.py index 2cb349c44..4946209d6 100644 --- a/azure-quantum/tests/test_qiskit.py +++ b/azure-quantum/tests/test_qiskit.py @@ -25,6 +25,7 @@ from azure.quantum.qiskit.job import AzureQuantumJob from azure.quantum.qiskit.backends.backend import QIR_BASIS_GATES +from azure.quantum.qiskit.backends.generic import AzureGenericQirBackend from azure.quantum.qiskit.backends.ionq import ( IonQSimulatorBackend, IonQSimulatorQirBackend, @@ -35,8 +36,49 @@ QuantinuumEmulatorBackend, QuantinuumEmulatorQirBackend, ) +from azure.quantum._client.models import TargetStatus -from mock_client import create_default_workspace +from mock_client import create_default_workspace, _paged + +from types import SimpleNamespace + + +def _seed_workspace_target( + monkeypatch: pytest.MonkeyPatch, + ws, + *, + provider_id: str, + target_id: str, + num_qubits: int | None = None, +) -> None: + """Inject a provider+target into the offline Workspace mock. + + The Qiskit provider discovers targets via `Workspace._get_target_status()`, + which iterates `ws._client.services.providers.list()`. + """ + + # `AzureQuantumProvider.__init__` appends a user agent to the Workspace, which + # recreates the underlying client (and would wipe our patched providers.list). + # For this offline-only test, keep the existing mock client. + if hasattr(ws, "_connection_params") and hasattr( + ws._connection_params, "on_new_client_request" + ): + ws._connection_params.on_new_client_request = None + + target_status = TargetStatus( + { + "id": target_id, + "currentAvailability": "Available", + "averageQueueTime": 0, + "numQubits": num_qubits, + } + ) + provider = SimpleNamespace(id=provider_id, targets=[target_status]) + monkeypatch.setattr( + ws._client.services.providers, + "list", + lambda *args, **kwargs: _paged([provider]), + ) def _patch_upload_input_data(monkeypatch: pytest.MonkeyPatch) -> None: @@ -385,3 +427,45 @@ def test_qir_target_profile_from_deprecated_target_capability(): profile = backend._get_target_profile(input_params) assert profile == TargetProfile.Base assert "target_profile" not in input_params + + +def test_generic_qir_backend_created_for_unknown_workspace_target( + monkeypatch: pytest.MonkeyPatch, +): + _patch_upload_input_data(monkeypatch) + + ws = create_default_workspace() + _seed_workspace_target( + monkeypatch, + ws, + provider_id="acme", + target_id="acme.qpu", + num_qubits=5, + ) + + provider = AzureQuantumProvider(workspace=ws) + backend = provider.get_backend("acme.qpu") + + assert isinstance(backend, AzureGenericQirBackend) + + # Avoid calling `backend.run()` (requires qsharp for QIR generation). + input_params = backend._get_input_params({}, shots=11) + job = backend._run( + job_name="offline-generic", + input_data=b"; QIR placeholder", + input_params=input_params, + metadata={}, + ) + + details = ws._client.services.jobs.get( + ws.subscription_id, + ws.resource_group, + ws.name, + job.id(), + ) + + assert details.provider_id == "acme" + assert details.target == "acme.qpu" + assert details.input_data_format == "qir.v1" + assert details.output_data_format == "microsoft.quantum-results.v2" + assert details.input_params["shots"] == 11 From 281e42b12ea96af154aed00ee260b6552c6feaa5 Mon Sep 17 00:00:00 2001 From: Scott Carda Date: Fri, 13 Feb 2026 14:33:16 -0800 Subject: [PATCH 2/6] Ensure metadata are strings --- .../azure/quantum/qiskit/backends/backend.py | 93 +++--- azure-quantum/azure/quantum/qiskit/job.py | 271 +++++++++++------- 2 files changed, 216 insertions(+), 148 deletions(-) diff --git a/azure-quantum/azure/quantum/qiskit/backends/backend.py b/azure-quantum/azure/quantum/qiskit/backends/backend.py index 0ca46fc04..ead2806eb 100644 --- a/azure-quantum/azure/quantum/qiskit/backends/backend.py +++ b/azure-quantum/azure/quantum/qiskit/backends/backend.py @@ -46,6 +46,7 @@ try: # Qiskit 1.x legacy support from qiskit.providers.models import BackendConfiguration # type: ignore + BackendConfigurationType = BackendConfiguration from qiskit.qobj import QasmQobj, PulseQobj # type: ignore @@ -263,15 +264,11 @@ def from_dict(cls, data: Mapping[str, Any]) -> "AzureBackendConfig": ) @classmethod - def from_backend_configuration( - cls, configuration: Any - ) -> "AzureBackendConfig": + def from_backend_configuration(cls, configuration: Any) -> "AzureBackendConfig": return cls.from_dict(configuration.to_dict()) -def _ensure_backend_config( - configuration: Any -) -> AzureBackendConfig: +def _ensure_backend_config(configuration: Any) -> AzureBackendConfig: if isinstance(configuration, AzureBackendConfig): return configuration @@ -289,15 +286,12 @@ def _ensure_backend_config( class AzureBackendBase(Backend, SessionHost): # Name of the provider's input parameter which specifies number of shots for a submitted job. - # If None, backend will not pass this input parameter. + # If None, backend will not pass this input parameter. _SHOTS_PARAM_NAME = None @abstractmethod def __init__( - self, - configuration: Any, - provider: "AzureQuantumProvider" = None, - **fields + self, configuration: Any, provider: "AzureQuantumProvider" = None, **fields ): if configuration is None: raise ValueError("Backend configuration is required for Azure backends") @@ -339,19 +333,19 @@ def _build_target(self, configuration: AzureBackendConfig) -> Target: target.add_instruction(instruction) return target - + @abstractmethod def run( self, run_input: Union[QuantumCircuit, List[QuantumCircuit]] = [], - shots: int = None, + shots: int = None, **options, ) -> AzureQuantumJob: """Run on the backend. This method returns a :class:`~azure.quantum.qiskit.job.AzureQuantumJob` object - that runs circuits. + that runs circuits. Args: run_input (QuantumCircuit or List[QuantumCircuit]): An individual or a @@ -399,6 +393,7 @@ def target(self) -> Target: @property def max_circuits(self) -> Optional[int]: return 1 + def retrieve_job(self, job_id) -> AzureQuantumJob: """Returns the Job instance associated with the given id.""" return self.provider.get_job(job_id) @@ -415,8 +410,10 @@ def _get_output_data_format(self, options: Dict[str, Any] = {}) -> str: output_data_format = options.pop("output_data_format", azure_defined_override) return output_data_format - - def _get_input_params(self, options: Dict[str, Any], shots: int = None) -> Dict[str, Any]: + + def _get_input_params( + self, options: Dict[str, Any], shots: int = None + ) -> Dict[str, Any]: # Backend options are mapped to input_params. input_params: Dict[str, Any] = vars(self.options).copy() @@ -426,7 +423,7 @@ def _get_input_params(self, options: Dict[str, Any], shots: int = None) -> Dict[ final_shots = None # First we check for the explicitly specified 'shots' parameter, then for a provider-specific - # field in options, then for a backend's default value. + # field in options, then for a backend's default value. # Warn about options conflict, default to 'shots'. if shots is not None and options_shots is not None: @@ -436,7 +433,7 @@ def _get_input_params(self, options: Dict[str, Any], shots: int = None) -> Dict[ stacklevel=3, ) final_shots = shots - + elif shots is not None: final_shots = shots elif options_shots is not None: @@ -446,13 +443,13 @@ def _get_input_params(self, options: Dict[str, Any], shots: int = None) -> Dict[ stacklevel=3, ) final_shots = options_shots - + # If nothing is found, try to get from default values. if final_shots is None: final_shots = input_params.get(self.__class__._SHOTS_PARAM_NAME) - # Also add all possible shots options into input_params to make sure - # that all backends covered. + # Also add all possible shots options into input_params to make sure + # that all backends covered. # TODO: Double check all backends for shots options in order to remove this extra check. input_params["shots"] = final_shots input_params["count"] = final_shots @@ -462,7 +459,6 @@ def _get_input_params(self, options: Dict[str, Any], shots: int = None) -> Dict[ _ = options.pop("count", None) input_params[self.__class__._SHOTS_PARAM_NAME] = final_shots - if "items" in options: input_params["items"] = options.pop("items") @@ -493,10 +489,7 @@ def _run(self, job_name, input_data, input_params, metadata, **options): # Anything left here is an invalid parameter with the user attempting to use # deprecated parameters. targetCapability = input_params.get("targetCapability", None) - if ( - targetCapability not in [None, "qasm"] - and input_data_format != "qir.v1" - ): + if targetCapability not in [None, "qasm"] and input_data_format != "qir.v1": message = "The targetCapability parameter has been deprecated and is only supported for QIR backends." message += os.linesep message += "To find a QIR capable backend, use the following code:" @@ -506,7 +499,6 @@ def _run(self, job_name, input_data, input_params, metadata, **options): ) raise ValueError(message) - # Update metadata with all remaining options values, then clear options # JobDetails model will error if unknown keys are passed down which # can happen with estiamtor and backend wrappers @@ -592,14 +584,14 @@ def _azure_config(self) -> Dict[str, str]: "output_data_format": "microsoft.quantum-results.v2", "is_default": True, } - + def _basis_gates(self) -> List[str]: return QIR_BASIS_GATES def run( self, run_input: Union[QuantumCircuit, List[QuantumCircuit]] = [], - shots: int = None, + shots: int = None, **options, ) -> AzureQuantumJob: """Run on the backend. @@ -638,7 +630,7 @@ def run( # config normalization input_params = self._get_input_params(options, shots=shots) - + shots_count = None if self._can_send_shots_input_param(): @@ -664,11 +656,10 @@ def _prepare_job_metadata(self, circuit: QuantumCircuit) -> Dict[str, str]: return { "qiskit": str(True), "name": circuit.name, - "num_qubits": circuit.num_qubits, + "num_qubits": str(circuit.num_qubits), "metadata": json.dumps(circuit.metadata), } - def _get_qir_str( self, circuit: QuantumCircuit, target_profile: TargetProfile, **kwargs ) -> str: @@ -685,9 +676,8 @@ def _get_qir_str( ) qir_str = backend.qir(circuit) - - return qir_str + return qir_str def _translate_input( self, circuit: QuantumCircuit, input_params: Dict[str, Any] @@ -709,7 +699,7 @@ def _translate_input( category=DeprecationWarning, stacklevel=3, ) - + qir_str = self._get_qir_str( circuit, target_profile, skip_transpilation=skip_transpilation ) @@ -769,9 +759,9 @@ def __init__( def _prepare_job_metadata(self, circuit): """Returns the metadata relative to the given circuit that will be attached to the Job""" return { - "qiskit": True, + "qiskit": str(True), "name": circuit.name, - "num_qubits": circuit.num_qubits, + "num_qubits": str(circuit.num_qubits), "metadata": json.dumps(circuit.metadata), } @@ -780,12 +770,12 @@ def _translate_input(self, circuit): pass def run( - self, - run_input: Union[QuantumCircuit, List[QuantumCircuit]] = [], - shots: int = None, - **options, - ): - """Submits the given circuit to run on an Azure Quantum backend.""" + self, + run_input: Union[QuantumCircuit, List[QuantumCircuit]] = [], + shots: int = None, + **options, + ): + """Submits the given circuit to run on an Azure Quantum backend.""" circuit = self._normalize_run_input_params(run_input, **options) options.pop("run_input", None) options.pop("circuit", None) @@ -831,22 +821,23 @@ def run( return job + def _get_shots_or_deprecated_count_input_param( - param_name: str, - shots: int = None, - count: int = None, - ) -> Optional[int]: + param_name: str, + shots: int = None, + count: int = None, +) -> Optional[int]: """ This helper function checks if the deprecated 'count' option is specified. In earlier versions it was possible to pass this option to specify shots number for a job, - but now we only check for it for compatibility reasons. + but now we only check for it for compatibility reasons. """ final_shots = None if shots is not None: final_shots = shots - + elif count is not None: final_shots = count warnings.warn( @@ -854,5 +845,5 @@ def _get_shots_or_deprecated_count_input_param( f"Please, use '{param_name}' parameter instead.", category=DeprecationWarning, ) - - return final_shots \ No newline at end of file + + return final_shots diff --git a/azure-quantum/azure/quantum/qiskit/job.py b/azure-quantum/azure/quantum/qiskit/job.py index e9fcd6b8f..0b76e7ab8 100644 --- a/azure-quantum/azure/quantum/qiskit/job.py +++ b/azure-quantum/azure/quantum/qiskit/job.py @@ -21,6 +21,7 @@ from azure.quantum import Job import logging + logger = logging.getLogger(__name__) AzureJobStatusMap = { @@ -33,7 +34,7 @@ "CancellationRequested": JobStatus.RUNNING, "Cancelling": JobStatus.RUNNING, "Failed": JobStatus.ERROR, - "Cancelled": JobStatus.CANCELLED + "Cancelled": JobStatus.CANCELLED, } # Constants for output data format: @@ -42,21 +43,17 @@ IONQ_OUTPUT_DATA_FORMAT = "ionq.quantum-results.v1" QUANTINUUM_OUTPUT_DATA_FORMAT = "honeywell.quantum-results.v1" + class AzureQuantumJob(JobV1): - def __init__( - self, - backend, - azure_job=None, - **kwargs - ) -> None: + def __init__(self, backend, azure_job=None, **kwargs) -> None: """ - A Job running on Azure Quantum + A Job running on Azure Quantum """ if azure_job is None: azure_job = Job.from_input_data( workspace=backend.provider.get_workspace(), session_id=backend.get_latest_session_id(), - **kwargs + **kwargs, ) self._azure_job = azure_job @@ -65,19 +62,19 @@ def __init__( super().__init__(backend, self._azure_job.id, **kwargs) def job_id(self): - """ This job's id.""" + """This job's id.""" return self._azure_job.id def id(self): - """ This job's id.""" + """This job's id.""" return self._azure_job.id def refresh(self): - """ Refreshes the job metadata from the server.""" + """Refreshes the job metadata from the server.""" return self._azure_job.refresh() def submit(self): - """ Submits the job for execution. """ + """Submits the job for execution.""" self._azure_job.submit() return @@ -85,17 +82,24 @@ def result(self, timeout=None, sampler_seed=None): """Return the results of the job.""" self._azure_job.wait_until_completed(timeout_secs=timeout) - success = self._azure_job.details.status == "Succeeded" or self._azure_job.details.status == "Completed" + success = ( + self._azure_job.details.status == "Succeeded" + or self._azure_job.details.status == "Completed" + ) results = self._format_results(sampler_seed=sampler_seed) result_dict = { - "results" : results if isinstance(results, list) else [results], - "job_id" : self._azure_job.details.id, - "backend_name" : self._backend.name, - "backend_version" : self._backend.version, - "qobj_id" : self._azure_job.details.name, - "success" : success, - "error_data" : None if self._azure_job.details.error_data is None else self._azure_job.details.error_data.as_dict() + "results": results if isinstance(results, list) else [results], + "job_id": self._azure_job.details.id, + "backend_name": self._backend.name, + "backend_version": self._backend.version, + "qobj_id": self._azure_job.details.name, + "success": success, + "error_data": ( + None + if self._azure_job.details.error_data is None + else self._azure_job.details.error_data.as_dict() + ), } return Result.from_dict(result_dict) @@ -118,21 +122,37 @@ def _shots_count(self): # Some providers use 'count', some other 'shots', give preference to 'count': input_params = self._azure_job.details.input_params options = self.backend().options - shots = \ - input_params["count"] if "count" in input_params else \ - input_params["shots"] if "shots" in input_params else \ - options.get("count") if "count" in vars(options) else \ - options.get("shots") + shots = ( + input_params["count"] + if "count" in input_params + else ( + input_params["shots"] + if "shots" in input_params + else ( + options.get("count") + if "count" in vars(options) + else options.get("shots") + ) + ) + ) return shots - def _format_results(self, sampler_seed=None) -> Union[List[Dict[str, Any]], Dict[str, Any]]: - """ Populates the results datastructures in a format that is compatible with qiskit libraries. """ + def _format_results( + self, sampler_seed=None + ) -> Union[List[Dict[str, Any]], Dict[str, Any]]: + """Populates the results datastructures in a format that is compatible with qiskit libraries.""" - if (self._azure_job.details.output_data_format == MICROSOFT_OUTPUT_DATA_FORMAT_V2): + if ( + self._azure_job.details.output_data_format + == MICROSOFT_OUTPUT_DATA_FORMAT_V2 + ): return self._format_microsoft_v2_results() - success = self._azure_job.details.status == "Succeeded" or self._azure_job.details.status == "Completed" + success = ( + self._azure_job.details.status == "Succeeded" + or self._azure_job.details.status == "Completed" + ) job_result = { "data": {}, @@ -141,13 +161,23 @@ def _format_results(self, sampler_seed=None) -> Union[List[Dict[str, Any]], Dict } if success: - if (self._azure_job.details.output_data_format == MICROSOFT_OUTPUT_DATA_FORMAT): - job_result["data"] = self._format_microsoft_results(sampler_seed=sampler_seed) - - elif (self._azure_job.details.output_data_format == IONQ_OUTPUT_DATA_FORMAT): - job_result["data"] = self._format_ionq_results(sampler_seed=sampler_seed) - - elif (self._azure_job.details.output_data_format == QUANTINUUM_OUTPUT_DATA_FORMAT): + if ( + self._azure_job.details.output_data_format + == MICROSOFT_OUTPUT_DATA_FORMAT + ): + job_result["data"] = self._format_microsoft_results( + sampler_seed=sampler_seed + ) + + elif self._azure_job.details.output_data_format == IONQ_OUTPUT_DATA_FORMAT: + job_result["data"] = self._format_ionq_results( + sampler_seed=sampler_seed + ) + + elif ( + self._azure_job.details.output_data_format + == QUANTINUUM_OUTPUT_DATA_FORMAT + ): job_result["data"] = self._format_quantinuum_results() else: @@ -155,7 +185,9 @@ def _format_results(self, sampler_seed=None) -> Union[List[Dict[str, Any]], Dict job_result["header"] = self._azure_job.details.metadata if "metadata" in job_result["header"]: - job_result["header"]["metadata"] = json.loads(job_result["header"]["metadata"]) + job_result["header"]["metadata"] = json.loads( + job_result["header"]["metadata"] + ) job_result["shots"] = self._shots_count() return job_result @@ -164,15 +196,20 @@ def _draw_random_sample(self, sampler_seed, probabilities, shots): _norm = sum(probabilities.values()) if _norm != 1: if np.isclose(_norm, 1.0, rtol=1e-4): - probabilities = {k: v/_norm for k, v in probabilities.items()} + probabilities = {k: v / _norm for k, v in probabilities.items()} else: raise ValueError(f"Probabilities do not add up to 1: {probabilities}") if not sampler_seed: import hashlib + id = self.job_id() - sampler_seed = int(hashlib.sha256(id.encode('utf-8')).hexdigest(), 16) % (2**32 - 1) + sampler_seed = int(hashlib.sha256(id.encode("utf-8")).hexdigest(), 16) % ( + 2**32 - 1 + ) rand = np.random.RandomState(sampler_seed) - rand_values = rand.choice(list(probabilities.keys()), shots, p=list(probabilities.values())) + rand_values = rand.choice( + list(probabilities.keys()), shots, p=list(probabilities.values()) + ) return dict(zip(*np.unique(rand_values, return_counts=True))) @staticmethod @@ -183,29 +220,40 @@ def _to_bitstring(k, num_qubits, meas_map): return "".join([bitstring[n] for n in meas_map])[::-1] def _format_ionq_results(self, sampler_seed=None): - """ Translate IonQ's histogram data into a format that can be consumed by qiskit libraries. """ + """Translate IonQ's histogram data into a format that can be consumed by qiskit libraries.""" az_result = self._azure_job.get_results() shots = self._shots_count() if "num_qubits" not in self._azure_job.details.metadata: - raise ValueError(f"Job with ID {self.id()} does not have the required metadata (num_qubits) to format IonQ results.") + raise ValueError( + f"Job with ID {self.id()} does not have the required metadata (num_qubits) to format IonQ results." + ) - meas_map = json.loads(self._azure_job.details.metadata.get("meas_map")) if "meas_map" in self._azure_job.details.metadata else None - num_qubits = self._azure_job.details.metadata.get("num_qubits") + meas_map = ( + json.loads(self._azure_job.details.metadata.get("meas_map")) + if "meas_map" in self._azure_job.details.metadata + else None + ) + num_qubits = int(self._azure_job.details.metadata.get("num_qubits")) - if not 'histogram' in az_result: + if not "histogram" in az_result: raise "Histogram missing from IonQ Job results" counts = defaultdict(int) probabilities = defaultdict(int) - for key, value in az_result['histogram'].items(): - bitstring = self._to_bitstring(key, num_qubits, meas_map) if meas_map else key + for key, value in az_result["histogram"].items(): + bitstring = ( + self._to_bitstring(key, num_qubits, meas_map) if meas_map else key + ) probabilities[bitstring] += value if self.backend().configuration().simulator: counts = self._draw_random_sample(sampler_seed, probabilities, shots) else: - counts = {bitstring: np.round(shots * value) for bitstring, value in probabilities.items()} + counts = { + bitstring: np.round(shots * value) + for bitstring, value in probabilities.items() + } return {"counts": counts, "probabilities": probabilities} @@ -219,10 +267,7 @@ def _qir_to_qiskit_bitstring(obj): # the outermost implied container is a tuple, and each item is # associated with a classical register. return " ".join( - [ - AzureQuantumJob._qir_to_qiskit_bitstring(term) - for term in obj - ] + [AzureQuantumJob._qir_to_qiskit_bitstring(term) for term in obj] ) elif isinstance(obj, list): # a list is for an individual classical register @@ -231,7 +276,7 @@ def _qir_to_qiskit_bitstring(obj): return str(obj) def _format_microsoft_results(self, sampler_seed=None): - """ Translate Microsoft's job results histogram into a format that can be consumed by qiskit libraries. """ + """Translate Microsoft's job results histogram into a format that can be consumed by qiskit libraries.""" histogram = self._azure_job.get_results() shots = self._shots_count() @@ -247,61 +292,78 @@ def _format_microsoft_results(self, sampler_seed=None): if self.backend().configuration().simulator: counts = self._draw_random_sample(sampler_seed, probabilities, shots) else: - counts = {bitstring: np.round(shots * value) for bitstring, value in probabilities.items()} + counts = { + bitstring: np.round(shots * value) + for bitstring, value in probabilities.items() + } return {"counts": counts, "probabilities": probabilities} - + def _format_quantinuum_results(self): - """ Translate Quantinuum's histogram data into a format that can be consumed by qiskit libraries. """ + """Translate Quantinuum's histogram data into a format that can be consumed by qiskit libraries.""" az_result = self._azure_job.get_results() all_bitstrings = [ - bitstrings for classical_register, bitstrings - in az_result.items() if classical_register != "access_token" + bitstrings + for classical_register, bitstrings in az_result.items() + if classical_register != "access_token" ] counts = {} - combined_bitstrings = ["".join(bitstrings) for bitstrings in zip(*all_bitstrings)] + combined_bitstrings = [ + "".join(bitstrings) for bitstrings in zip(*all_bitstrings) + ] shots = len(combined_bitstrings) for bitstring in set(combined_bitstrings): counts[bitstring] = combined_bitstrings.count(bitstring) - histogram = {bitstring: count/shots for bitstring, count in counts.items()} + histogram = {bitstring: count / shots for bitstring, count in counts.items()} return {"counts": counts, "probabilities": histogram} def _format_unknown_results(self): - """ This method is called to format Job results data when the job output is in an unknown format.""" + """This method is called to format Job results data when the job output is in an unknown format.""" az_result = self._azure_job.get_results() return az_result def _translate_microsoft_v2_results(self): - """ Translate Microsoft's batching job results histograms into a format that can be consumed by qiskit libraries. """ + """Translate Microsoft's batching job results histograms into a format that can be consumed by qiskit libraries.""" az_result_histogram = self._azure_job.get_results_histogram() az_result_shots = self._azure_job.get_results_shots() - + # If it is a non-batched result, format to be in batch format so we can have one code path if isinstance(az_result_histogram, dict): az_result_histogram = [az_result_histogram] az_result_shots = [az_result_shots] - + histograms = [] - - for (histogram, shots) in zip(az_result_histogram, az_result_shots): + + for histogram, shots in zip(az_result_histogram, az_result_shots): counts = {} probabilities = {} total_count = len(shots) - for (display, result) in histogram.items(): + for display, result in histogram.items(): bitstring = AzureQuantumJob._qir_to_qiskit_bitstring(display) count = result["count"] probability = count / total_count counts[bitstring] = count probabilities[bitstring] = probability - - formatted_shots = [AzureQuantumJob._qir_to_qiskit_bitstring(shot) for shot in shots] - histograms.append((total_count, {"counts": counts, "probabilities": probabilities, "memory": formatted_shots})) + formatted_shots = [ + AzureQuantumJob._qir_to_qiskit_bitstring(shot) for shot in shots + ] + + histograms.append( + ( + total_count, + { + "counts": counts, + "probabilities": probabilities, + "memory": formatted_shots, + }, + ) + ) return histograms def _get_entry_point_names(self): @@ -311,13 +373,15 @@ def _get_entry_point_names(self): entry_point_names = [] for entry_point in entry_points: if not "entryPoint" in entry_point: - raise ValueError("Entry point input_param is missing an 'entryPoint' field") + raise ValueError( + "Entry point input_param is missing an 'entryPoint' field" + ) entry_point_names.append(entry_point["entryPoint"]) return entry_point_names if len(entry_point_names) > 0 else ["main"] def _get_headers(self): headers = self._azure_job.details.metadata - if (not isinstance(headers, list)): + if not isinstance(headers, list): headers = [headers] # This function will attempt to parse the header into a JSON object, and if the header is not a JSON object, we return the header itself @@ -330,44 +394,57 @@ def tryParseJSON(value): except ValueError: return value return value - + for header in headers: - del header['qiskit'] # we throw out the qiskit header as it is implied + del header["qiskit"] # we throw out the qiskit header as it is implied for key in header.keys(): header[key] = tryParseJSON(header[key]) return headers - def _format_microsoft_v2_results(self) -> List[Dict[str, Any]]: - success = self._azure_job.details.status == "Succeeded" or self._azure_job.details.status == "Completed" + success = ( + self._azure_job.details.status == "Succeeded" + or self._azure_job.details.status == "Completed" + ) if not success: - return [{ - "data": {}, - "success": False, - "header": {}, - "shots": 0, - }] - + return [ + { + "data": {}, + "success": False, + "header": {}, + "shots": 0, + } + ] + entry_point_names = self._get_entry_point_names() results = self._translate_microsoft_v2_results() if len(results) != len(entry_point_names): - raise ValueError("The number of experiment results does not match the number of entry point names") - + raise ValueError( + "The number of experiment results does not match the number of entry point names" + ) + headers = self._get_headers() - + if len(results) != len(headers): - raise ValueError("The number of experiment results does not match the number of headers") - + raise ValueError( + "The number of experiment results does not match the number of headers" + ) + status = self.status() - return [{ - "data": result, - "success": success, - "shots": total_count, - "name": name, - "status": status, - "header": header - } for name, (total_count, result), header in zip(entry_point_names, results, headers)] + return [ + { + "data": result, + "success": success, + "shots": total_count, + "name": name, + "status": status, + "header": header, + } + for name, (total_count, result), header in zip( + entry_point_names, results, headers + ) + ] From e21e4596c00be0baa77f081c0cfa70da8297b276 Mon Sep 17 00:00:00 2001 From: Scott Carda Date: Tue, 17 Feb 2026 10:45:31 -0800 Subject: [PATCH 3/6] added _azure_config method to generic --- azure-quantum/azure/quantum/qiskit/backends/generic.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/azure-quantum/azure/quantum/qiskit/backends/generic.py b/azure-quantum/azure/quantum/qiskit/backends/generic.py index 5e8f00d11..74432823c 100644 --- a/azure-quantum/azure/quantum/qiskit/backends/generic.py +++ b/azure-quantum/azure/quantum/qiskit/backends/generic.py @@ -50,9 +50,6 @@ def __init__( ): self._provider_id = provider_id - azure_config: Dict[str, Any] = self._azure_config() - azure_config.update({"provider_id": provider_id, "is_default": False}) - config = AzureBackendConfig.from_dict( { "backend_name": name, @@ -69,7 +66,7 @@ def __init__( "max_shots": None, "open_pulse": False, "gates": [{"name": "TODO", "parameters": [], "qasm_def": "TODO"}], - "azure": azure_config, + "azure": self._azure_config(), } ) @@ -83,3 +80,8 @@ def _default_options(cls) -> Options: **{cls._SHOTS_PARAM_NAME: _DEFAULT_SHOTS_COUNT}, target_profile=TargetProfile.Base, ) + + def _azure_config(self) -> Dict[str, str]: + config = super()._azure_config() + config.update({"provider_id": self._provider_id, "is_default": False}) + return config From 04cb3cc88ca9809d75a75a870cfaa01cb11bcab8 Mon Sep 17 00:00:00 2001 From: Scott Carda Date: Wed, 18 Feb 2026 10:34:08 -0800 Subject: [PATCH 4/6] undo formatting for job.py --- azure-quantum/azure/quantum/qiskit/job.py | 253 ++++++++-------------- 1 file changed, 92 insertions(+), 161 deletions(-) diff --git a/azure-quantum/azure/quantum/qiskit/job.py b/azure-quantum/azure/quantum/qiskit/job.py index 0602ee364..beb55b99d 100644 --- a/azure-quantum/azure/quantum/qiskit/job.py +++ b/azure-quantum/azure/quantum/qiskit/job.py @@ -21,7 +21,6 @@ from azure.quantum import Job import logging - logger = logging.getLogger(__name__) AzureJobStatusMap = { @@ -34,7 +33,7 @@ "CancellationRequested": JobStatus.RUNNING, "Cancelling": JobStatus.RUNNING, "Failed": JobStatus.ERROR, - "Cancelled": JobStatus.CANCELLED, + "Cancelled": JobStatus.CANCELLED } # Constants for output data format: @@ -43,17 +42,21 @@ IONQ_OUTPUT_DATA_FORMAT = "ionq.quantum-results.v1" QUANTINUUM_OUTPUT_DATA_FORMAT = "honeywell.quantum-results.v1" - class AzureQuantumJob(JobV1): - def __init__(self, backend, azure_job=None, **kwargs) -> None: + def __init__( + self, + backend, + azure_job=None, + **kwargs + ) -> None: """ - A Job running on Azure Quantum + A Job running on Azure Quantum """ if azure_job is None: azure_job = Job.from_input_data( workspace=backend.provider.get_workspace(), session_id=backend.get_latest_session_id(), - **kwargs, + **kwargs ) self._azure_job = azure_job @@ -62,19 +65,19 @@ def __init__(self, backend, azure_job=None, **kwargs) -> None: super().__init__(backend, self._azure_job.id, **kwargs) def job_id(self): - """This job's id.""" + """ This job's id.""" return self._azure_job.id def id(self): - """This job's id.""" + """ This job's id.""" return self._azure_job.id def refresh(self): - """Refreshes the job metadata from the server.""" + """ Refreshes the job metadata from the server.""" return self._azure_job.refresh() def submit(self): - """Submits the job for execution.""" + """ Submits the job for execution. """ self._azure_job.submit() return @@ -82,24 +85,17 @@ def result(self, timeout=None, sampler_seed=None): """Return the results of the job.""" self._azure_job.wait_until_completed(timeout_secs=timeout) - success = ( - self._azure_job.details.status == "Succeeded" - or self._azure_job.details.status == "Completed" - ) + success = self._azure_job.details.status == "Succeeded" or self._azure_job.details.status == "Completed" results = self._format_results(sampler_seed=sampler_seed) result_dict = { - "results": results if isinstance(results, list) else [results], - "job_id": self._azure_job.details.id, - "backend_name": self._backend.name, - "backend_version": self._backend.version, - "qobj_id": self._azure_job.details.name, - "success": success, - "error_data": ( - None - if self._azure_job.details.error_data is None - else self._azure_job.details.error_data.as_dict() - ), + "results" : results if isinstance(results, list) else [results], + "job_id" : self._azure_job.details.id, + "backend_name" : self._backend.name, + "backend_version" : self._backend.version, + "qobj_id" : self._azure_job.details.name, + "success" : success, + "error_data" : None if self._azure_job.details.error_data is None else self._azure_job.details.error_data.as_dict() } return Result.from_dict(result_dict) @@ -130,21 +126,13 @@ def _shots_count(self): return shots - def _format_results( - self, sampler_seed=None - ) -> Union[List[Dict[str, Any]], Dict[str, Any]]: - """Populates the results datastructures in a format that is compatible with qiskit libraries.""" + def _format_results(self, sampler_seed=None) -> Union[List[Dict[str, Any]], Dict[str, Any]]: + """ Populates the results datastructures in a format that is compatible with qiskit libraries. """ - if ( - self._azure_job.details.output_data_format - == MICROSOFT_OUTPUT_DATA_FORMAT_V2 - ): + if (self._azure_job.details.output_data_format == MICROSOFT_OUTPUT_DATA_FORMAT_V2): return self._format_microsoft_v2_results() - success = ( - self._azure_job.details.status == "Succeeded" - or self._azure_job.details.status == "Completed" - ) + success = self._azure_job.details.status == "Succeeded" or self._azure_job.details.status == "Completed" job_result = { "data": {}, @@ -153,23 +141,13 @@ def _format_results( } if success: - if ( - self._azure_job.details.output_data_format - == MICROSOFT_OUTPUT_DATA_FORMAT - ): - job_result["data"] = self._format_microsoft_results( - sampler_seed=sampler_seed - ) - - elif self._azure_job.details.output_data_format == IONQ_OUTPUT_DATA_FORMAT: - job_result["data"] = self._format_ionq_results( - sampler_seed=sampler_seed - ) - - elif ( - self._azure_job.details.output_data_format - == QUANTINUUM_OUTPUT_DATA_FORMAT - ): + if (self._azure_job.details.output_data_format == MICROSOFT_OUTPUT_DATA_FORMAT): + job_result["data"] = self._format_microsoft_results(sampler_seed=sampler_seed) + + elif (self._azure_job.details.output_data_format == IONQ_OUTPUT_DATA_FORMAT): + job_result["data"] = self._format_ionq_results(sampler_seed=sampler_seed) + + elif (self._azure_job.details.output_data_format == QUANTINUUM_OUTPUT_DATA_FORMAT): job_result["data"] = self._format_quantinuum_results() else: @@ -177,9 +155,7 @@ def _format_results( job_result["header"] = self._azure_job.details.metadata if "metadata" in job_result["header"]: - job_result["header"]["metadata"] = json.loads( - job_result["header"]["metadata"] - ) + job_result["header"]["metadata"] = json.loads(job_result["header"]["metadata"]) job_result["shots"] = self._shots_count() return job_result @@ -188,20 +164,15 @@ def _draw_random_sample(self, sampler_seed, probabilities, shots): _norm = sum(probabilities.values()) if _norm != 1: if np.isclose(_norm, 1.0, rtol=1e-4): - probabilities = {k: v / _norm for k, v in probabilities.items()} + probabilities = {k: v/_norm for k, v in probabilities.items()} else: raise ValueError(f"Probabilities do not add up to 1: {probabilities}") if not sampler_seed: import hashlib - id = self.job_id() - sampler_seed = int(hashlib.sha256(id.encode("utf-8")).hexdigest(), 16) % ( - 2**32 - 1 - ) + sampler_seed = int(hashlib.sha256(id.encode('utf-8')).hexdigest(), 16) % (2**32 - 1) rand = np.random.RandomState(sampler_seed) - rand_values = rand.choice( - list(probabilities.keys()), shots, p=list(probabilities.values()) - ) + rand_values = rand.choice(list(probabilities.keys()), shots, p=list(probabilities.values())) return dict(zip(*np.unique(rand_values, return_counts=True))) @staticmethod @@ -212,40 +183,29 @@ def _to_bitstring(k, num_qubits, meas_map): return "".join([bitstring[n] for n in meas_map])[::-1] def _format_ionq_results(self, sampler_seed=None): - """Translate IonQ's histogram data into a format that can be consumed by qiskit libraries.""" + """ Translate IonQ's histogram data into a format that can be consumed by qiskit libraries. """ az_result = self._azure_job.get_results() shots = self._shots_count() if "num_qubits" not in self._azure_job.details.metadata: - raise ValueError( - f"Job with ID {self.id()} does not have the required metadata (num_qubits) to format IonQ results." - ) + raise ValueError(f"Job with ID {self.id()} does not have the required metadata (num_qubits) to format IonQ results.") - meas_map = ( - json.loads(self._azure_job.details.metadata.get("meas_map")) - if "meas_map" in self._azure_job.details.metadata - else None - ) - num_qubits = int(self._azure_job.details.metadata.get("num_qubits")) + meas_map = json.loads(self._azure_job.details.metadata.get("meas_map")) if "meas_map" in self._azure_job.details.metadata else None + num_qubits = self._azure_job.details.metadata.get("num_qubits") - if not "histogram" in az_result: + if not 'histogram' in az_result: raise "Histogram missing from IonQ Job results" counts = defaultdict(int) probabilities = defaultdict(int) - for key, value in az_result["histogram"].items(): - bitstring = ( - self._to_bitstring(key, num_qubits, meas_map) if meas_map else key - ) + for key, value in az_result['histogram'].items(): + bitstring = self._to_bitstring(key, num_qubits, meas_map) if meas_map else key probabilities[bitstring] += value if self.backend().configuration().simulator: counts = self._draw_random_sample(sampler_seed, probabilities, shots) else: - counts = { - bitstring: np.round(shots * value) - for bitstring, value in probabilities.items() - } + counts = {bitstring: np.round(shots * value) for bitstring, value in probabilities.items()} return {"counts": counts, "probabilities": probabilities} @@ -259,7 +219,10 @@ def _qir_to_qiskit_bitstring(obj): # the outermost implied container is a tuple, and each item is # associated with a classical register. return " ".join( - [AzureQuantumJob._qir_to_qiskit_bitstring(term) for term in obj] + [ + AzureQuantumJob._qir_to_qiskit_bitstring(term) + for term in obj + ] ) elif isinstance(obj, list): # a list is for an individual classical register @@ -268,7 +231,7 @@ def _qir_to_qiskit_bitstring(obj): return str(obj) def _format_microsoft_results(self, sampler_seed=None): - """Translate Microsoft's job results histogram into a format that can be consumed by qiskit libraries.""" + """ Translate Microsoft's job results histogram into a format that can be consumed by qiskit libraries. """ histogram = self._azure_job.get_results() shots = self._shots_count() @@ -284,78 +247,61 @@ def _format_microsoft_results(self, sampler_seed=None): if self.backend().configuration().simulator: counts = self._draw_random_sample(sampler_seed, probabilities, shots) else: - counts = { - bitstring: np.round(shots * value) - for bitstring, value in probabilities.items() - } + counts = {bitstring: np.round(shots * value) for bitstring, value in probabilities.items()} return {"counts": counts, "probabilities": probabilities} - + def _format_quantinuum_results(self): - """Translate Quantinuum's histogram data into a format that can be consumed by qiskit libraries.""" + """ Translate Quantinuum's histogram data into a format that can be consumed by qiskit libraries. """ az_result = self._azure_job.get_results() all_bitstrings = [ - bitstrings - for classical_register, bitstrings in az_result.items() - if classical_register != "access_token" + bitstrings for classical_register, bitstrings + in az_result.items() if classical_register != "access_token" ] counts = {} - combined_bitstrings = [ - "".join(bitstrings) for bitstrings in zip(*all_bitstrings) - ] + combined_bitstrings = ["".join(bitstrings) for bitstrings in zip(*all_bitstrings)] shots = len(combined_bitstrings) for bitstring in set(combined_bitstrings): counts[bitstring] = combined_bitstrings.count(bitstring) - histogram = {bitstring: count / shots for bitstring, count in counts.items()} + histogram = {bitstring: count/shots for bitstring, count in counts.items()} return {"counts": counts, "probabilities": histogram} def _format_unknown_results(self): - """This method is called to format Job results data when the job output is in an unknown format.""" + """ This method is called to format Job results data when the job output is in an unknown format.""" az_result = self._azure_job.get_results() return az_result def _translate_microsoft_v2_results(self): - """Translate Microsoft's batching job results histograms into a format that can be consumed by qiskit libraries.""" + """ Translate Microsoft's batching job results histograms into a format that can be consumed by qiskit libraries. """ az_result_histogram = self._azure_job.get_results_histogram() az_result_shots = self._azure_job.get_results_shots() - + # If it is a non-batched result, format to be in batch format so we can have one code path if isinstance(az_result_histogram, dict): az_result_histogram = [az_result_histogram] az_result_shots = [az_result_shots] - + histograms = [] - - for histogram, shots in zip(az_result_histogram, az_result_shots): + + for (histogram, shots) in zip(az_result_histogram, az_result_shots): counts = {} probabilities = {} total_count = len(shots) - for display, result in histogram.items(): + for (display, result) in histogram.items(): bitstring = AzureQuantumJob._qir_to_qiskit_bitstring(display) count = result["count"] probability = count / total_count counts[bitstring] = count probabilities[bitstring] = probability + + formatted_shots = [AzureQuantumJob._qir_to_qiskit_bitstring(shot) for shot in shots] - formatted_shots = [ - AzureQuantumJob._qir_to_qiskit_bitstring(shot) for shot in shots - ] - - histograms.append( - ( - total_count, - { - "counts": counts, - "probabilities": probabilities, - "memory": formatted_shots, - }, - ) - ) + histograms.append((total_count, {"counts": counts, "probabilities": probabilities, "memory": formatted_shots})) return histograms def _get_entry_point_names(self): @@ -365,15 +311,13 @@ def _get_entry_point_names(self): entry_point_names = [] for entry_point in entry_points: if not "entryPoint" in entry_point: - raise ValueError( - "Entry point input_param is missing an 'entryPoint' field" - ) + raise ValueError("Entry point input_param is missing an 'entryPoint' field") entry_point_names.append(entry_point["entryPoint"]) return entry_point_names if len(entry_point_names) > 0 else ["main"] def _get_headers(self): headers = self._azure_job.details.metadata - if not isinstance(headers, list): + if (not isinstance(headers, list)): headers = [headers] # This function will attempt to parse the header into a JSON object, and if the header is not a JSON object, we return the header itself @@ -386,57 +330,44 @@ def tryParseJSON(value): except ValueError: return value return value - + for header in headers: - del header["qiskit"] # we throw out the qiskit header as it is implied + del header['qiskit'] # we throw out the qiskit header as it is implied for key in header.keys(): header[key] = tryParseJSON(header[key]) return headers + def _format_microsoft_v2_results(self) -> List[Dict[str, Any]]: - success = ( - self._azure_job.details.status == "Succeeded" - or self._azure_job.details.status == "Completed" - ) + success = self._azure_job.details.status == "Succeeded" or self._azure_job.details.status == "Completed" if not success: - return [ - { - "data": {}, - "success": False, - "header": {}, - "shots": 0, - } - ] - + return [{ + "data": {}, + "success": False, + "header": {}, + "shots": 0, + }] + entry_point_names = self._get_entry_point_names() results = self._translate_microsoft_v2_results() if len(results) != len(entry_point_names): - raise ValueError( - "The number of experiment results does not match the number of entry point names" - ) - + raise ValueError("The number of experiment results does not match the number of entry point names") + headers = self._get_headers() - + if len(results) != len(headers): - raise ValueError( - "The number of experiment results does not match the number of headers" - ) - + raise ValueError("The number of experiment results does not match the number of headers") + status = self.status() - return [ - { - "data": result, - "success": success, - "shots": total_count, - "name": name, - "status": status, - "header": header, - } - for name, (total_count, result), header in zip( - entry_point_names, results, headers - ) - ] + return [{ + "data": result, + "success": success, + "shots": total_count, + "name": name, + "status": status, + "header": header + } for name, (total_count, result), header in zip(entry_point_names, results, headers)] From b3a471cf0d9f810331432aea7bfde7e1f92c86f9 Mon Sep 17 00:00:00 2001 From: Scott Carda Date: Thu, 19 Feb 2026 14:02:57 -0800 Subject: [PATCH 5/6] raise error if name is not an installed backend or valid target. --- azure-quantum/azure/quantum/qiskit/provider.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/azure-quantum/azure/quantum/qiskit/provider.py b/azure-quantum/azure/quantum/qiskit/provider.py index 92e8d1b48..92a8707f3 100644 --- a/azure-quantum/azure/quantum/qiskit/provider.py +++ b/azure-quantum/azure/quantum/qiskit/provider.py @@ -134,6 +134,15 @@ def backends(self, name=None, **kwargs): status_by_target = self._get_workspace_target_status_map(name, provider_id) allowed_targets: List[Tuple[str, str]] = list(status_by_target.keys()) + # If a user asks for a specific backend name and it isn't installed, + # raise a clear error. With generic backends, a name can still be valid + # even if it isn't installed, as long as the target exists in the workspace. + if name and name not in self._backends and not allowed_targets: + provider_clause = f" for provider_id '{provider_id}'" if provider_id else "" + raise QiskitBackendNotFoundError( + f"The '{name}' backend is not installed in your system, nor is it a valid target{provider_clause} in your Azure Quantum workspace." + ) + workspace_allowed = lambda backend: self._is_available_in_ws( allowed_targets, backend ) From 72cf1a46e95950950156277ac1a45cfbb457dc51 Mon Sep 17 00:00:00 2001 From: Scott Carda Date: Thu, 19 Feb 2026 14:54:39 -0800 Subject: [PATCH 6/6] set target_profile based on target in generic backend --- .../azure/quantum/qiskit/backends/generic.py | 35 +++++++++++++++++++ .../azure/quantum/qiskit/provider.py | 27 +++----------- azure-quantum/tests/test_qiskit.py | 7 ++++ 3 files changed, 46 insertions(+), 23 deletions(-) diff --git a/azure-quantum/azure/quantum/qiskit/backends/generic.py b/azure-quantum/azure/quantum/qiskit/backends/generic.py index 74432823c..517b810a0 100644 --- a/azure-quantum/azure/quantum/qiskit/backends/generic.py +++ b/azure-quantum/azure/quantum/qiskit/backends/generic.py @@ -44,6 +44,7 @@ def __init__( provider: "AzureQuantumProvider", *, provider_id: str, + target_profile: Optional[TargetProfile | str] = None, num_qubits: Optional[int] = None, description: Optional[str] = None, **kwargs: Any, @@ -72,6 +73,40 @@ def __init__( super().__init__(config, provider, **kwargs) + # Prefer an instance-specific target profile discovered from the workspace target metadata. + default_target_profile = self._coerce_target_profile(target_profile) + if default_target_profile is not None: + self.set_options(target_profile=default_target_profile) + + @staticmethod + def _coerce_target_profile( + value: Optional[TargetProfile | str], + ) -> Optional[TargetProfile]: + if value is None: + return None + if isinstance(value, TargetProfile): + return value + if not isinstance(value, str): + return None + + raw = value.strip() + if not raw: + return None + + # Prefer the qsharp helper when available. + from_str = getattr(TargetProfile, "from_str", None) + if callable(from_str): + try: + parsed = from_str(raw) + if isinstance(parsed, TargetProfile): + return parsed + except Exception: + pass + + # Best-effort: try enum attribute lookup. + normalized = raw.replace("-", "_") + return getattr(TargetProfile, normalized, None) + @classmethod def _default_options(cls) -> Options: # Default to the most conservative QIR profile; users can override per-run via diff --git a/azure-quantum/azure/quantum/qiskit/provider.py b/azure-quantum/azure/quantum/qiskit/provider.py index 92a8707f3..31cf87981 100644 --- a/azure-quantum/azure/quantum/qiskit/provider.py +++ b/azure-quantum/azure/quantum/qiskit/provider.py @@ -31,28 +31,6 @@ class AzureQuantumProvider(ABC): - @staticmethod - def _target_status_num_qubits(target_status: TargetStatus) -> Optional[int]: - try: - value = getattr(target_status, "num_qubits") - if isinstance(value, int): - return value - except Exception: - pass - - get_method = getattr(target_status, "get", None) - if callable(get_method): - value = get_method("numQubits", get_method("num_qubits")) - if isinstance(value, int): - return value - - if isinstance(target_status, Mapping): - value = target_status.get("numQubits", target_status.get("num_qubits")) - if isinstance(value, int): - return value - - return None - def __init__(self, workspace: Optional[Workspace] = None, **kwargs): """Class for interfacing with the Azure Quantum service using Qiskit quantum circuits. @@ -170,7 +148,10 @@ def backends(self, name=None, **kwargs): name=target_id, provider=self, provider_id=pid, - num_qubits=self._target_status_num_qubits(status), + target_profile=( + status.target_profile if status is not None else None + ), + num_qubits=status.num_qubits if status is not None else None, ) ) diff --git a/azure-quantum/tests/test_qiskit.py b/azure-quantum/tests/test_qiskit.py index 509f1826c..9a81e83dd 100644 --- a/azure-quantum/tests/test_qiskit.py +++ b/azure-quantum/tests/test_qiskit.py @@ -50,6 +50,7 @@ def _seed_workspace_target( provider_id: str, target_id: str, num_qubits: int | None = None, + target_profile: str | None = None, ) -> None: """Inject a provider+target into the offline Workspace mock. @@ -71,6 +72,7 @@ def _seed_workspace_target( "currentAvailability": "Available", "averageQueueTime": 0, "numQubits": num_qubits, + "targetProfile": target_profile, } ) provider = SimpleNamespace(id=provider_id, targets=[target_status]) @@ -438,6 +440,7 @@ def test_generic_qir_backend_created_for_unknown_workspace_target( provider_id="acme", target_id="acme.qpu", num_qubits=5, + target_profile="Adaptive_RI", ) provider = AzureQuantumProvider(workspace=ws) @@ -445,6 +448,10 @@ def test_generic_qir_backend_created_for_unknown_workspace_target( assert isinstance(backend, AzureGenericQirBackend) + from qsharp import TargetProfile + + assert backend.options.get("target_profile") == TargetProfile.Adaptive_RI + # Avoid calling `backend.run()` (requires qsharp for QIR generation). input_params = backend._get_input_params({}, shots=11) job = backend._run(