microsoft/qdk
Publicmirrored from https://github.com/microsoft/qdkAvailable
source/pip/qsharp/interop/qiskit/backends/neutral_atom_backend.py
288lines · modecode
| 1 | # Copyright (c) Microsoft Corporation. |
| 2 | # Licensed under the MIT License. |
| 3 | |
| 4 | import logging |
| 5 | from typing import Any, Dict, List, Literal, Optional, Union |
| 6 | from uuid import uuid4 |
| 7 | |
| 8 | from qiskit import QuantumCircuit |
| 9 | from qiskit.providers import Options |
| 10 | from qiskit.transpiler.target import Target |
| 11 | |
| 12 | from .... import Result, TargetProfile |
| 13 | from .. import OutputSemantics |
| 14 | from ..execution import DetaultExecutor |
| 15 | from ..jobs import QsSimJob, QsJobSet |
| 16 | from .backend_base import BackendBase |
| 17 | from .compilation import Compilation |
| 18 | from .errors import Errors |
| 19 | from .neutral_atom_target import NeutralAtomTarget |
| 20 | |
| 21 | logger = logging.getLogger(__name__) |
| 22 | |
| 23 | |
| 24 | def _bitstring_has_qubit_loss(bitstring: str) -> bool: |
| 25 | """Return True if the bitstring contains a qubit-loss marker. |
| 26 | |
| 27 | Lost qubits may be represented using non-binary markers (e.g. '-', '2'). |
| 28 | We treat any shot containing those markers as lost-qubit affected. |
| 29 | """ |
| 30 | return "-" in bitstring or "2" in bitstring |
| 31 | |
| 32 | |
| 33 | class NeutralAtomBackend(BackendBase): |
| 34 | """A Qiskit backend that simulates circuits using the NeutralAtomDevice pipeline. |
| 35 | |
| 36 | Circuits are transpiled to OpenQASM 3 using the device's native gate set |
| 37 | (Rz, SX, CZ), compiled to QIR via the Q# compiler, then run through the |
| 38 | NeutralAtomDevice compilation and simulation pipeline. |
| 39 | The device handles single-qubit gate optimization and qubit movement scheduling. |
| 40 | An optional noise model can be applied to model realistic device behavior. |
| 41 | |
| 42 | The native gate set target ensures Qiskit's transpiler decomposes all non-native |
| 43 | gates before simulation, so noise configured on native gates (``noise.rz``, |
| 44 | ``noise.sx``, ``noise.cz``, ``noise.mresetz``) behaves as expected. |
| 45 | |
| 46 | The simulator backend (Clifford, CPU full-state, or GPU full-state) is |
| 47 | selected automatically unless overridden via the ``simulator_type`` option. |
| 48 | |
| 49 | Example:: |
| 50 | |
| 51 | from qiskit import QuantumCircuit |
| 52 | from qsharp.interop.qiskit import NeutralAtomBackend |
| 53 | from qsharp._simulation import NoiseConfig |
| 54 | |
| 55 | qc = QuantumCircuit(2) |
| 56 | qc.h(0) |
| 57 | qc.cx(0, 1) |
| 58 | qc.measure_all() |
| 59 | |
| 60 | # Noiseless simulation |
| 61 | backend = NeutralAtomBackend() |
| 62 | job = backend.run(qc, shots=1000) |
| 63 | print(job.result().get_counts()) |
| 64 | |
| 65 | # Noisy simulation |
| 66 | noise = NoiseConfig() |
| 67 | noise.cz.set_depolarizing(1e-3) |
| 68 | noise.mresetz.set_bitflip(1e-3) |
| 69 | |
| 70 | job = backend.run(qc, shots=1000, noise=noise, seed=42) |
| 71 | print(job.result().get_counts()) |
| 72 | """ |
| 73 | |
| 74 | def __init__( |
| 75 | self, |
| 76 | device=None, |
| 77 | target: Optional[Target] = None, |
| 78 | qiskit_pass_options: Optional[Dict[str, Any]] = None, |
| 79 | transpile_options: Optional[Dict[str, Any]] = None, |
| 80 | qasm_export_options: Optional[Dict[str, Any]] = None, |
| 81 | skip_transpilation: bool = False, |
| 82 | **options, |
| 83 | ): |
| 84 | """ |
| 85 | :param device: The NeutralAtomDevice instance to use for compilation and simulation. |
| 86 | A default-configured device is created automatically if not provided. |
| 87 | Pass a custom device to control the qubit layout (column count, zone dimensions, etc.). |
| 88 | :type device: NeutralAtomDevice |
| 89 | :param target: Qiskit transpiler target. Defaults to the NeutralAtomDevice native |
| 90 | gate set ``{rz, sx, cz, measure, reset}``. Override only if you need a custom |
| 91 | decomposition strategy. |
| 92 | :param qiskit_pass_options: Options forwarded to Qiskit pre-transpilation passes. |
| 93 | :type qiskit_pass_options: Dict |
| 94 | :param transpile_options: Options forwarded to ``qiskit.transpile()``. |
| 95 | :type transpile_options: Dict |
| 96 | :param qasm_export_options: Options forwarded to the Qiskit QASM3 exporter. |
| 97 | :type qasm_export_options: Dict |
| 98 | :param skip_transpilation: Skip Qiskit transpilation. Useful when the circuit is |
| 99 | already expressed in terms of the target gate set. |
| 100 | :type skip_transpilation: bool |
| 101 | :param **options: Default option overrides. These can also be overridden per-call via |
| 102 | :meth:`run`. Common options: |
| 103 | |
| 104 | - ``name`` (str): Backend name for job metadata. Defaults to the circuit name. |
| 105 | - ``shots`` (int): Number of shots. Defaults to ``1024``. |
| 106 | - ``seed`` (int): Random seed for reproducibility. Defaults to ``None``. |
| 107 | - ``noise`` (NoiseConfig): Optional per-gate noise model. Defaults to ``None`` (noiseless). |
| 108 | - ``simulator_type`` (str): Simulator to use — ``"clifford"`` (Clifford only), |
| 109 | ``"cpu"`` (CPU full-state), ``"gpu"`` (GPU full-state), or ``None`` to |
| 110 | auto-select (GPU if available, CPU otherwise). |
| 111 | - ``output_semantics`` (OutputSemantics): QIR output encoding. Defaults to ``OutputSemantics.Qiskit``. |
| 112 | - ``executor``: Executor for async job submission. |
| 113 | """ |
| 114 | self._device = device |
| 115 | super().__init__( |
| 116 | target, |
| 117 | qiskit_pass_options, |
| 118 | transpile_options, |
| 119 | qasm_export_options, |
| 120 | skip_transpilation, |
| 121 | **options, |
| 122 | ) |
| 123 | |
| 124 | def _get_device(self): |
| 125 | """Return the NeutralAtomDevice, creating a default one on first access.""" |
| 126 | if self._device is None: |
| 127 | from qsharp._device._atom import NeutralAtomDevice |
| 128 | |
| 129 | self._device = NeutralAtomDevice() |
| 130 | return self._device |
| 131 | |
| 132 | def _build_target(self) -> Target: |
| 133 | """Return a target restricted to the NeutralAtomDevice native gate set. |
| 134 | |
| 135 | Limiting the target to ``{rz, sx, cz, measure, reset}`` ensures Qiskit's |
| 136 | transpiler decomposes all non-native gates before QASM3 export, so the |
| 137 | circuit that reaches the simulator already uses only native gates. |
| 138 | """ |
| 139 | return NeutralAtomTarget.build_target(num_qubits=None) |
| 140 | |
| 141 | @classmethod |
| 142 | def _default_options(cls): |
| 143 | return Options( |
| 144 | search_path=".", |
| 145 | shots=1024, |
| 146 | seed=None, |
| 147 | noise=None, |
| 148 | simulator_type=None, |
| 149 | output_semantics=OutputSemantics.Qiskit, |
| 150 | executor=DetaultExecutor(), |
| 151 | ) |
| 152 | |
| 153 | def run( |
| 154 | self, |
| 155 | run_input: Union[QuantumCircuit, List[QuantumCircuit]], |
| 156 | **options, |
| 157 | ) -> Union[QsSimJob, QsJobSet]: |
| 158 | """Simulate the given circuit(s) using the NeutralAtomDevice pipeline. |
| 159 | |
| 160 | :param run_input: A single ``QuantumCircuit`` or a list of them. |
| 161 | :param **options: Per-call option overrides. Common options: |
| 162 | |
| 163 | - ``name`` (str): Backend name for job metadata. Defaults to the circuit name. |
| 164 | - ``shots`` (int): Number of shots. Defaults to ``1024``. |
| 165 | - ``seed`` (int): Random seed for reproducibility. Defaults to ``None``. |
| 166 | - ``noise`` (NoiseConfig): Optional per-gate noise model. Defaults to ``None`` (noiseless). |
| 167 | - ``simulator_type`` (str): Simulator to use — ``"clifford"`` (Clifford only), |
| 168 | ``"cpu"`` (CPU full-state), ``"gpu"`` (GPU full-state), or ``None`` to |
| 169 | auto-select (GPU if available, CPU otherwise). |
| 170 | - ``output_semantics`` (OutputSemantics): QIR output encoding. Defaults to ``OutputSemantics.Qiskit``. |
| 171 | - ``executor``: Executor for async job submission. |
| 172 | :return: A job object whose ``.result()`` returns a Qiskit ``Result``. |
| 173 | :rtype: QsSimJob |
| 174 | :raises ValueError: If ``run_input`` is not a ``QuantumCircuit`` or list thereof, |
| 175 | or if a ``target_profile`` other than ``TargetProfile.Base`` is provided. |
| 176 | """ |
| 177 | run_input = self._validate_quantum_circuits(run_input) |
| 178 | return self._run(run_input, **options) |
| 179 | |
| 180 | def _map_result_bit(self, v) -> str: |
| 181 | """Override: unknown values are qubit-loss markers (``"-"``).""" |
| 182 | if v == Result.One: |
| 183 | return "1" |
| 184 | if v == Result.Zero: |
| 185 | return "0" |
| 186 | return "-" |
| 187 | |
| 188 | def _execute(self, programs: List[Compilation], **input_params) -> Dict[str, Any]: |
| 189 | device = self._get_device() |
| 190 | |
| 191 | shots = input_params.get("shots") |
| 192 | if shots is None: |
| 193 | raise ValueError(str(Errors.MISSING_NUMBER_OF_SHOTS)) |
| 194 | |
| 195 | noise = input_params.get("noise") |
| 196 | simulator_type: Optional[Literal["clifford", "cpu", "gpu"]] = input_params.get( |
| 197 | "simulator_type" |
| 198 | ) |
| 199 | seed: Optional[int] = input_params.get("seed") |
| 200 | search_path: str = input_params.get("search_path", ".") |
| 201 | output_semantics = input_params.get("output_semantics") |
| 202 | |
| 203 | # NeutralAtomDevice always requires base-profile QIR — the device's |
| 204 | # compilation pipeline validates that no conditional branches exist. |
| 205 | # Raise explicitly if the caller passed a non-Base profile so the |
| 206 | # error is immediate and clear rather than silently ignored. |
| 207 | target_profile = input_params.get("target_profile") |
| 208 | if target_profile is not None and target_profile != TargetProfile.Base: |
| 209 | raise ValueError( |
| 210 | "NeutralAtomBackend only supports TargetProfile.Base. " |
| 211 | "The NeutralAtomDevice compilation pipeline does not support " |
| 212 | f"conditional branches produced by {target_profile}." |
| 213 | ) |
| 214 | |
| 215 | job_results = [] |
| 216 | for program in programs: |
| 217 | name = input_params.get("name", program.circuit.name) |
| 218 | |
| 219 | # Compile QASM3 → QIR (base profile). |
| 220 | qir = self._qasm_to_qir( |
| 221 | program.qasm, |
| 222 | name=name, |
| 223 | target_profile=TargetProfile.Base, |
| 224 | output_semantics=output_semantics, |
| 225 | search_path=search_path, |
| 226 | ) |
| 227 | |
| 228 | # Run through NeutralAtomDevice compilation + simulation pipeline. |
| 229 | sim_results = device.simulate( |
| 230 | qir, |
| 231 | shots=shots, |
| 232 | noise=noise, |
| 233 | type=simulator_type, |
| 234 | seed=seed, |
| 235 | ) |
| 236 | |
| 237 | raw_memory = [self._shot_to_bitstring(shot) for shot in sim_results] |
| 238 | |
| 239 | # Separate accepted shots (no loss markers) from raw shots. |
| 240 | # Qiskit-compatible fields (counts, memory, probabilities) |
| 241 | # contain only clean {0,1} outcomes; raw_* fields retain the |
| 242 | # full picture including loss. |
| 243 | memory = [s for s in raw_memory if not _bitstring_has_qubit_loss(s)] |
| 244 | accepted_total_count = len(memory) |
| 245 | raw_total_count = len(raw_memory) |
| 246 | |
| 247 | raw_counts: Dict[str, int] = {} |
| 248 | counts: Dict[str, int] = {} |
| 249 | for bs in raw_memory: |
| 250 | raw_counts[bs] = raw_counts.get(bs, 0) + 1 |
| 251 | if not _bitstring_has_qubit_loss(bs): |
| 252 | counts[bs] = counts.get(bs, 0) + 1 |
| 253 | |
| 254 | raw_probabilities = ( |
| 255 | {} |
| 256 | if raw_total_count == 0 |
| 257 | else {bs: c / raw_total_count for bs, c in raw_counts.items()} |
| 258 | ) |
| 259 | probabilities = ( |
| 260 | {} |
| 261 | if accepted_total_count == 0 |
| 262 | else {bs: c / accepted_total_count for bs, c in counts.items()} |
| 263 | ) |
| 264 | |
| 265 | job_results.append( |
| 266 | { |
| 267 | "data": { |
| 268 | # Qiskit-compatible fields: loss shots excluded. |
| 269 | "counts": counts, |
| 270 | "probabilities": probabilities, |
| 271 | "memory": memory, |
| 272 | # Raw fields: all shots, including loss markers. |
| 273 | "raw_counts": raw_counts, |
| 274 | "raw_probabilities": raw_probabilities, |
| 275 | "raw_memory": raw_memory, |
| 276 | }, |
| 277 | "success": True, |
| 278 | "header": { |
| 279 | "metadata": {"qasm": program.qasm}, |
| 280 | "name": program.circuit.name, |
| 281 | "compilation_time_taken": program.time_taken, |
| 282 | }, |
| 283 | # shots reflects accepted (non-loss) count. |
| 284 | "shots": accepted_total_count, |
| 285 | } |
| 286 | ) |
| 287 | |
| 288 | return {"results": job_results, "qobj_id": str(uuid4()), "success": True} |
| 289 | |