Source code for pytket.extensions.qiskit.backends.crosstalk_model

# Copyright 2019-2024 Quantinuum
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# 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.


from dataclasses import dataclass
from typing import Optional

import numpy as np
from qiskit_aer.noise import NoiseModel  # type: ignore
from qiskit_aer.noise.errors.standard_errors import (  # type: ignore
    amplitude_damping_error,
    phase_damping_error,
)
from scipy.linalg import fractional_matrix_power  # type: ignore

from pytket.backends.backendinfo import BackendInfo
from pytket.circuit import (
    Circuit,
    Command,
    Node,
    Op,
    OpType,
    Qubit,
    Unitary1qBox,
    Unitary2qBox,
    Unitary3qBox,
)
from pytket.extensions.qiskit.qiskit_convert import _gate_str_2_optype


@dataclass
class FractionalUnitary:
    """
    Wrapper for a fractional unitary gate.

    :param cmd: the fractional UnitaryBox wrapped in a pytket Command
    :param n_fractions: the number of fractional gates
        used to compose the original unitary gate.
    """

    cmd: Command
    n_fractions: float


@dataclass
class NoiseGate:
    """
    Wrapper for a gate that simulates noise
    :param cmd: gate wrapped in a pytket Command
    :param type: one of zz_crosstalks, single_q_phase, two_q_induced_phase
        and non_markovian.
    """

    cmd: Command
    type: str


Instruction = FractionalUnitary | Command | NoiseGate
Slice = list[Instruction]
EPS = 1e-9


[docs] @dataclass class CrosstalkParams: """ Stores various parameters for modelling crosstalk noise :param zz_crosstalks: symmetric crosstalks between qubit pairs :param single_q_phase_errors: dict specify the single qubit phase error on each qubit :param two_q_induced_phase_errors: keys of dictionary specify the control and target qubit index, while the values are tuples with the spectator qubit index and the amount of phase error to be applied. :param non_markovian_noise: List storing the non-Markovian noise parameters. Each tuple in the list contains the qubit index and the zx, zz noise parameters. :param virtual_z: If True, then don't break any single qubit Z gate into unitary fractions, instead add the full unitary. :param N: hyperparameter specifies splices per second. 1/N must divide all gate times. :param gate_times: python dict to store the gate time information. :param phase_damping_error: dict specify amplitude phase damping error on each qubit :param amplitude_damping_error: dict pecify amplitude damping error on each qubit """ zz_crosstalks: dict[tuple[Qubit, Qubit], float] single_q_phase_errors: dict[Qubit, float] two_q_induced_phase_errors: dict[tuple[Qubit, Qubit], tuple[Qubit, float]] non_markovian_noise: list[tuple[Qubit, float, float]] virtual_z: bool N: float gate_times: dict[tuple[OpType, tuple[Qubit, ...]], float] phase_damping_error: dict[Qubit, float] amplitude_damping_error: dict[Qubit, float]
[docs] def get_noise_model(self) -> NoiseModel: """Construct a NoiseModel from phase_damping_error and amplitude_damping_error""" noise_model = NoiseModel() for q, phase in self.phase_damping_error.items(): noise_model.add_quantum_error( phase_damping_error(phase / self.N), ["unitary"], [q.index[0]] ) for q, amp in self.amplitude_damping_error.items(): noise_model.add_quantum_error( amplitude_damping_error(amp / self.N), ["unitary"], [q.index[0]], warnings=False, ) return noise_model
class NoisyCircuitBuilder: """Builder used to generate a noisy circuit""" Ibox = Unitary1qBox(np.eye(2)) # type: ignore SUPPORTED_TYPES = { OpType.Unitary1qBox, OpType.Unitary2qBox, OpType.Unitary3qBox, OpType.Measure, OpType.Reset, } def __init__( self, circ: Circuit, ct_params: CrosstalkParams, ) -> None: """Construct a builder to generate noisy circuit :param circ: the original circuit. :param N: hyperparameter N :param ct_params: crosstalk parameters. """ self.circ = circ self.all_qubits = set(circ.qubits) self.N = ct_params.N self.ct_params = ct_params self.two_level_map = {} for i, (q, _, _) in enumerate(self.ct_params.non_markovian_noise): two_level_q = Qubit("two-level", i) self.two_level_map[q] = two_level_q self.reset() def reset(self) -> None: """Clear the build cache""" self._slices: list[Slice] = [] @staticmethod def _get_qubits(inst: Instruction) -> list[Qubit]: if isinstance(inst, Command): return inst.qubits else: return inst.cmd.qubits def _append( self, inst: Instruction, frontier: dict[Qubit, int], ) -> None: """Append a command to the splices in Tetris style""" args = self._get_qubits(inst) pivot_q = max(args, key=lambda q: frontier[q]) slice_idx = frontier[pivot_q] # add cmd to the idx_th slice n_slices = len(self._slices) assert slice_idx <= n_slices if slice_idx == n_slices: self._slices.append([inst]) else: self._slices[slice_idx].append(inst) # update frontier for q in args: frontier[q] = slice_idx + 1 def _fill_gaps(self, frontier: dict[Qubit, int]) -> None: """Fill the gaps in the slices with identity `Unitary1qBox`es""" for idx, s in enumerate(self._slices): slice_qubits = set().union(*[self._get_qubits(inst) for inst in s]) gap_qs = self.all_qubits - slice_qubits for q in gap_qs: # only fill up to the frontier if idx < frontier[q]: s.append(Command(self.Ibox, [q])) def sort_and_fill_gaps(self) -> None: """Sort splices so each slice only contains independent instructions""" old_slices = self._slices.copy() self._slices = [] frontier = {q: 0 for q in self.all_qubits} for s in old_slices: for inst in s: self._append(inst, frontier) self._fill_gaps(frontier) @staticmethod def _get_ubox(u: np.ndarray) -> Unitary1qBox | Unitary2qBox | Unitary3qBox: """Return a UnitaryxqBox for a given unitary""" if u.shape[0] == 2: return Unitary1qBox(u) if u.shape[0] == 4: return Unitary2qBox(u) if u.shape[0] == 8: return Unitary3qBox(u) raise ValueError(f"Unsupported unitary shape: {u.shape}") def unitary_factorisation(self) -> None: """For each unitary U with time D, factorise it into N*D unitaries u_i, such that u_0;u1;...;u_(N*D-1) = U. Store the factorised circuit as a list of slices """ for cmd in self.circ: if cmd.op.type in [OpType.Measure, OpType.Reset]: self._slices.append([cmd]) else: gt: Optional[float] if self.ct_params.virtual_z and cmd.op.type == OpType.Z: gt = 1 / self.N else: gt = self.ct_params.gate_times.get((cmd.op.type, tuple(cmd.qubits))) if gt is None: raise ValueError( f"No gate time for OpType {cmd.op.type} on qubits {cmd.qubits}" ) u = cmd.op.get_unitary() n_fractions = round(self.N * gt) if abs((self.N * gt) - n_fractions) > 1e-9: raise ValueError( f"Command {cmd} cannot be factorised into equal slices" ) power = 1 / n_fractions u_i = fractional_matrix_power(u, power) for _ in range(n_fractions): u_i_box = self._get_ubox(u_i) self._slices.append( [FractionalUnitary(Command(u_i_box, cmd.args), n_fractions)] ) def _add_zz_crosstalks(self, noise_slice: Slice) -> None: for (q0, q1), zz in self.ct_params.zz_crosstalks.items(): if q0 in self.all_qubits and q1 in self.all_qubits: Z = zz / self.N if abs(Z) > EPS: noise_slice.append( NoiseGate( Command(Op.create(OpType.ZZPhase, Z), [q0, q1]), "zz_crosstalks", ) ) def _add_single_q_phase(self, noise_slice: Slice) -> None: for q, z in self.ct_params.single_q_phase_errors.items(): if q in self.all_qubits: Z = z / self.N if abs(Z) > EPS: noise_slice.append( NoiseGate( Command(Op.create(OpType.Rz, Z), [q]), "single_q_phase" ) ) def _add_two_q_induced_phase( self, unitary_slice: Slice, noise_slice: Slice ) -> None: for inst in unitary_slice: if ( isinstance(inst, FractionalUnitary) and inst.cmd.op.type == OpType.Unitary2qBox ): qubits = inst.cmd.qubits value = self.ct_params.two_q_induced_phase_errors.get( (qubits[0], qubits[1]) ) if value is None: # Don't add and error if the two-q interaction is not specified continue Z = value[1] / inst.n_fractions if abs(Z) > EPS: noise_slice.append( NoiseGate( Command(Op.create(OpType.Rz, Z), [value[0]]), "two_q_induced_phase", ) ) def _add_non_markovian(self, noise_slice: Slice) -> None: for q, zx, zz in self.ct_params.non_markovian_noise: two_level_q = self.two_level_map[q] ZX = zx / self.N ZZ = zz / self.N if abs(ZZ) > EPS: noise_slice.append( NoiseGate( Command(Op.create(OpType.ZZPhase, ZZ), [two_level_q, q]), "non_markovian", ) ) if abs(ZX) > EPS: noise_slice.extend( [ NoiseGate( Command(Op.create(OpType.H), [q]), "non_markovian", ), NoiseGate( Command(Op.create(OpType.ZZPhase, ZX), [two_level_q, q]), "non_markovian", ), NoiseGate( Command(Op.create(OpType.H), [q]), "non_markovian", ), ] ) def add_noise(self) -> None: """Add noise gates between slices""" i = 1 base_noise_slice: Slice = [] self._add_zz_crosstalks(base_noise_slice) self._add_single_q_phase(base_noise_slice) self._add_non_markovian(base_noise_slice) while i < len(self._slices): noise_slice: Slice = [] if self.circ.n_qubits > 2: self._add_two_q_induced_phase(self._slices[i - 1], noise_slice) noise_slice.extend(base_noise_slice) self._slices.insert(i, noise_slice) i = i + 2 def build(self) -> None: """Build the noisy circuit as slices""" self.reset() self.unitary_factorisation() self.sort_and_fill_gaps() self.add_noise() def get_circuit(self) -> Circuit: """Convert the slices into a circuit""" d = Circuit() for q in self.circ.qubits: d.add_qubit(q) for q in self.two_level_map.values(): d.add_qubit(q) for b in self.circ.bits: d.add_bit(b) for s in self._slices: for inst in s: if isinstance(inst, Command): d.add_gate(inst.op, inst.args) else: d.add_gate(inst.cmd.op, inst.cmd.args) return d def get_slices(self) -> list[Slice]: """Return the internally stored slices""" return self._slices def get_gate_times_from_backendinfo( backend_info: BackendInfo, ) -> dict[tuple[OpType, tuple[Qubit, ...]], float]: """Convert the gate time information stored in a `BackendInfo` into the format required by `NoisyCircuitBuilder`""" if ( "characterisation" not in backend_info.misc or "GateTimes" not in backend_info.misc["characterisation"] ): raise ValueError("'GateTimes' is not present in the provided 'BackendInfo'") gate_times: dict[tuple[OpType, tuple[Qubit, ...]], float] = {} for gt in backend_info.misc["characterisation"]["GateTimes"]: # GateTimes are nanoseconds gate_times[_gate_str_2_optype[gt[0]], tuple([Node(q) for q in gt[1]])] = ( gt[2] / 1e9 ) return gate_times