# Copyright (c) 2023 - 2026 Chair for Design Automation, TUM
# Copyright (c) 2025 - 2026 Munich Quantum Software Company GmbH
# All rights reserved.
#
# SPDX-License-Identifier: MIT
#
# Licensed under the MIT License
"""QDMI Qiskit Backend.
Provides a Qiskit BackendV2-compatible interface to QDMI devices via FoMaC.
"""
from __future__ import annotations
import inspect
import itertools
import warnings
from typing import TYPE_CHECKING, Any, ClassVar
from qiskit import qasm2, qasm3
from qiskit.circuit import QuantumCircuit
from qiskit.circuit.library import (
MCPhaseGate,
MCXGate,
get_standard_gate_name_mapping,
)
from qiskit.providers import BackendV2, Options
from qiskit.transpiler import InstructionProperties, Target
from ... import fomac
from .converters import qiskit_to_iqm_json
from .exceptions import (
CircuitValidationError,
JobSubmissionError,
TranslationError,
UnsupportedDeviceError,
UnsupportedFormatError,
UnsupportedOperationError,
)
from .gates import MoveGate
from .job import QDMIJob
if TYPE_CHECKING:
from collections.abc import Iterable, Mapping, MutableSet, Sequence
from qiskit.circuit import Instruction, Parameter
from qiskit.circuit.parameterexpression import ParameterValueType
from .provider import QDMIProvider
# Type alias for parameter values
ParametersType = Mapping[Parameter, ParameterValueType] | Iterable[ParameterValueType]
__all__ = ["QDMIBackend"]
def __dir__() -> list[str]:
return __all__
def _build_gate_mappings_for_backend(
gate_aliases: dict[str, set[str]],
) -> tuple[dict[str, set[str]], dict[str, Instruction | type[Instruction]]]:
"""Build both forward (Qiskit→QDMI) and inverse (QDMI→Gate) mappings.
Uses Qiskit's standard gate mapping as the canonical source of truth,
combined with a list of device-specific aliases.
Args:
gate_aliases: Maps canonical names to their aliases.
Returns:
Tuple of (qiskit_to_qdmi_map, operation_to_gate_map).
"""
# Get Qiskit's standard gate name mapping as our canonical source
canonical_gates = get_standard_gate_name_mapping()
# Augment the canonical mapping with any additional gates that may not be in Qiskit's standard library
canonical_gates.update({
"mcx": MCXGate,
"mcphase": MCPhaseGate,
"mcp": MCPhaseGate,
"mcx_gray": MCXGate,
"move": MoveGate(),
})
qiskit_to_qdmi: dict[str, set[str]] = {}
operation_to_gate: dict[str, Instruction | type[Instruction]] = {}
# Process each canonical gate from Qiskit's standard library
for canonical_name, gate in canonical_gates.items():
# Get all names for this gate (canonical + aliases)
all_names = {canonical_name}
if canonical_name in gate_aliases:
all_names.update(gate_aliases[canonical_name])
# For each name, map it to all names (bidirectional aliases)
for name in all_names:
qiskit_to_qdmi[name] = all_names.copy()
operation_to_gate[name] = gate
return qiskit_to_qdmi, operation_to_gate
[docs]
class QDMIBackend(BackendV2):
"""A Qiskit BackendV2 adapter for QDMI devices via FoMaC.
This backend provides program submission to QDMI devices.
It automatically introspects device capabilities and constructs a
:class:`~qiskit.transpiler.Target` object with supported operations.
Backends should be obtained through :class:`~mqt.core.qdmi.qiskit.QDMIProvider`
rather than instantiated directly.
Args:
device: FoMaC device to wrap.
provider: The provider instance that created this backend.
Examples:
Get a backend through the provider:
>>> from mqt.core.plugins.qiskit import QDMIProvider
>>> provider = QDMIProvider()
>>> backend = provider.get_backend("MQT Core DDSIM QDMI Device")
"""
[docs]
@staticmethod
def is_convertible(device: fomac.Device) -> bool:
"""Returns whether a device can be represented in Qiskit's Target model."""
# Zoned operations cannot easily be represented in Qiskit's Target model
return not any(op.is_zoned() for op in device.operations())
# Class-level counter for generating unique circuit names
_circuit_counter = itertools.count()
# Define known aliases
_GATE_ALIASES: ClassVar[dict[str, set[str]]] = {
"id": {"i"}, # Identity gate can also be called 'i'
"p": {"phase"}, # Phase gate can also be called 'phase'
"r": {"prx"}, # R gate can also be called 'prx' (IQM naming)
"u": {"u3"}, # U and U3 are the same gate
"cu": {"cu3"}, # CU and CU3 are the same gate
"cx": {"cnot"}, # CX and CNOT are the same gate
"global_phase": {"gphase"}, # Qiskit canonical name
"gphase": {"global_phase"}, # OpenQASM canonical name
"mcphase": {"mcp"}, # Qiskit canonical name
"mcp": {"mcphase"}, # OpenQASM canonical name
"mcx_gray": {"mcx"}, # Alias for MCX with specific encoding
"mcx_vchain": {"mcx"}, # Alias for MCX with specific encoding
"mcx_recursive": {"mcx"}, # Alias for MCX with specific encoding
}
_QDMI_TO_QISKIT_GATE_MAP: ClassVar[dict[str, str]] = {
"i": "id",
"prx": "r",
"mcp": "mcphase",
"u3": "u",
"gphase": "global_phase",
"cu3": "cu",
}
_QISKIT_TO_QDMI_GATE_MAP: ClassVar[dict[str, set[str]]]
_OPERATION_TO_GATE_MAP: ClassVar[dict[str, Instruction | type[Instruction]]]
# Initialize derived mappings at class definition time
_QISKIT_TO_QDMI_GATE_MAP, _OPERATION_TO_GATE_MAP = _build_gate_mappings_for_backend(_GATE_ALIASES)
def __init__(self, device: fomac.Device, provider: QDMIProvider | None = None) -> None:
"""Initialize the backend with a FoMaC device.
Args:
device: FoMaC device instance.
provider: Provider instance that created this backend.
Raises:
UnsupportedDeviceError: If the device cannot be represented in Qiskit's Target model.
"""
if not self.is_convertible(device):
msg = f"Device '{device.name()}' cannot be represented in Qiskit's Target model"
raise UnsupportedDeviceError(msg)
super().__init__(name=device.name(), provider=provider, backend_version=device.version())
self._device = device
# Build Target from device
self._target = self._build_target()
@property
def target(self) -> Target:
"""The Target describing the capabilities of the backend."""
return self._target
@property
def provider(self) -> Any | None: # ruff:ignore[any-type]
"""The provider that created the backend."""
return self._provider
@property
def max_circuits(self) -> int | None:
"""The maximum number of circuits that can be run in a single job."""
return None # No limit, processed sequentially
@property
def options(self) -> Options:
"""The backend options."""
return self._options
@classmethod
def _default_options(cls) -> Options:
"""Return default backend options.
Returns:
Default Options with shots=1024.
"""
return Options(shots=1024)
def _target_num_qubits(self) -> int:
"""Number of addressable qubits to expose in the Target.
Subclasses may override this to hide device sites that should not be
directly addressable by the transpiler (e.g. computational
resonators on star-topology architectures).
Returns:
Number of qubits to expose in the Target.
"""
return self._device.qubits_num()
def _build_target(self) -> Target:
"""Construct a Qiskit Target from device capabilities.
Returns:
Target object with device operations and properties.
"""
target = Target(
description=f"QDMI device: {self._device.name()}",
num_qubits=self._target_num_qubits(),
)
# Deduplicate operations by Qiskit gate name (not device operation name)
# Multiple device operations may map to the same Qiskit gate
seen_gate_names: set[str] = set()
# Add operations from device
for op in self._device.operations():
self._add_operation_to_target(target, op, seen_gate_names)
# Check if the measurement operation is defined
if "measure" not in seen_gate_names:
warnings.warn(
f"{self._device.name()} does not define a measurement operation. This may limit practical usage.",
UserWarning,
stacklevel=2,
)
return target
def _add_operation_to_target(
self, target: Target, op: fomac.Device.Operation, seen_gate_names: MutableSet[str]
) -> None:
"""Add a single device operation to the Target, if it maps to a Qiskit gate.
Subclasses may override this to customize how an individual device
operation is represented in the Target, e.g. substituting fictional
qubit-qubit loci for an operation that natively acts on non-qubit
sites (such as a qubit-resonator gate).
Args:
target: The Target being constructed.
op: The device operation to add.
seen_gate_names: Qiskit gate names already added to the target (mutated in place).
"""
# Map known operations to Qiskit gates
op_name = op.name().lower()
# Skip control flow operations that don't belong in the Target
# (barrier is handled separately by Qiskit, if_else is a circuit construct)
if op_name in {"barrier", "if_else"}:
return
if op_name in self._QDMI_TO_QISKIT_GATE_MAP:
op_name = self._QDMI_TO_QISKIT_GATE_MAP[op_name]
gate = self._map_operation_to_gate(op_name)
if gate is None:
warnings.warn(
f"Device operation '{op_name}' cannot be mapped to a Qiskit gate and will be skipped",
UserWarning,
stacklevel=2,
)
return
is_class = inspect.isclass(gate)
# Skip if we've already added this Qiskit gate to the target
gate_name = op_name if is_class else gate.name
if gate_name in seen_gate_names:
return
seen_gate_names.add(gate_name)
# Determine which qubits this operation applies to
qargs = self._get_operation_qargs(op)
# Globally supported gates (such as MCX) must specify a name and no properties
if is_class:
target.add_instruction(gate, name=op_name)
return
# If qargs is [None], it means the operation is available on all qubits
if qargs == [None]:
# Create instruction properties
props = None
duration = op.duration()
fidelity = op.fidelity()
if duration is not None or fidelity is not None:
error = 1.0 - fidelity if fidelity is not None else None
props = InstructionProperties(
duration=duration,
error=error,
)
target.add_instruction(gate, {None: props})
return
# Add the operation without properties and populate them iteratively later
target.add_instruction(gate, dict.fromkeys(qargs))
num_qubits = op.qubits_num()
if num_qubits == 1:
op_sites = op.sites()
assert op_sites is not None
for qarg, site in zip(qargs, op_sites, strict=True):
duration = op.duration(sites=[site])
fidelity = op.fidelity(sites=[site])
if duration is not None or fidelity is not None:
error = 1.0 - fidelity if fidelity is not None else None
props = InstructionProperties(
duration=duration,
error=error,
)
target.update_instruction_properties(gate_name, qarg, props)
return
if num_qubits == 2:
op_site_pairs = op.site_pairs()
assert op_site_pairs is not None
for qarg, (site1, site2) in zip(qargs, op_site_pairs, strict=True):
duration = op.duration(sites=[site1, site2])
fidelity = op.fidelity(sites=[site1, site2])
if duration is not None or fidelity is not None:
error = 1.0 - fidelity if fidelity is not None else None
props = InstructionProperties(
duration=duration,
error=error,
)
target.update_instruction_properties(gate_name, qarg, props)
return
@staticmethod
def _map_operation_to_gate(op_name: str) -> Instruction | type[Instruction] | None:
"""Map a device operation name to a Qiskit gate.
Args:
op_name: Device operation name.
Returns:
Qiskit gate instance or None if not mappable.
"""
return QDMIBackend._OPERATION_TO_GATE_MAP.get(op_name.lower())
@staticmethod
def _map_qiskit_gate_to_operation_names(qiskit_gate_name: str) -> set[str]:
"""Map a Qiskit gate name to possible QDMI device operation names.
This is the inverse of _map_operation_to_gate, accounting for the fact that
different devices may use different naming conventions for the same operation.
Args:
qiskit_gate_name: Qiskit gate name.
Returns:
Set of possible QDMI device operation names that could map to this gate.
"""
return QDMIBackend._QISKIT_TO_QDMI_GATE_MAP.get(qiskit_gate_name.lower(), {qiskit_gate_name.lower()})
def _get_operation_qargs(self, op: fomac.Device.Operation) -> list[tuple[int]] | list[tuple[int, int]] | list[None]:
"""Get the qubit argument tuples for an operation.
This method determines which qubit indices an operation can act on by:
1. Checking explicit site lists from the operation (sites() for 1-qubit, site_pairs() for 2-qubit)
2. For operations without site lists (returns None):
- Single-qubit: Available on all individual qubits
- Two-qubit with coupling map: Misconfigured device (error)
- Two-qubit without coupling map: Available on all qubit pairs (all-to-all)
- Multi-qubit (3+): Assumed to be globally available
Args:
op: Device operation from FoMaC.
Returns:
Sequence of qubit index tuples this operation can act on.
Returns [None] for globally available operations (will be converted to {None: None} in Target).
Raises:
UnsupportedOperationError: If the device is misconfigured.
"""
qubits_num = op.qubits_num()
# For single-qubit operations, first check for explicit sites
if qubits_num == 1:
site_list = op.sites()
if site_list is not None:
# Operation explicitly defines where it can be executed
return [(s.index(),) for s in site_list]
# No explicit sites - operation is globally available on all qubits
return [None]
# For two-qubit operations, first check for explicit site_pairs
if qubits_num == 2:
site_pairs = op.site_pairs()
if site_pairs is not None:
return [(s1.index(), s2.index()) for s1, s2 in site_pairs]
# Two-qubit operations without explicit site_pairs
# Check device-level coupling map
coupling_map = self._device.coupling_map()
if coupling_map is not None:
# Device has coupling map but operation doesn't expose sites
msg = (
f"Device provides a coupling map (stating connectivity constraints), "
f"but operation '{op.name()}' does not expose site pairs. This indicates "
f"a misconfigured device. Devices with connectivity constraints must expose "
f"sites for their operations."
)
raise UnsupportedOperationError(msg)
# No coupling map and no site pairs - operation is globally available (all-to-all)
return [None]
# Operation has unspecified qubit count or 3+ qubits -> assume it applies to all qubits
return [None]
def _preprocess_circuit(self, circuit: QuantumCircuit) -> QuantumCircuit: # ruff:ignore[no-self-use]
"""Rewrite a bound circuit before validation and conversion.
Called once per circuit in :meth:`run`, after parameter binding and
before operation-support validation and program conversion.
Subclasses may override this to transform a circuit into a
device-native equivalent, e.g. inserting MOVE gates and widening the
circuit to address computational resonators. The default
implementation is the identity function.
Args:
circuit: The bound circuit to preprocess.
Returns:
The (possibly rewritten) circuit to use for validation and conversion.
"""
return circuit
def _convert_circuit(
self, circuit: QuantumCircuit, supported_program_formats: Iterable[fomac.ProgramFormat]
) -> tuple[str, fomac.ProgramFormat]:
"""Convert a :class:`~qiskit.circuit.QuantumCircuit` to one of the supported program formats.
The conversion priority order is:
1. IQM JSON (if supported) - device-specific format
2. OpenQASM 3 (if supported) - superset of QASM 2
3. OpenQASM 2 (if supported) - legacy format
Args:
circuit: The quantum circuit to convert.
supported_program_formats: Supported program formats.
Returns:
Tuple of (program string, program format).
Raises:
UnsupportedFormatError: If no supported program formats are found.
UnsupportedOperationError: If the circuit contains operations not supported by IQM JSON.
TranslationError: If conversion fails.
"""
if not supported_program_formats:
msg = "No supported program formats found"
raise UnsupportedFormatError(msg)
# Try IQM JSON format first (device-specific)
if fomac.ProgramFormat.IQM_JSON in supported_program_formats:
try:
return qiskit_to_iqm_json(circuit, self._device), fomac.ProgramFormat.IQM_JSON
except UnsupportedOperationError:
# Let this propagate so caller can handle fallback
raise
except Exception as exc:
msg = f"Failed to convert circuit to IQM JSON: {exc}"
raise TranslationError(msg) from exc
# Try OpenQASM3
if fomac.ProgramFormat.QASM3 in supported_program_formats:
# Qiskit's OpenQASM3 exporter is fairly limited in terms of which gates it supports natively.
# So it needs some help from us.
exclusion_list = set()
# Qiskit treats "measure", "reset", and "barrier" as keywords rather than gates
exclusion_list.update({"measure", "reset", "barrier"})
# We also need to remove all gates that are defined in the OpenQASM `stdlib.inc`.
# Qiskit's exporter will otherwise complain about duplicate definitions.
exclusion_list.update({
"p",
"x",
"y",
"z",
"h",
"s",
"sdg",
"t",
"tdg",
"sx",
"rx",
"ry",
"rz",
"cx",
"cy",
"cz",
"cp",
"crx",
"cry",
"crz",
"ch",
"swap",
"ccx",
"cswap",
"cu",
"CX",
"phase",
"cphase",
"id",
"u1",
"u2",
"u3",
})
# By excluding already defined gates, we allow the exporter to emit otherwise unsupported gates without
# needing to provide a definition for them. The exporter will then treat them as opaque gates, which is fine
# as long as the target device supports them.
basis_gates = [gate for gate in self.target.operation_names if gate not in exclusion_list] + ["U"]
try:
return qasm3.dumps(circuit, basis_gates=basis_gates), fomac.ProgramFormat.QASM3
except Exception as exc:
msg = f"Failed to convert circuit to QASM3: {exc}"
raise TranslationError(msg) from exc
# Try OpenQASM2 (legacy)
if fomac.ProgramFormat.QASM2 in supported_program_formats:
try:
return qasm2.dumps(circuit), fomac.ProgramFormat.QASM2
except Exception as exc:
msg = f"Failed to convert circuit to QASM2: {exc}"
raise TranslationError(msg) from exc
msg = f"No conversion from Qiskit to any of the supported program formats: {supported_program_formats}"
raise UnsupportedFormatError(msg)
[docs]
def run(
self,
run_input: QuantumCircuit | Sequence[QuantumCircuit],
parameter_values: Sequence[ParametersType] | None = None,
**options: Any, # ruff:ignore[any-type]
) -> QDMIJob:
"""Execute one or more :class:`~qiskit.circuit.QuantumCircuit` instances on the backend.
Args:
run_input: A single quantum circuit or a sequence of quantum circuits to execute.
parameter_values: Optional parameter values to bind to the circuits. If provided, must be a sequence
with one entry per circuit. Each entry can be either a dictionary mapping parameters to values,
or a sequence of values in the order of circuit.parameters.
**options: Execution options (e.g., shots).
Returns:
Job handle for the execution. For multiple circuits, the job aggregates results from all circuits.
Raises:
CircuitValidationError: If circuit validation fails (e.g., invalid shots, unbound parameters,
parameter_values length mismatch).
UnsupportedOperationError: If a circuit contains unsupported operations.
JobSubmissionError: If job submission to the device fails.
Examples:
Run a single circuit with parameter values:
>>> from qiskit.circuit import Parameter, QuantumCircuit
>>> theta = Parameter("theta")
>>> qc = QuantumCircuit(1)
>>> qc.ry(theta, 0)
>>> qc.measure_all()
>>> job = backend.run(qc, parameter_values=[{theta: 1.5708}])
Run multiple circuits with different parameter values:
>>> qc1 = QuantumCircuit(1)
>>> qc1.ry(theta, 0)
>>> qc1.measure_all()
>>> qc2 = QuantumCircuit(1)
>>> qc2.ry(theta, 0)
>>> qc2.measure_all()
>>> job = backend.run([qc1, qc2], parameter_values=[{theta: 0.5}, {theta: 1.5}])
"""
# Normalize input to a list of circuits
circuits = [run_input] if isinstance(run_input, QuantumCircuit) else run_input
# Validate non-empty circuit list
if not circuits:
msg = "No circuits provided to run. At least one circuit is required."
raise CircuitValidationError(msg)
# Validate parameter_values length if provided
if parameter_values is not None and len(parameter_values) != len(circuits):
msg = (
f"Length of parameter_values ({len(parameter_values)}) must match "
f"the number of circuits ({len(circuits)})"
)
raise CircuitValidationError(msg)
# Get shots option
shots_opt = options.get("shots", self._options.shots)
try:
shots = int(shots_opt)
except Exception as exc:
msg = f"Invalid 'shots' value: {shots_opt!r}"
raise CircuitValidationError(msg) from exc
if shots < 0:
msg = f"'shots' must be >= 0, got {shots}"
raise CircuitValidationError(msg)
# Build set of all supported QDMI operation names once
device_ops = {op.name().lower() for op in self._device.operations()}
# Process each circuit
qdmi_jobs: list[fomac.Job] = []
circuit_names: list[str] = []
# First pass: validate and convert all circuits
converted_circuits: list[tuple[str, fomac.ProgramFormat, str]] = []
for idx, circuit in enumerate(circuits):
# Bind parameters if provided
bound_circuit = circuit
if parameter_values is not None:
try:
bound_circuit = circuit.assign_parameters(parameter_values[idx])
except Exception as exc:
msg = f"Failed to bind parameters for circuit {idx}: {exc}"
raise CircuitValidationError(msg) from exc
# Validate circuit has no unbound parameters
if bound_circuit.parameters:
params = ", ".join(sorted(p.name for p in bound_circuit.parameters))
msg = (
f"Circuit contains unbound parameters: {params}. Provide `parameter_values` or bind them manually."
)
raise CircuitValidationError(msg)
bound_circuit = self._preprocess_circuit(bound_circuit)
# Validate operations are supported
for instruction in bound_circuit.data:
op_name = instruction.operation.name
# Map the Qiskit gate name to possible QDMI operation names and check if any match
possible_qdmi_names = self._map_qiskit_gate_to_operation_names(op_name)
# Check if any of the possible QDMI names are supported by the device
# Also always allow 'barrier' as it's a directive, not an operation
if op_name != "barrier" and not any(qdmi_name in device_ops for qdmi_name in possible_qdmi_names):
msg = f"Unsupported operation: '{op_name}'"
raise UnsupportedOperationError(msg)
# Convert circuit to the specified program format
program_str, program_format = self._convert_circuit(bound_circuit, self._device.supported_program_formats())
circuit_name = circuit.name or f"circuit-{next(QDMIBackend._circuit_counter)}"
converted_circuits.append((program_str, program_format, circuit_name))
# Second pass: submit all validated circuits
for program_str, program_format, circuit_name in converted_circuits:
# Submit job to QDMI device
try:
qdmi_job = self._device.submit_job(
program=program_str,
program_format=program_format,
num_shots=shots,
)
except Exception as exc:
msg = f"Failed to submit job to device: {exc}"
raise JobSubmissionError(msg) from exc
# Track the job and circuit name
qdmi_jobs.append(qdmi_job)
circuit_names.append(circuit_name)
# Create and return Qiskit job wrapper (handles single or multiple jobs)
return QDMIJob(backend=self, jobs=qdmi_jobs, circuit_names=circuit_names)