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) -> 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.
Returns
-------
object
The value of the parameter if specified for the given logical qubit,
else ``None``.
"""
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