Source code for iqm.qdmi.qiskit

# Copyright (c) 2025 - 2026 IQM Finland Oy
# All rights reserved.
#
# Licensed under the Apache License v2.0 with LLVM Exceptions (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://github.com/iqm-finland/QDMI-on-IQM/blob/main/LICENSE
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations under
# the License.
#
# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception

"""Qiskit-facing integration for the IQM QDMI device library."""

from __future__ import annotations

import json
import os
from collections.abc import Mapping
from pathlib import Path
from typing import TYPE_CHECKING, ClassVar
from uuid import UUID

try:
    from mqt.core.plugins.qiskit.backend import QDMIBackend
    from mqt.core.plugins.qiskit.exceptions import CircuitValidationError, UnsupportedDeviceError
    from mqt.core.qdmi import CustomProperty
    from mqt.core.qdmi.builtin_driver import open_device
except ImportError as e:
    msg = (
        "Failed to import Qiskit plugin. "
        "Ensure that `iqm-qdmi` is installed with the `qiskit` extra, e.g., via `uv pip install iqm-qdmi[qiskit]`."
    )
    raise ImportError(msg) from e

from qiskit.transpiler import Target

from . import IQM_QDMI_DEVICE_ID
from .gates import MoveGate

if TYPE_CHECKING:
    from mqt.core.plugins.qiskit.provider import QDMIProvider
    from mqt.core.qdmi import Device
    from mqt.core.typing import QDMIJobParameters
    from qiskit.circuit import Instruction
    from qiskit.providers import Options

__all__ = ["IQMBackend"]


def __dir__() -> list[str]:
    return __all__


[docs] class IQMBackend(QDMIBackend): """Qiskit backend for the packaged IQM QDMI device library. This backend loads the shared library distributed with `iqm-qdmi` and exposes it through MQT Core's Qiskit-compatible QDMI backend. Args: device_id: Stable ID from the installed catalogue. Defaults to `iqm.default`. device: An already-open device. provider: Optional Qiskit provider to associate with this backend. base_url: Base URL of the IQM service. Overrides `IQM_SERVER_URL`, its `IQM_BASE_URL` alias, and the manifest default when provided. token: Authentication token. Defaults to `IQM_TOKEN`. tokens_file: Path to an authentication file. Defaults to `IQM_TOKENS_FILE`. qc_id: Optional IQM quantum computer identifier. Defaults to `IQM_QC_ID`. qc_alias: Optional IQM quantum computer alias. Defaults to `IQM_QUANTUM_COMPUTER`, then its `IQM_QC_ALIAS` alias. calibration_set_id: Optional calibration UUID to use for target construction and execution. When omitted, resolve the server default at initialization. Environment defaults for the endpoint and quantum computer apply only to `iqm.default`. An explicit ID or alias takes precedence over either selector from the environment. Named presets use their manifest configuration. """ #: MOVE is native to IQM's star-topology devices but absent from Qiskit's #: standard gate library, so the Target needs it supplied here. _EXTRA_GATES: ClassVar[dict[str, Instruction | type[Instruction]]] = {"move": MoveGate()} @property def physical_qubits(self) -> tuple[int, ...]: """Device site indices in target order, including computational resonators.""" return self._physical_qubits def _build_target(self) -> Target: """Build a target from calibrated qubits and computational resonators. Returns: A calibrated target with contiguous indices and native resonator operations. Raises: UnsupportedDeviceError: No qubits have both required calibrations. """ target = super()._build_target() # The IQM device lists qubits first, followed by computational resonators. num_qubits = self.device.qubits_num() self._physical_qubits = tuple( index for index in range(target.num_qubits) if index >= num_qubits or all(target.instruction_supported(operation_name=gate, qargs=(index,)) for gate in ("r", "measure")) ) if not any(index < num_qubits for index in self._physical_qubits): msg = "No IQM qubits have calibrated PRX and measurement operations." raise UnsupportedDeviceError(msg) if len(self._physical_qubits) == target.num_qubits: return target indices = {physical: logical for logical, physical in enumerate(self._physical_qubits)} restricted = Target(description=target.description, num_qubits=len(indices)) # IQM native operations have explicit calibrated loci. for name, placements in target.items(): properties = { tuple(indices[index] for index in locus): props for locus, props in placements.items() if locus is not None and all(index in indices for index in locus) } if properties: restricted.add_instruction(target.operation_from_name(name), properties, name=name) return restricted @classmethod def _default_options(cls) -> Options: """Return shot options and optional IQM run-request fields. Returns: Backend defaults; ``None`` leaves server options at their defaults. """ options = super()._default_options() options.update_options(run_request_options=None) return options def _job_parameters(self, options: Mapping[str, object]) -> QDMIJobParameters: # ruff:ignore[no-self-use] """Serialize the run-request mapping into QDMI ``custom1``. Returns: Custom job parameters shared by every circuit in the run. Raises: CircuitValidationError: The options contain reserved fields, invalid JSON values, or shot-discarding heralding. """ request_options = options.get("run_request_options") if request_options is None: return {} if not isinstance(request_options, Mapping): msg = "'run_request_options' must be a JSON object" raise CircuitValidationError(msg) if reserved := request_options.keys() & {"circuits", "shots", "calibration_set_id"}: msg = f"'run_request_options' cannot override {', '.join(sorted(reserved))}" raise CircuitValidationError(msg) try: payload = json.dumps(dict(request_options), allow_nan=False) except (TypeError, ValueError, OverflowError, RecursionError) as exc: msg = "'run_request_options' must contain finite JSON-compatible values" raise CircuitValidationError(msg) from exc if request_options.get("heralding_mode") == "zeros": msg = "IQM heralding_mode='zeros' is unsupported because it may discard shots." raise CircuitValidationError(msg) return {"custom1": payload} def __init__( self, *, device_id: str | None = None, device: Device | None = None, provider: QDMIProvider | None = None, base_url: str | None = None, token: str | None = None, tokens_file: str | os.PathLike[str] | None = None, qc_id: str | None = None, qc_alias: str | None = None, calibration_set_id: str | UUID | None = None, ) -> None: """Initialize the IQM Qiskit backend. Raises: ValueError: If an already-open device is combined with session overrides. """ if device is not None: if any(value is not None for value in (base_url, token, tokens_file, qc_id, qc_alias, calibration_set_id)): msg = "An already-open device cannot be combined with session overrides." raise ValueError(msg) else: calibration_id = str(UUID(str(calibration_set_id))) if calibration_set_id is not None else None device_id = device_id or IQM_QDMI_DEVICE_ID if device_id == IQM_QDMI_DEVICE_ID: base_url = base_url or os.getenv("IQM_SERVER_URL") or os.getenv("IQM_BASE_URL") if not qc_id and not qc_alias: qc_id = os.getenv("IQM_QC_ID") qc_alias = os.getenv("IQM_QUANTUM_COMPUTER") or os.getenv("IQM_QC_ALIAS") tokens_file_value = tokens_file or os.getenv("IQM_TOKENS_FILE") device = open_device( device_id, base_url=base_url or None, token=token or os.getenv("IQM_TOKEN"), auth_file=Path(tokens_file_value) if tokens_file_value else None, custom1=qc_id, custom2=qc_alias, custom4=calibration_id, ) super().__init__(device=device, provider=provider, device_id=device_id) @property def calibration_set_id(self) -> str: """Effective calibration UUID to share with execution clients. Raises: RuntimeError: If the device does not report a calibration UUID. """ calibration_id = self.device.query_custom_property(CustomProperty.CUSTOM1, str) if calibration_id is None: msg = "The IQM device did not report its calibration set ID." raise RuntimeError(msg) return calibration_id