Source code for surface_sim.layouts.layout

from __future__ import annotations

from collections.abc import Collection
from copy import deepcopy
from os import path
from pathlib import Path
from typing import Literal, TypedDict, overload

import networkx as nx
import numpy as np
import yaml
from xarray import DataArray

IntDirections = list[str]
IntOrder = IntDirections | dict[str, IntDirections]


class LogQubitsDict(TypedDict):
    ind: int
    log_x: Collection[str]
    log_z: Collection[str]


class QubitDict(TypedDict):
    qubit: str
    ind: int
    neighbors: dict[str, str]


class LayoutDict(TypedDict):
    code: str
    logical_qubits: dict[str, LogQubitsDict]
    observables: dict[str, Collection[str]]
    interaction_order: dict[str, list[str]]
    layout: Collection[QubitDict]


[docs] class Layout: """Layout class for a QEC code. Initialization and storage -------------------------- - ``__init__`` - ``__copy__`` - ``from_dict`` - ``to_dict`` - ``from_yaml`` - ``to_yaml`` Get information from physical qubits ------------------------------------ - ``param`` - ``get_inds`` - ``qubit_inds`` - ``get_max_ind`` - ``get_min_ind`` - ``get_qubits`` - ``get_neighbors`` - ``get_coords`` - ``get_support`` - ``get_label_from_ind`` - ``get_labels_from_inds`` - ``qubits`` - ``data_qubits`` - ``anc_qubits`` - ``num_qubits`` - ``num_data_qubits`` - ``num_anc_qubits`` - ``qubit_coords`` - ``anc_coords`` - ``data_coords`` Get information from logical qubits ----------------------------------- - ``logical_qubits`` - ``logical_param`` - ``get_logical_inds`` - ``logical_qubit_inds`` - ``get_max_logical_ind`` - ``get_min_logical_ind`` - ``get_logical_labels_from_inds`` Get information from observables -------------------------------- - ``observables`` - ``num_observables`` - ``observable_definition`` Set information --------------- - ``set_param`` - ``set_logical_param`` Matrix generation ----------------- - ``adjacency_matrix`` - ``expansion_matrix`` - ``projection_matrix`` Notes ----- The parameters of the layout must be updated or set by the appropiate methods of this class to guarantee a correct behavior. In this sense, the qubits and their connections cannot be updated once the layout object has been created. """ ############################ # initialization and storage
[docs] def __init__(self, setup: LayoutDict) -> None: """Initiailizes the layout for a particular code. Parameters ---------- setup The layout setup, provided as a dict. The setup dictionary is expected to have a 'layout' item, containing a list of dictionaries. Each such dictionary (``dict[str, object]``) must define the qubit label (``str``) corresponding the ``'qubit'`` item. In addition, each dictionary must also have a ``'neighbors'`` item that defines a dictonary (``dict[str, str]``) of ordinal directions and neighbouring qubit labels. Apart from these two items, each dictionary can hold any other metadata or parameter relevant to these qubits. In addition to the layout list, the setup dictionary can also optionally define the name of the layout (``str``), a description (``str``) of the layout as well as the interaction order of the different types of check, if the layout is used for a QEC code. Raises ------ ValueError If the type of the setup provided is not a dictionary. """ if not isinstance(setup, dict): raise ValueError(f"'setup' must be a dict, instead got {type(setup)}.") self.name: str = setup.get("name", "") self.code: str = setup.get("code", "") self._log_qubits: dict[str, LogQubitsDict] = setup.get("logical_qubits", {}) self._observables: dict[str, Collection[str]] = setup.get("observables", {}) self.distance: int = setup.get("distance", -1) self.distance_z: int = setup.get("distance_z", -1) self.distance_x: int = setup.get("distance_x", -1) self.description: str = setup.get("description", "") self.interaction_order: dict[str, list[str]] = setup.get( "interaction_order", {} ) self._load_layout(setup) self._check_logical_qubits() self._check_observables() # precompute specific attributes # make then tuples so that they areunmutable self.data_qubits: tuple[str, ...] = tuple( sorted(self.get_qubits(role="data"), key=label_order) ) # sort ancilla qubits based on stabilizer type because this order # is used (by default) for the detector ordering. Note that it is possible # that the ancilla qubits do not have the parameter 'stab_type'. _x_anc_qubits = set(self.get_qubits(role="anc", stab_type="x_type")) _anc_qubits = set(self.get_qubits(role="anc")) self.anc_qubits: tuple[str, ...] = tuple( sorted(_x_anc_qubits, key=label_order) + sorted(_anc_qubits - _x_anc_qubits, key=label_order) ) self.qubits: tuple[str, ...] = self.data_qubits + self.anc_qubits self.logical_qubits: tuple[str, ...] = tuple(self._log_qubits) self.observables: tuple[str, ...] = tuple(self._observables) self.num_qubits: int = len(self.qubits) self.num_data_qubits: int = len(self.data_qubits) self.num_anc_qubits: int = len(self.anc_qubits) self.num_logical_qubits: int = len(self.logical_qubits) self.num_observables: int = len(self.observables) self._qubit_ind_to_label: dict[int, str] = { v: k for k, v in self.qubit_inds.items() } self._logical_qubit_ind_to_label: dict[int, str] = { v: k for k, v in self.logical_qubit_inds.items() } return
def _check_logical_qubits(self) -> None: """Checks that the ``Layout._log_qubits`` has the correct structure.""" if not isinstance(self._log_qubits, dict): raise TypeError( f"'logical_qubits' must be a dict, but {type(self._log_qubits)} was given." ) if any(not isinstance(l, str) for l in self._log_qubits): raise TypeError( "The keys in 'logical_qubits' must be str, " f"but {list(self._log_qubits)} was given." ) if any(not isinstance(l, dict) for l in self._log_qubits.values()): raise TypeError( "The values in 'logical_qubits' must be dict, " f"but {list(self._log_qubits.values())} was given." ) for key in ["ind", "log_x", "log_z"]: if any(key not in p for p in self._log_qubits.values()): raise ValueError(f"Each logical qubit must have '{key}' specified.") for log_p in ["log_x", "log_z"]: for l, params in self._log_qubits.items(): if not isinstance(params[log_p], Collection): raise TypeError( f"'{log_p}' in logical {l} must be an Collection, " f"but {type(params[log_p])} was given." ) if set(params[log_p]) > set(self._qubit_inds): raise ValueError( f"'{log_p}' in logical {l} has support on qubits not present in this layout." ) return def _check_observables(self) -> None: """Checks that the ``Layout._observables`` has the correct structure.""" if not isinstance(self._observables, dict): raise TypeError( f"'observables' must be a dict, but {type(self._observables)} was given." ) if any(not isinstance(l, str) for l in self._observables): raise TypeError( "The keys in 'observables' must be str, " f"but {list(self._observables)} was given." ) for l, support in self._observables.items(): if not isinstance(support, Collection): raise TypeError( f"Attribute of observable {l} must be a Collection, " f"but {type(support)} was given." ) if set(support) > set(self._qubit_inds): raise ValueError( f"Attribute of observable {l} has support on qubits not present in this layout." ) return def _load_layout(self, setup: LayoutDict) -> None: """Internal function that loads the directed graph from the setup dictionary that is provided during initialization. Parameters ---------- setup The setup dictionary that must specify the 'layout' list of dictionaries, containing the qubit informaiton. Raises ------ ValueError If there are unlabeled qubits in the any of the layout dictionaries. ValueError If any qubit label is repeated in the layout list. """ layout = deepcopy(setup.get("layout")) if layout is None: raise ValueError("'setup' does not contain a 'layout' key.") self._qubit_inds: dict[str, int] = {} self.graph: nx.DiGraph[str] = nx.DiGraph() for qubit_info in layout: qubit = qubit_info.pop("qubit", None) if qubit is None: raise ValueError("Each qubit in the layout must be labeled.") if qubit in self.graph: raise ValueError("Qubit label repeated, ensure labels are unique.") ind = qubit_info.get("ind", None) if ind is None: raise ValueError( "Each qubit in the layout must be indexed (have 'ind' param)." ) self._qubit_inds[qubit] = ind self.graph.add_node(qubit, **qubit_info) for node, attrs in self.graph.nodes(data=True): nbr_dict = attrs.get("neighbors", None) if nbr_dict is None: raise ValueError( "All elements in 'setup' must have the 'neighbors' attribute." ) for edge_dir, nbr_qubit in nbr_dict.items(): if nbr_qubit is not None: self.graph.add_edge(node, nbr_qubit, direction=edge_dir) if all((i is None) for i in self._qubit_inds.values()): qubits = list(self.graph.nodes) self._qubit_inds = dict(zip(qubits, range(len(qubits)))) if any((i is None) for i in self._qubit_inds.values()): raise ValueError("Either all qubits have indicies or none of them.") if len(self._qubit_inds) != len(set(self._qubit_inds.values())): raise ValueError("Qubit index repeated, ensure indices are unique.") return def __copy__(self) -> Layout: """Copies the Layout.""" return Layout(self.to_dict()) @classmethod def from_dict(cls, setup: LayoutDict) -> "Layout": """Loads the layout class from a dictionary. Parameters ---------- setup The layout setup, see ``Layout.__init__``. Returns ------- Layout The initialized layout object. """ return cls(setup) def to_dict(self) -> LayoutDict: """Return a setup dictonary for the layout. Returns ------- setup The dictionary of the setup. A copyt of this ``Layout`` can be initalized using ``Layout(setup)``. """ setup: dict[str, object] = dict() if self.name != "": setup["name"] = self.name if self.code != "": setup["code"] = self.code if self.distance != -1: setup["distance"] = self.distance if self.distance_z != -1: setup["distance_z"] = self.distance_z if self.distance_x != -1: setup["distance_x"] = self.distance_x if self._log_qubits != {}: setup["logical_qubits"] = self._log_qubits if self._observables != {}: setup["observables"] = self._observables if self.description != "": setup["description"] = self.description if self.interaction_order != {}: setup["interaction_order"] = self.interaction_order layout: list[dict[str, object]] = [] for node, attrs in self.graph.nodes(data=True): node_dict = deepcopy(attrs) node_dict["qubit"] = node nbr_dict: dict[str, Collection[str]] = dict() adj_view = self.graph.adj[node] for nbr_node, edge_attrs in adj_view.items(): edge_dir = edge_attrs["direction"] nbr_dict[edge_dir] = nbr_node node_dict["neighbors"] = nbr_dict layout.append(node_dict) setup["layout"] = layout return setup @classmethod def from_yaml(cls, filename: str | Path) -> "Layout": """Loads the layout class from a YAML file. The file must define the setup dictionary that initializes the layout. Parameters ---------- filename The pathfile name of the YAML setup file. Returns ------- Layout The initialized layout object. """ if not path.exists(filename): raise ValueError("Given path doesn't exist") with open(filename, "r") as file: layout_setup: LayoutDict = yaml.safe_load(file) return cls(layout_setup) def to_yaml(self, filename: str | Path) -> None: """Saves the layout as a YAML file. Parameters ---------- filename The pathfile name of the YAML setup file. """ setup = self.to_dict() with open(filename, "w") as file: yaml.dump(setup, file, default_flow_style=False) ###################################### # get information from physical qubits def param(self, param: str, qubit: str) -> object: """Returns the parameter value of a given qubit Parameters ---------- param The label of the qubit parameter. qubit The label of the qubit that is being queried. Returns ------- object The value of the parameter if specified for the given qubit, else ``None``. """ if param not in self.graph.nodes[qubit]: return None else: return self.graph.nodes[qubit][param] def get_inds(self, qubits: Collection[str]) -> tuple[int, ...]: """Returns the indices of the qubits. Parameters ---------- qubits List of qubits. Returns ------- The list of qubit indices. """ return tuple(self._qubit_inds[qubit] for qubit in qubits) @property def qubit_inds(self) -> dict[str, int]: """Returns a dictionary mapping all the qubits to their indices.""" return {k: v for k, v in self._qubit_inds.items()} def get_max_ind(self) -> int: """Returns the largest qubit index in the layout.""" return max(self._qubit_inds.values()) def get_min_ind(self) -> int: """Returns the smallest qubit index in the layout.""" return min(self._qubit_inds.values()) def get_qubits(self, **conds: object) -> tuple[str, ...]: """Return the qubit labels that meet a set of conditions. Parameters ---------- **conds Dictionary of the conditions. Returns ------- nodes The list of qubit labels that meet all conditions. Notes ----- The order that the qubits appear in is defined during the initialization of the layout and remains fixed. The conditions conds are the keyward arguments that specify the value (``object``) that each parameter label (``str``) needs to take. """ if conds: node_view = self.graph.nodes(data=True) nodes = tuple( node for node, attrs in node_view if valid_attrs(attrs, **conds) ) return nodes return tuple(self.graph.nodes) @overload def get_neighbors( self, qubits: Collection[str], *, as_pairs: Literal[False] = False, **conds: object, ) -> tuple[str, ...]: ... @overload def get_neighbors( self, qubits: Collection[str], *, as_pairs: Literal[True], **conds: object, ) -> tuple[tuple[str, str], ...]: ... def get_neighbors( self, qubits: Collection[str], *, as_pairs: bool = False, **conds: object, ) -> tuple[str, ...] | tuple[tuple[str, str], ...]: """Returns the list of qubit labels, neighboring specific qubits that meet a set of conditions. Parameters ---------- qubits The qubit labels, whose neighbors are being considered. **conds Conditions that the neighbors and/or the connections (or edges) need to satisfy. Returns ------- end_notes The list of qubit label, neighboring qubit, that meet the conditions. Notes ----- The order that the qubits appear in is defined during the initialization of the layout and remains fixed. The conditions ``conds`` are the keyward arguments that specify the value (``object``) that each parameter label (``str``) needs to take. """ edge_view = self.graph.out_edges(qubits, data=True) start_nodes: list[str] = [] end_nodes: list[str] = [] for start_node, end_node, attrs in edge_view: if not conds: start_nodes.append(start_node) end_nodes.append(end_node) continue # conditions can be for the edge and/or the end_node. if valid_attrs(attrs, **conds) or valid_attrs( self.graph.nodes[end_node], **conds ): start_nodes.append(start_node) end_nodes.append(end_node) if as_pairs: return tuple(zip(start_nodes, end_nodes)) return tuple(end_nodes) def get_coords(self, qubits: Collection[str]) -> tuple[tuple[float | int], ...]: """Returns the coordinates of the given qubits. Parameters ---------- qubits List of qubits. Returns ------- Coordinates of the given qubits. """ all_coords = nx.get_node_attributes(self.graph, "coords", default=tuple()) if set(qubits) > set(all_coords): raise ValueError("Some of the given qubits do not have coordinates.") return tuple(tuple(all_coords[q]) for q in qubits) def get_support(self, qubits: Collection[str]) -> dict[str, tuple[str, ...]]: """Returns a dictionary mapping the qubits to their support.""" return {q: self.get_neighbors([q]) for q in qubits} def get_label_from_ind(self, ind: int) -> str: """Returns the qubit label for the given qubit index.""" return self._qubit_ind_to_label[ind] def get_labels_from_inds(self, inds: Collection[int]) -> tuple[str, ...]: """Returns list of qubit labels for the given qubit indicies.""" return tuple(self._qubit_ind_to_label[ind] for ind in inds) @property def qubit_coords(self) -> dict[str, tuple[float | int, ...]]: """Returns a dictionary mapping all the qubits to their coordinates.""" return {q: c for q, c in zip(self.qubits, self.get_coords(self.qubits))} @property def anc_coords(self) -> dict[str, tuple[float | int, ...]]: """Returns a dictionary mapping all ancilla qubits to their coordinates.""" return {q: c for q, c in zip(self.anc_qubits, self.get_coords(self.anc_qubits))} @property def data_coords(self) -> dict[str, tuple[float | int, ...]]: """Returns a dictionary mapping all data qubits to their coordinates.""" return { q: c for q, c in zip(self.data_qubits, self.get_coords(self.data_qubits)) } ##################################### # get information from logical qubits def logical_param(self, param: str, logical_qubit: str | None = None) -> object: """Returns the parameter value of a given logical qubit. Parameters ---------- param The label of the logical qubit parameter. logical_qubit The label of the logical qubit that is being queried. If the layout has only a single logical qubit, it does not have to be specified. Returns ------- object The value of the parameter if specified for the given logical qubit, else ``None``. """ if logical_qubit is None: if self.num_logical_qubits != 1: raise ValueError("Missing 'logical_qubit' argument.") logical_qubit = self.logical_qubits[0] params = self._log_qubits.get(logical_qubit) if params is None: return None return params.get(param) def get_logical_inds(self, logical_qubits: Collection[str]) -> tuple[int, ...]: """Returns the indices of the specified logical qubits.""" if set(logical_qubits) > set(self._log_qubits): raise ValueError( f"At least one of the given logical qubits ({logical_qubits}) are not present in this layout." ) return tuple(self._log_qubits[l]["ind"] for l in logical_qubits) @property def logical_qubit_inds(self) -> dict[str, int]: """Returns a dictionary mapping all the logical qubits to their indices.""" return {k: self._log_qubits[k]["ind"] for k in self._log_qubits} def get_max_logical_ind(self) -> int: """Returns the largest logical qubit index in the layout.""" return max(self.logical_qubit_inds.values()) def get_min_logical_ind(self) -> int: """Returns the largest logical qubit index in the layout.""" return min(self.logical_qubit_inds.values()) def get_logical_labels_from_inds(self, inds: Collection[int]) -> tuple[str, ...]: """Returns list of logical qubit labels for the given logical qubit indicies.""" return tuple(self._logical_qubit_ind_to_label[ind] for ind in inds) ##################################### # get information from observables def observable_definition(self, observable: str) -> tuple[str, ...]: """Returns the definition of the specified observable.""" if observable not in self.observables: raise ValueError(f"'{observable}' is not an observable from this layout.") return tuple(self._observables[observable]) ################# # set information def set_param(self, param: str, qubit: str, value: object) -> None: """Sets the value of a given qubit parameter Parameters ---------- param The label of the qubit parameter. qubit The label of the qubit that is being queried. value The new value of the qubit parameter. """ self.graph.nodes[qubit][param] = value return def set_logical_param(self, param: str, logical_qubit: str, value: object) -> None: """Sets the valur of a given logical parameter. Parameters ---------- param The label of the logical qubit parameter. logical_qubit The label of the logical qubit that is being queried. value The new value of the logical qubit parameter. """ if logical_qubit not in self._log_qubits: raise ValueError( f"Logical qubit {logical_qubit} is not present in this layout." ) self._log_qubits[logical_qubit][param] = value return ################### # matrix generation def adjacency_matrix(self) -> DataArray: """Returns the adjaceny matrix corresponding to the layout. The layout is encoded as a directed graph, such that there are two edges in opposite directions between each pair of neighboring qubits. Returns ------- ajd_matrix The adjacency matrix. """ qubits = list(self.qubits) adj_matrix = nx.adjacency_matrix(self.graph) data_arr = DataArray( data=adj_matrix.toarray(), dims=["from_qubit", "to_qubit"], coords=dict( from_qubit=qubits, to_qubit=qubits, ), ) return data_arr def expansion_matrix(self) -> DataArray: """Returns the expansion matrix corresponding to the layout. The matrix can expand a vector of measurements/defects to a 2D array corresponding to layout of the ancilla qubits. Used for convolutional neural networks. Returns ------- DataArray The expansion matrix. """ node_view = self.graph.nodes(data=True) coords: list[tuple[float | int, ...]] = [ node_view[anc]["coords"] for anc in self.anc_qubits ] rows, cols = zip(*coords) row_inds, num_rows = index_coords(rows, reverse=True) col_inds, num_cols = index_coords(cols) anc_inds = range(self.num_anc_qubits) tensor = np.zeros((self.num_anc_qubits, num_rows, num_cols), dtype=bool) tensor[anc_inds, row_inds, col_inds] = True expanded_tensor = np.expand_dims(tensor, axis=1) expansion_tensor = DataArray( expanded_tensor, dims=["anc_qubit", "channel", "row", "col"], coords=dict( anc_qubit=list(self.anc_qubits), ), ) return expansion_tensor def projection_matrix(self, stab_type: str) -> DataArray: """Returns the projection matrix, mapping data qubits (defined by a parameter ``'role'`` equal to ``'data'``) to ancilla qubits (defined by a parameter ``'role'`` equal to ``'anc'``) measuing a given stabilizerr type (defined by a parameter ``'stab_type'`` equal to stab_type). This matrix can be used to project a final set of data-qubit measurements to a set of syndromes. Parameters ---------- stab_type The type of the stabilizers that the data qubit measurement is being projected to. Returns ------- DataArray The projection matrix. """ adj_mat = self.adjacency_matrix() anc_qubits = list(self.get_qubits(role="anc", stab_type=stab_type)) proj_mat = adj_mat.sel(from_qubit=list(self.data_qubits), to_qubit=anc_qubits) return proj_mat.rename(from_qubit="data_qubit", to_qubit="anc_qubit")
def valid_attrs(attrs: dict[str, object], **conditions: object) -> bool: """Checks if the items in attrs match each condition in conditions. Both attrs and conditions are dictionaries mapping parameter labels (str) to values (object). Parameters ---------- attrs The attribute dictionary. Returns ------- bool Whether the attributes meet a set of conditions. """ for key, val in conditions.items(): attr_val = attrs.get(key) if attr_val is None or attr_val != val: return False return True def index_coords( coords: tuple[int, ...], reverse: bool = False ) -> tuple[tuple[int, ...], int]: """Indexes a list of coordinates. Parameters ---------- coords The list of coordinates. reverse Whether to return the values in reverse, by default False Returns ------- indices The list of indexed coordinates. num_unique_vals The number of unique coordinates. """ unique_vals = set(coords) num_unique_vals = len(unique_vals) if reverse: unique_inds = reversed(range(num_unique_vals)) else: unique_inds = range(num_unique_vals) mapping = dict(zip(unique_vals, unique_inds)) indicies = tuple(mapping[coord] for coord in coords) return indicies, num_unique_vals def label_order(label: str) -> int | str: """Function for sorting the qubit labels to avoid e.g. ``"D12"`` appearing before ``"D2"``.""" if label[1:].isdigit(): return int(label[1:]) return label