diff --git a/minorminer/utils/zephyr/__init__.py b/minorminer/utils/zephyr/__init__.py new file mode 100644 index 00000000..0ce76ef7 --- /dev/null +++ b/minorminer/utils/zephyr/__init__.py @@ -0,0 +1,20 @@ +# Copyright 2025 D-Wave +# +# 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 minorminer.utils.zephyr.zephyr import * +from minorminer.utils.zephyr.coordinate_systems import * +from minorminer.utils.zephyr.node_edge import * +from minorminer.utils.zephyr.plane_shift import * diff --git a/minorminer/utils/zephyr/coordinate_systems.py b/minorminer/utils/zephyr/coordinate_systems.py new file mode 100644 index 00000000..2d1e0068 --- /dev/null +++ b/minorminer/utils/zephyr/coordinate_systems.py @@ -0,0 +1,78 @@ +# Copyright 2025 D-Wave +# +# 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 __future__ import annotations + +from collections import namedtuple + +__all__ = ["cartesian_to_zephyr", "zephyr_to_cartesian", "CartesianCoord", "ZephyrCoord"] + + +zephyr_fields = ["u", "w", "k", "j", "z"] +ZephyrCoord = namedtuple("ZephyrCoord", zephyr_fields, defaults=(None,) * len(zephyr_fields)) +cartesian_fields = ["x", "y", "k"] +CartesianCoord = namedtuple( + "CartesianCoord", cartesian_fields, defaults=(None,) * len(cartesian_fields) +) + + +def cartesian_to_zephyr(ccoord: CartesianCoord) -> ZephyrCoord: + """Converts a :class:`CartesianCoord` to its corresponding :class:`ZephyrCoord`. + + ..note:: + It assumes the given :class:`CartesianCoord` is valid. + + Args: + ccoord (CartesianCoord): The coodinate in Cartesian system to be converted. + + Returns: + ZephyrCoord: The coordinate of the ``ccoord`` in Zephyr system. + """ + x, y, k = ccoord + if x % 2 == 0: + u: int = 0 + w: int = x // 2 + j: int = ((y - 1) % 4) // 2 + z: int = y // 4 + else: + u: int = 1 + w: int = y // 2 + j: int = ((x - 1) % 4) // 2 + z: int = x // 4 + return ZephyrCoord(u=u, w=w, k=k, j=j, z=z) + + +def zephyr_to_cartesian(zcoord: ZephyrCoord) -> CartesianCoord: + """Converts a :class:`ZephyrCoord` to its corresponding :class:`CartesianCoord`. + + ..note:: + It assumes the given ``zcoord`` is a valid Zephyr coordinate. + + Args: + zcoord (ZephyrCoord): The coodinate in Zephyr system to be converted. + + Returns: + CartesianCoord: The coordinate of the ``ccoord`` in Cartesian system. + """ + u, w, k, j, z = zcoord + if u == 0: + x = 2 * w + y = 4 * z + 2 * j + 1 + else: + x = 4 * z + 2 * j + 1 + y = 2 * w + return CartesianCoord(x=x, y=y, k=k) diff --git a/minorminer/utils/zephyr/node_edge.py b/minorminer/utils/zephyr/node_edge.py new file mode 100644 index 00000000..3426b874 --- /dev/null +++ b/minorminer/utils/zephyr/node_edge.py @@ -0,0 +1,823 @@ +# Copyright 2025 D-Wave +# +# 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 __future__ import annotations + +from collections import namedtuple +from enum import Enum +from itertools import product +from typing import Callable, Generator, Iterable, Any + +from minorminer.utils.zephyr.coordinate_systems import (CartesianCoord, ZephyrCoord, + cartesian_to_zephyr, zephyr_to_cartesian) +from minorminer.utils.zephyr.plane_shift import ZPlaneShift + + +__all__ = ["ZShape", "EdgeKind", "NodeKind", "Edge", "ZEdge", "ZNode"] + + +ZShape = namedtuple("ZShape", ["m", "t"], defaults=(None, None)) + + + +class EdgeKind(Enum): + INTERNAL = 1 + EXTERNAL = 2 + ODD = 3 + + +class NodeKind(Enum): + VERTICAL = 0 + HORIZONTAL = 1 + + +class Edge: + """Represents an edge of a graph in a canonical order. + + Args: + x (Any): One endpoint of edge. + y (Any): Another endpoint of edge. + + ..note:: ``x`` and ``y`` must be mutually comparable. + """ + + def __init__(self, x: Any, y: Any) -> None: + self._edge = self._set_edge(x, y) + + def _set_edge(self, x: Any, y: Any): + """Returns ordered tuple corresponding to the set {x, y}.""" + if x < y: + return (x, y) + else: + return (y, x) + + def __hash__(self) -> int: + return hash(self._edge) + + def __getitem__(self, index: int) -> int: + return self._edge[index] + + def __eq__(self, other: Edge) -> bool: + return self._edge == other._edge + + def __str__(self) -> str: + return f"{type(self).__name__}{self._edge}" + + def __repr__(self) -> str: + return f"{type(self).__name__}{self._edge}" + + +class ZEdge(Edge): + """Represents an edge in a graph with Zephyr topology. + + Args: + x (ZNode): Endpoint of edge. Must have same shape as ``y``. + y (ZNode): Endpoint of edge. Must have same shape as ``x`` + check_edge_valid (bool, optional): Flag to whether check the validity of values and types of ``x``, ``y``. + Defaults to True. + + Raises: + TypeError: If either of x or y is not :class:`ZNode`. + ValueError: If x, y do not have the same shape. + ValueError: If x, y are not neighbors in a perfect yield (quotient) + Zephyr graph. + + Example 1: + >>> from minorminer.utils.zephyr.node_edge import ZNode, ZEdge + >>> e = ZEdge(ZNode((3, 2)), ZNode((7, 2))) + >>> print(e) + ZEdge(ZNode(CartesianCoord(x=3, y=2, k=None)), ZNode(CartesianCoord(x=7, y=2, k=None))) + Example 2: + >>> from minorminer.utils.zephyr.node_edge import ZNode, ZEdge + >>> ZEdge(ZNode((2, 3)), ZNode((6, 3))) # raises error, since the two are not neighbors + """ + + def __init__( + self, + x: ZNode, + y: ZNode, + check_edge_valid: bool = True, + ) -> None: + if check_edge_valid: + if not isinstance(x, ZNode) or not isinstance(y, ZNode): + raise TypeError(f"Expected x, y to be ZNode, got {type(x), type(y)}") + + if x.shape != y.shape: + raise ValueError(f"Expected x, y to have the same shape, got {x.shape, y.shape}") + + for kind in EdgeKind: + if x.is_neighbor(y, nbr_kind=kind): + self._edge_kind = kind + break + else: + raise ValueError(f"Expected x, y to be neighbors, got {x, y}") + + self._edge = self._set_edge(x, y) + + @property + def edge_kind(self) -> EdgeKind: + if not hasattr(self, "_edge_kind"): + x, y = self._edge + for kind in EdgeKind: + if x.is_neighbor(y, nbr_kind=kind): + self._edge_kind = kind + break + try: + return self._edge_kind + except AttributeError: + raise ValueError(f"{self._edge} is not an edge in Zephyr topology") + + + +class ZNode: + """Represents a node of a graph with Zephyr topology with coordinate and optional shape. + + Args: + coord (CartesianCoord | ZephyrCoord | tuple[int]): Coordinate in (quotient) Zephyr or (quotient) Cartesian. + shape (ZShape | tuple[int | None] | None, optional): shape of Zephyr graph containing ZNode. + m: grid size, t: tile size + Defaults to None. + convert_to_z (bool | None, optional): Whether to express the coordinates in ZephyrCoordinates. + Defaults to None. + check_node_valid (bool, optional): Flag to whether check the validity of values and types of ``coord``, ``shape``. + Defaults to True. + + ..note:: + + If the given coord has non-None k value (in either Cartesian or Zephyr coordinates), + ``shape = None`` raises ValueError. In this case the tile size of Zephyr, t, + must be provided. + + Example: + >>> from minorminer.utils.zephyr.node_edge import ZNode, ZShape + >>> zn1 = ZNode((5, 2), ZShape(m=5)) + >>> zn1.neighbors() + [ZNode(CartesianCoord(x=4, y=1, k=None), shape=ZShape(m=5, t=None)), + ZNode(CartesianCoord(x=4, y=3, k=None), shape=ZShape(m=5, t=None)), + ZNode(CartesianCoord(x=6, y=1, k=None), shape=ZShape(m=5, t=None)), + ZNode(CartesianCoord(x=6, y=3, k=None), shape=ZShape(m=5, t=None)), + ZNode(CartesianCoord(x=1, y=2, k=None), shape=ZShape(m=5, t=None)), + ZNode(CartesianCoord(x=9, y=2, k=None), shape=ZShape(m=5, t=None)), + ZNode(CartesianCoord(x=3, y=2, k=None), shape=ZShape(m=5, t=None)), + ZNode(CartesianCoord(x=7, y=2, k=None), shape=ZShape(m=5, t=None))] + >>> from minorminer.utils.zephyr.node_edge import ZNode, ZShape + >>> zn1 = ZNode((5, 2), ZShape(m=5)) + >>> zn1.neighbors(nbr_kind=EdgeKind.ODD) + [ZNode(CartesianCoord(x=3, y=2, k=None), shape=ZShape(m=5, t=None)), + ZNode(CartesianCoord(x=7, y=2, k=None), shape=ZShape(m=5, t=None))] + """ + def __init__( + self, + coord: CartesianCoord | ZephyrCoord | tuple[int], + shape: ZShape | None = None, + convert_to_z: bool | None = None, + check_node_valid: bool = True, + ) -> None: + if shape: + self._shape = self._set_shape(shape) + else: + self._shape = ZShape() + + if convert_to_z is None: + self.convert_to_z = len(coord) in (4, 5) + else: + self.convert_to_z = convert_to_z + + # convert coord to CartesianCoord or ZephyrCoord + if not isinstance(coord, (CartesianCoord, ZephyrCoord)): + coord = self.tuple_to_coord(coord) + + # convert coord to CartesianCoord + if isinstance(coord, ZephyrCoord): + coord = zephyr_to_cartesian(coord) + + self._ccoord = self._set_ccoord(coord=coord, check_node_valid=check_node_valid) + + @property + def shape(self) -> ZShape: + """Returns the :class:`ZShape` of the Zephyr graph the node belongs to.""" + return self._shape + + def _set_shape(self, shape: ZShape | tuple[int | None]) -> ZShape: + """Returns the :class:`Zshape` corresponding to ``shape``. + + Args: + shape (ZShape | tuple[int | None]): The shape to set. + """ + if isinstance(shape, tuple): + shape = ZShape(*shape) + + for var, val in shape._asdict().items(): + if (val is not None) and (not isinstance(val, int)): + raise TypeError(f"Expected {var} to be None or 'int', got {type(val)}") + return shape + + @property + def ccoord(self) -> CartesianCoord: + """Returns the :class:`CartesianCoord` of ``self``.""" + return self._ccoord + + def _check_ccoord_val(self, coord: CartesianCoord): + for c in coord: + if c is not None and c < 0: + raise ValueError(f"Expected ccoord elements to be None or non-negative, got {c}") + + if coord.x % 2 == coord.y % 2: + raise ValueError( + f"Expected ccoord.x and ccoord.y to differ in parity, got {coord.x, coord.y}" + ) + + def _check_ccoord_shape(self, coord: CartesianCoord) -> None: + """Check value of ``coord`` is consistent with ``t``.""" + + # k value of coord is consistent with t + if (self._shape.t is None) != (coord.k is None): + raise ValueError( + f"shape and ccoord must be both quotient or non-quotient, got {self._shape}, {coord}" + ) + if (self._shape.t is not None) and (coord.k not in range(self._shape.t)): + raise ValueError(f"Expected k to be in {range(self._shape.t)}, got {coord.k}") + + # check x, y value of coord is consistent with m + if self._shape.m is not None: + if not all(val in range(4 * self._shape.m + 1) for val in (coord.x, coord.y)): + raise ValueError( + f"Expected ccoord.x and ccoord.y to be in {range(4*self._shape.m+1)}, got {coord.x, coord.y}" + ) + + def _set_ccoord(self, coord: CartesianCoord | tuple[int], check_node_valid: bool) -> CartesianCoord: + """Returns the :class:`CartesianCoord` corresponding to ``coord``. + + Args: + coord (CartesianCoord or tuple[int]): The Cartesian coordinate to set. + """ + if isinstance(coord, tuple): + coord = CartesianCoord(*coord) + + for c in coord: + if (c is not None) and (not isinstance(c, int)): + raise TypeError(f"Expected elements of ccoord to be 'int' or None, got {type(c)}") + + if check_node_valid: + self._check_ccoord_val(coord) + self._check_ccoord_shape(coord) + + return coord + + @staticmethod + def tuple_to_coord(coord: tuple[int]) -> CartesianCoord | ZephyrCoord: + """Takes a tuple[int] and returns the corresponding ``CartesianCoord`` or ``ZephyrCoord``""" + if (not isinstance(coord, tuple)) or (not all(isinstance(c, int) for c in coord)): + raise TypeError(f"Expected {coord} to be a tuple[int], got {coord}") + + if any(c < 0 for c in coord): + raise ValueError(f"Expected elements of coord to be non-negative, got {coord}") + + len_coord = len(coord) + if len_coord in (2, 3): + x, y, *k = coord + if x % 2 == y % 2: + raise ValueError(f"Expected x, y to differ in parity, got {x, y}") + + return CartesianCoord(x=x, y=y) if len(k) == 0 else CartesianCoord(x=x, y=y, k=k[0]) + + if len_coord in (4, 5): + u, w, *k, j, z = coord + for var, val in [("u", u), ("j", j)]: + if not val in [0, 1]: + raise ValueError(f"Expected {var} to be in [0, 1], got {val}") + + if len(k) == 0: + return ZephyrCoord(u=u, w=w, j=j, z=z) + + return ZephyrCoord(u=u, w=w, k=k[0], j=j, z=z) + + raise ValueError(f"coord can have length 2, 3, 4 or 5, got {len_coord}") + + @property + def zcoord(self) -> ZephyrCoord: + """Returns :class:`ZephyrCoord` corresponding to ccoord.""" + return cartesian_to_zephyr(self._ccoord) + + @property + def node_kind(self) -> NodeKind: + """Returns the :class:`NodeKind` of ``self``.""" + if self._ccoord.x % 2 == 0: + return NodeKind.VERTICAL + return NodeKind.HORIZONTAL + + @property + def direction(self) -> int: + """Returns the direction of node, i.e. its ``u`` coordinate in Zephyr coordinates.""" + return self.node_kind.value + + def is_quo(self) -> bool: + """Decides if the :class:`ZNode` object is quotient.""" + return (self._ccoord.k is None) and (self._shape.t is None) + + def to_quo(self) -> ZNode: + """Returns the quotient :class:`ZNode` corresponding to self""" + qshape = ZShape(m=self._shape.m) + qccoord = CartesianCoord(x=self._ccoord.x, y=self._ccoord.y) + return ZNode(coord=qccoord, shape=qshape, convert_to_z=self.convert_to_z) + + def is_vertical(self) -> bool: + """Returns True if the node represents a vertical qubit (i.e., its ``u`` coordinate in Zephyr coordinates is 0).""" + return self.node_kind is NodeKind.VERTICAL + + def is_horizontal(self) -> bool: + """Returns True if the node represents a horizontal qubit (i.e., its ``u`` coordinate in Zephyr coordinates is 1).""" + return self.node_kind is NodeKind.HORIZONTAL + + def neighbor_kind( + self, + other: ZNode, + ) -> EdgeKind | None: + """Returns the kind of edge between two ZNodes. + + :class:`EdgeKind` if there is an edge in perfect Zephyr between them or ``None``. + + Args: + other (ZNode): The neigboring :class:`ZNode`. + + Returns: + EdgeKind | None: The edge kind between self and other in perfect Zephyr or None if there is no edge between in perfect Zephyr. + """ + if not isinstance(other, ZNode): + other = ZNode(other) + + if self._shape != other._shape: + return None + + coord1 = self._ccoord + coord2 = other._ccoord + x1, y1 = coord1.x, coord1.y + x2, y2 = coord2.x, coord2.y + if abs(x1 - x2) == abs(y1 - y2) == 1: + return EdgeKind.INTERNAL + + if x1 % 2 != x2 % 2: + return None + + if coord1.k != coord2.k: # odd, external neighbors only on the same k + return None + + if self.node_kind is NodeKind.VERTICAL: # self vertical + if x1 != x2: # odd, external neighbors only on the same vertical lines + return None + + diff_y = abs(y1 - y2) + return EdgeKind.ODD if diff_y == 2 else EdgeKind.EXTERNAL if diff_y == 4 else None + + # else, self is horizontal + if y1 != y2: # odd, external neighbors only on the same horizontal lines + return None + + diff_x = abs(x1 - x2) + return EdgeKind.ODD if diff_x == 2 else EdgeKind.EXTERNAL if diff_x == 4 else None + + def internal_neighbors_generator( + self, + where: Callable[[CartesianCoord | ZephyrCoord], bool] = lambda coord: True, + ) -> Generator[ZNode]: + """Generator of internal neighbors of self when restricted by ``where``. + Args: + where (Callable[[CartesianCoord | ZephyrCoord], bool]): + A coordinate filter. Applies to ``ccoord`` if :py:attr:`self.convert_to_z` is ``False``, + or to ``zcoord`` if :py:attr:`self.convert_to_z` is ``True``. Defaults to always. + Yields: + ZNode: Internal neighbors of self when restricted by ``where``. + """ + x, y, _ = self._ccoord + convert = self.convert_to_z + k_vals = [None] if self._shape.t is None else range(self._shape.t) + for i, j, k in product((-1, 1), (-1, 1), k_vals): + ccoord = CartesianCoord(x=x + i, y=y + j, k=k) + + # Check ccoord is valid. If not valid, ignore this ccoord + try: + self._check_ccoord_val(ccoord) + self._check_ccoord_shape(ccoord) + except ValueError: + continue + + coord = ccoord if not convert else cartesian_to_zephyr(ccoord) + if not where(coord): + continue + + yield ZNode( + coord=ccoord, + shape=self._shape, + convert_to_z=convert, + ) + + def external_neighbors_generator( + self, + where: Callable[[CartesianCoord | ZephyrCoord], bool] = lambda coord: True, + ) -> Generator[ZNode]: + """Generator of external neighbors of self when restricted by ``where``. + Args: + where (Callable[[CartesianCoord | ZephyrCoord], bool]): + A coordinate filter. Applies to ``ccoord`` if :py:attr:`self.convert_to_z` is ``False``, + or to ``zcoord`` if :py:attr:`self.convert_to_z` is ``True``. Defaults to always. + Yields: + ZNode: External neighbors of self when restricted by ``where``. + """ + x, y, k = self._ccoord + convert = self.convert_to_z + changing_index = 1 if x % 2 == 0 else 0 + for s in [-4, 4]: + new_x = x + s if changing_index == 0 else x + new_y = y + s if changing_index == 1 else y + ccoord = CartesianCoord(x=new_x, y=new_y, k=k) + + # Check ccoord is valid. If not valid, ignore this ccoord + try: + self._check_ccoord_val(ccoord) + self._check_ccoord_shape(ccoord) + except ValueError: + continue + + coord = ccoord if not convert else cartesian_to_zephyr(ccoord) + if not where(coord): + continue + + yield ZNode( + coord=ccoord, + shape=self._shape, + convert_to_z=convert, + ) + + def odd_neighbors_generator( + self, + where: Callable[[CartesianCoord | ZephyrCoord], bool] = lambda coord: True, + ) -> Generator[ZNode]: + """Generator of odd neighbors of self when restricted by ``where``. + Args: + where (Callable[[CartesianCoord | ZephyrCoord], bool]): + A coordinate filter. Applies to ``ccoord`` if :py:attr:`self.convert_to_z` is ``False``, + or to ``zcoord`` if :py:attr:`self.convert_to_z` is ``True``. Defaults to always. + Yields: + ZNode: Odd neighbors of self when restricted by ``where``. + """ + x, y, k = self._ccoord + convert = self.convert_to_z + changing_index = 1 if x % 2 == 0 else 0 + for s in [-2, 2]: + new_x = x + s if changing_index == 0 else x + new_y = y + s if changing_index == 1 else y + ccoord = CartesianCoord(x=new_x, y=new_y, k=k) + + # Check ccoord is valid. If not valid, ignore this ccoord + try: + self._check_ccoord_val(ccoord) + self._check_ccoord_shape(ccoord) + except ValueError: + continue + + coord = ccoord if not convert else cartesian_to_zephyr(ccoord) + if not where(coord): + continue + + yield ZNode( + coord=ccoord, + shape=self._shape, + convert_to_z=convert, + ) + + def neighbors_generator( + self, + nbr_kind: EdgeKind | Iterable[EdgeKind] | None = None, + where: Callable[[CartesianCoord | ZephyrCoord], bool] = lambda coord: True, + ) -> Generator[ZNode]: + """Generator of neighbors of self when restricted by ``nbr_kind`` and ``where``. + + Args: + nbr_kind (EdgeKind | Iterable[EdgeKind] | None, optional): + Edge kind filter. Restricts yielded neighbors to those connected by the given edge kind(s). + If ``None``, no filtering is applied. Defaults to None. + where (Callable[[CartesianCoord | ZephyrCoord], bool]): + A coordinate filter. Applies to ``ccoord`` if :py:attr:`self.convert_to_z` is ``False``, + or to ``zcoord`` if :py:attr:`self.convert_to_z` is ``True``. Defaults to always. + + Yields: + ZNode: Neighbors of self when restricted by ``nbr_kind`` and ``where``. + """ + if nbr_kind is None: + kinds = {kind for kind in EdgeKind} + elif isinstance(nbr_kind, EdgeKind): + kinds = {nbr_kind} + else: + kinds = set(nbr_kind) + if EdgeKind.INTERNAL in kinds: + for cc in self.internal_neighbors_generator(where=where): + yield cc + if EdgeKind.EXTERNAL in kinds: + for cc in self.external_neighbors_generator(where=where): + yield cc + if EdgeKind.ODD in kinds: + for cc in self.odd_neighbors_generator(where=where): + yield cc + + def neighbors( + self, + nbr_kind: EdgeKind | Iterable[EdgeKind] | None = None, + where: Callable[[CartesianCoord | ZephyrCoord], bool] = lambda coord: True, + ) -> set[ZNode]: + """Returns set of neighbors of self when restricted by ``nbr_kind`` and ``where``. + + Args: + nbr_kind (EdgeKind | Iterable[EdgeKind] | None, optional): + Edge kind filter. Restricts returned neighbors to those connected by the given edge kind(s). + If ``None``, no filtering is applied. Defaults to None. + where (Callable[[CartesianCoord | ZephyrCoord], bool]): + A coordinate filter. Applies to ``ccoord`` if :py:attr:`self.convert_to_z` is ``False``, + or to ``zcoord`` if :py:attr:`self.convert_to_z` is ``True``. Defaults to always. + Returns: + set[ZNode]: Set of neighbors of self when restricted by ``nbr_kind`` and ``where``. + """ + return set(self.neighbors_generator(nbr_kind=nbr_kind, where=where)) + + def internal_neighbors( + self, + where: Callable[[CartesianCoord | ZephyrCoord], bool] = lambda coord: True, + ) -> set[ZNode]: + """Returns the set of internal neighbors of self when restricted by ``where``. + + Args: + where (Callable[[CartesianCoord | ZephyrCoord], bool]): + A coordinate filter. Applies to ``ccoord`` if :py:attr:`self.convert_to_z` is ``False``, + or to ``zcoord`` if :py:attr:`self.convert_to_z` is ``True``. Defaults to always. + Returns: + set[ZNode]: Set of internal neighbors of self when restricted by ``where``. + """ + return set(self.neighbors_generator(nbr_kind=EdgeKind.INTERNAL, where=where)) + + def external_neighbors( + self, + where: Callable[[CartesianCoord | ZephyrCoord], bool] = lambda coord: True, + ) -> set[ZNode]: + """Returns the set of external neighbors of self when restricted by ``where``. + + Args: + where (Callable[[CartesianCoord | ZephyrCoord], bool]): + A coordinate filter. Applies to ``ccoord`` if :py:attr:`self.convert_to_z` is ``False``, + or to ``zcoord`` if :py:attr:`self.convert_to_z` is ``True``. Defaults to always. + Returns: + set[ZNode]: Set of external neighbors of self when restricted by ``where``. + """ + return set(self.neighbors_generator(nbr_kind=EdgeKind.EXTERNAL, where=where)) + + def odd_neighbors( + self, + where: Callable[[CartesianCoord | ZephyrCoord], bool] = lambda coord: True, + ) -> set[ZNode]: + """Returns the set of odd neighbors of self when restricted by ``where``. + + Args: + where (Callable[[CartesianCoord | ZephyrCoord], bool]): + A coordinate filter. Applies to ``ccoord`` if :py:attr:`self.convert_to_z` is ``False``, + or to ``zcoord`` if :py:attr:`self.convert_to_z` is ``True``. Defaults to always. + Returns: + set[ZNode]: Set of odd neighbors of self when restricted by ``where``. + """ + return set(self.neighbors_generator(nbr_kind=EdgeKind.ODD, where=where)) + + def is_internal_neighbor( + self, + other: ZNode, + where: Callable[[CartesianCoord | ZephyrCoord], bool] = lambda coord: True, + ) -> bool: + """Returns whether other is an internal neighbor of self when restricted by ``where``. + + Args: + other (ZNode): Another instance to compare with self. + where (Callable[[CartesianCoord | ZephyrCoord], bool]): + A coordinate filter. Applies to ``ccoord`` if :py:attr:`self.convert_to_z` is ``False``, + or to ``zcoord`` if :py:attr:`self.convert_to_z` is ``True``. Defaults to always. + + Returns: + bool: Whether other is an internal neighbor of self when restricted by ``where``. + """ + for nbr in self.internal_neighbors_generator(where=where): + if other == nbr: + return True + return False + + def is_external_neighbor( + self, + other: ZNode, + where: Callable[[CartesianCoord | ZephyrCoord], bool] = lambda coord: True, + ) -> bool: + """Returns whether other is an external neighbor of self when restricted by ``where``. + + Args: + other (ZNode): Another instance to compare with self. + where (Callable[[CartesianCoord | ZephyrCoord], bool]): + A coordinate filter. Applies to ``ccoord`` if :py:attr:`self.convert_to_z` is ``False``, + or to ``zcoord`` if :py:attr:`self.convert_to_z` is ``True``. Defaults to always. + + Returns: + bool: Whether other is an external neighbor of self when restricted by ``where``. + """ + for nbr in self.external_neighbors_generator(where=where): + if other == nbr: + return True + return False + + def is_odd_neighbor( + self, + other: ZNode, + where: Callable[[CartesianCoord | ZephyrCoord], bool] = lambda coord: True, + ) -> bool: + """Returns whether other is an odd neighbor of self when restricted by ``where``. + + Args: + other (ZNode): Another instance to compare with self. + where (Callable[[CartesianCoord | ZephyrCoord], bool]): + A coordinate filter. Applies to ``ccoord`` if :py:attr:`self.convert_to_z` is ``False``, + or to ``zcoord`` if :py:attr:`self.convert_to_z` is ``True``. Defaults to always. + + Returns: + bool: Whether other is an odd neighbor of self when restricted by ``where``. + """ + for nbr in self.odd_neighbors_generator(where=where): + if other == nbr: + return True + return False + + def is_neighbor( + self, + other: ZNode, + nbr_kind: EdgeKind | Iterable[EdgeKind] | None = None, + where: Callable[[CartesianCoord | ZephyrCoord], bool] = lambda coord: True, + ) -> bool: + """Returns whether other is a neighbor of self when restricted by ``nbr_kind`` and ``where``. + + Args: + other (ZNode): Another instance to compare with self. + nbr_kind (EdgeKind | Iterable[EdgeKind] | None, optional): + Edge kind filter. Restricts neighbors to those connected by the given edge kind(s). + If ``None``, no filtering is applied. Defaults to None. + where (Callable[[CartesianCoord | ZephyrCoord], bool]): + A coordinate filter. Applies to ``ccoord`` if :py:attr:`self.convert_to_z` is ``False``, + or to ``zcoord`` if :py:attr:`self.convert_to_z` is ``True``. Defaults to always. + + Returns: + bool: Whether other is a neighbor of self when restricted by ``nbr_kind`` and ``where``. + """ + for nbr in self.neighbors_generator(nbr_kind=nbr_kind, where=where): + if other == nbr: + return True + return False + + def incident_edges( + self, + nbr_kind: EdgeKind | Iterable[EdgeKind] | None = None, + where: Callable[[CartesianCoord | ZephyrCoord], bool] = lambda coord: True, + ) -> list[ZEdge]: + """Returns incident edges with self when restricted by ``nbr_kind`` and ``where``. + + Args: + nbr_kind (EdgeKind | Iterable[EdgeKind] | None, optional): + Edge kind filter. Restricts returned edges to those having the given edge kind(s). + If ``None``, no filtering is applied. Defaults to None. + where (Callable[[CartesianCoord | ZephyrCoord], bool]): + A coordinate filter. Applies to ``ccoord`` if :py:attr:`self.convert_to_z` is ``False``, + or to ``zcoord`` if :py:attr:`self.convert_to_z` is ``True``. Defaults to always. + + Returns: + list[ZEdge]: List of edges incident with self when restricted by ``nbr_kind`` and ``where``. + """ + return [ZEdge(self, v) for v in self.neighbors(nbr_kind=nbr_kind, where=where)] + + def degree( + self, + nbr_kind: EdgeKind | Iterable[EdgeKind] | None = None, + where: Callable[[CartesianCoord | ZephyrCoord], bool] = lambda coord: True, + ) -> int: + """Returns degree of self when restricted by ``nbr_kind`` and ``where``. + + Args: + nbr_kind (EdgeKind | Iterable[EdgeKind] | None, optional): + Edge kind filter. Restricts counting the neighbors to those connected by the given edge kind(s). + If ``None``, no filtering is applied. Defaults to None. + where (Callable[[CartesianCoord | ZephyrCoord], bool]): + A coordinate filter. Applies to ``ccoord`` if :py:attr:`self.convert_to_z` is ``False``, + or to ``zcoord`` if :py:attr:`self.convert_to_z` is ``True``. Defaults to always. + + Returns: + int: degree of self when restricted by ``nbr_kind`` and ``where``. + """ + return len(self.neighbors(nbr_kind=nbr_kind, where=where)) + + def __eq__(self, other: ZNode) -> bool: + if not isinstance(other, ZNode): + raise TypeError(f"Expected {other} to be {type(self).__name__}, got {type(other)}") + if self._shape != other._shape: + raise ValueError( + f"Expected {self, other} to have the same shape, got {self._shape, other._shape}" + ) + return self._ccoord == other._ccoord + + + def __gt__(self, other: ZNode) -> bool: + if not isinstance(other, ZNode): + raise TypeError(f"Expected {other} to be {type(self).__name__}, got {type(other)}") + if self._shape != other._shape: + raise ValueError( + f"Expected {self, other} to have the same shape, got {self._shape, other._shape}" + ) + return self._ccoord > other._ccoord + + def __ge__(self, other: ZNode) -> bool: + if not isinstance(other, ZNode): + raise TypeError(f"Expected {other} to be {type(self).__name__}, got {type(other)}") + if self._shape != other._shape: + raise ValueError( + f"Expected {self, other} to have the same shape, got {self._shape, other._shape}" + ) + return self._ccoord >= other._ccoord + + def __lt__(self, other: ZNode) -> bool: + if not isinstance(other, ZNode): + raise TypeError(f"Expected {other} to be {type(self).__name__}, got {type(other)}") + if self._shape != other._shape: + raise ValueError( + f"Expected {self, other} to have the same shape, got {self._shape, other._shape}" + ) + return self._ccoord < other._ccoord + + def __le__(self, other: ZNode) -> bool: + if not isinstance(other, ZNode): + raise TypeError(f"Expected {other} to be {type(self).__name__}, got {type(other)}") + if self._shape != other._shape: + raise ValueError( + f"Expected {self, other} to have the same shape, got {self._shape, other._shape}" + ) + return self._ccoord <= other._ccoord + + def __add__( + self, + shift: ZPlaneShift | tuple[int], + ) -> ZNode: + if not isinstance(shift, ZPlaneShift): + shift = ZPlaneShift(*shift) + x, y, k = self._ccoord + new_x = x + shift[0] + new_y = y + shift[1] + + return ZNode(coord=CartesianCoord(x=new_x, y=new_y, k=k), shape=self._shape) + + def __sub__( + self, + other: ZNode, + ) -> ZPlaneShift: + if not isinstance(other, ZNode): + raise TypeError(f"Expected {other} to be {type(self).__name__}, got {type(other)}") + if self._shape != other._shape: + raise ValueError( + f"Expected {self, other} to have the same shape, got {self._shape, other._shape}" + ) + x_shift: int = self._ccoord.x - other._ccoord.x + y_shift: int = self._ccoord.y - other._ccoord.y + try: + return ZPlaneShift(x=x_shift, y=y_shift) + except ValueError as e: + raise ValueError(f"{other} cannot be subtracted from {self}") from e + + def __hash__(self) -> int: + return hash((self._ccoord, self._shape)) + + def __repr__(self) -> str: + if self.convert_to_z: + coord = self.zcoord + if coord.k is None: + coord_str = f"{coord.u, coord.w, coord.j, coord.z}" + else: + coord_str = f"{coord.u, coord.w, coord.k, coord.j, coord.z}" + else: + coord = self._ccoord + if coord.k is None: + coord_str = f"{coord.x, coord.y}" + else: + coord_str = f"{coord.x, coord.y, coord.k}" + if self._shape == ZShape(): + shape_str = "" + else: + shape_str = f", shape={self._shape.m, self._shape.t!r}" + return f"{type(self).__name__}(" + coord_str + shape_str + ")" diff --git a/minorminer/utils/zephyr/plane_shift.py b/minorminer/utils/zephyr/plane_shift.py new file mode 100644 index 00000000..0e002a82 --- /dev/null +++ b/minorminer/utils/zephyr/plane_shift.py @@ -0,0 +1,127 @@ +# Copyright 2025 D-Wave +# +# 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 __future__ import annotations + +from typing import Iterator + + +__all__ = ["PlaneShift", "ZPlaneShift"] + +class PlaneShift: + """Represents a displacement in a Cartesian plane. + + Args: + x (int): The displacement in the x-direction of a Cartesian coordinate. + y (int): The displacement in the y-direction of a Cartesian coordinate. + + + Example: + >>> from minorminer.utils.zephyr.plane_shift import PlaneShift + >>> ps1 = PlaneShift(1, 3) + >>> ps2 = PlaneShift(2, -4) + >>> print(f"{ps1 + ps2 = }") + >>> print(f"{2 * ps1 = }") + ps1 + ps2 = PlaneShift(3, -1) + 2 * ps1 = PlaneShift(2, 6) + """ + def __init__(self, x: int, y: int) -> None: + self._xy = (x, y) + + @property + def x(self) -> int: + """Returns the shift in x direction""" + return self._xy[0] + + @property + def y(self) -> int: + """Returns the shift in y direction""" + return self._xy[1] + + def __mul__(self, scale: int) -> PlaneShift: + """Multiplies the self from left by the number value ``scale``. + + Args: + scale (int): The scale for left-multiplying self with. + + Returns: + PlaneShift: The result of left-multiplying self by ``scale``. + """ + return type(self)(scale * self.x, scale * self.y) + + + def __rmul__(self, scale: int) -> PlaneShift: + """Multiplies the ``self`` from right by the number value ``scale``. + + Args: + scale (int): The scale for right-multiplying ``self`` with. + + Returns: + PlaneShift: The result of right-multiplying ``self`` by ``scale``. + """ + return self * scale + + def __add__(self, other: PlaneShift) -> PlaneShift: + """ + Adds another PlaneShift object to self. + + Args: + other (PlaneShift): The object to add self by. + + Raises: + TypeError: If other is not PlaneShift + + Returns: + PlaneShift: The displacement in CartesianCoord by self followed by other. + """ + if type(self) != type(other): + raise TypeError(f"Expected other to be {type(self).__name__}, got {type(other).__name__}") + return type(self)(self.x + other.x, self.y + other.y) + + def __iter__(self) -> Iterator[int]: + return self._xy.__iter__() + + def __len__(self) -> int: + return len(self._xy) + + def __hash__(self) -> int: + return hash(self._xy) + + def __getitem__(self, key) -> int: + return self._xy[key] + + def __eq__(self, other: PlaneShift) -> bool: + return type(self) == type(other) and self._xy == other._xy + + +class ZPlaneShift(PlaneShift): + """Represents a displacement in the Zephyr quotient plane (expressed in Cartesian coordinates). + + Args: + x (int): The displacement in the x-direction of a Cartesian coordinate. + y (int): The displacement in the y-direction of a Cartesian coordinate. + + Raises: + ValueError: If ``x`` and ``y`` have different parity. + """ + + def __init__(self, x: int, y: int) -> None: + if x % 2 != y % 2: + raise ValueError( + f"Expected x, y to have the same parity, got {x, y}" + ) + self._xy = (x, y) diff --git a/minorminer/utils/zephyr.py b/minorminer/utils/zephyr/zephyr.py similarity index 95% rename from minorminer/utils/zephyr.py rename to minorminer/utils/zephyr/zephyr.py index d7a56164..30f0523f 100644 --- a/minorminer/utils/zephyr.py +++ b/minorminer/utils/zephyr/zephyr.py @@ -1,4 +1,4 @@ -# Copyright 2022 D-Wave Systems Inc. +# Copyright 2022 D-Wave # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -14,12 +14,11 @@ # # ================================================================================================ -from dwave_networkx.generators.zephyr import zephyr_graph import networkx as nx - +from dwave_networkx.generators.zephyr import zephyr_graph from minorminer.busclique import busgraph_cache -__all__ = ['find_clique_embedding', 'find_biclique_embedding'] +__all__ = ["find_clique_embedding", "find_biclique_embedding"] def _get_target_graph(m=None, target_graph=None): @@ -130,5 +129,7 @@ def find_biclique_embedding(a, b, m=None, target_graph=None): if not embedding: raise ValueError("No biclique embedding found") - return ({x: embedding[anodes.index(x)] for x in anodes}, - {y: embedding[bnodes.index(y) + len(anodes)] for y in bnodes}) + return ( + {x: embedding[anodes.index(x)] for x in anodes}, + {y: embedding[bnodes.index(y) + len(anodes)] for y in bnodes}, + ) diff --git a/setup.py b/setup.py index 48d03168..00ca87ae 100644 --- a/setup.py +++ b/setup.py @@ -145,6 +145,7 @@ 'minorminer.utils', 'minorminer._extern', 'minorminer._extern.rpack', + 'minorminer.utils.zephyr', ], include_package_data=True, ) diff --git a/tests/utils/zephyr/test_coordinate_systems.py b/tests/utils/zephyr/test_coordinate_systems.py new file mode 100644 index 00000000..6c008994 --- /dev/null +++ b/tests/utils/zephyr/test_coordinate_systems.py @@ -0,0 +1,68 @@ +# Copyright 2025 D-Wave +# +# 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. +# +# ================================================================================================ + + +import unittest + +from parameterized import parameterized + +from minorminer.utils.zephyr.coordinate_systems import ( + CartesianCoord, + ZephyrCoord, + cartesian_to_zephyr, + zephyr_to_cartesian, +) + + +class TestCoordinateSystems(unittest.TestCase): + + def test_cartesian_to_zephyr_runs(self): + xyks = [(0, 1), (1, 0, None), (12, -3)] + ccoords = [CartesianCoord(*xyk) for xyk in xyks] + for ccoord in ccoords: + cartesian_to_zephyr(ccoord=ccoord) + + @parameterized.expand( + [ + ((0, 0, None, 0, 0), (0, 1, None)), + ((1, 0, None, 0, 0), (1, 0, None)), + ((1, 6, 3, 0, 1), (5, 12, 3)), + ] + ) + def test_cartesian_to_zephyr(self, zcoord, ccoord): + self.assertEqual( + ZephyrCoord(*zcoord), cartesian_to_zephyr(CartesianCoord(*ccoord)) + ) + self.assertEqual(CartesianCoord(*ccoord), zephyr_to_cartesian(ZephyrCoord(*zcoord))) + + def test_zephyr_to_cartesian_runs(self): + uwkjzs = [(0, 2, 4, 1, 5), (1, 3, 3, 0, 0), (1, 2, None, 1, 5)] + zcoords = [ZephyrCoord(*uwkjz) for uwkjz in uwkjzs] + for zcoord in zcoords: + zephyr_to_cartesian(zcoord=zcoord) + + def test_coordinate_systems_match(self): + valid_uwkjzs = [(0, 2, 4, 1, 5), (1, 3, 3, 0, 0), (1, 2, None, 1, 5)] + zcoords = [ZephyrCoord(*uwkjz) for uwkjz in valid_uwkjzs] + for zcoord in zcoords: + ccoord = zephyr_to_cartesian(zcoord=zcoord) + self.assertEqual(zcoord, cartesian_to_zephyr(ccoord=ccoord)) + + valid_xyks = [(0, 1), (1, 0, None), (12, -3)] + ccoords = [CartesianCoord(*xyk) for xyk in valid_xyks] + for ccoord in ccoords: + zcoord = cartesian_to_zephyr(ccoord=ccoord) + self.assertEqual(ccoord, zephyr_to_cartesian(zcoord=zcoord)) diff --git a/tests/utils/zephyr/test_node_edge.py b/tests/utils/zephyr/test_node_edge.py new file mode 100644 index 00000000..91bc8ecb --- /dev/null +++ b/tests/utils/zephyr/test_node_edge.py @@ -0,0 +1,350 @@ +# Copyright 2025 D-Wave +# +# 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. +# +# ================================================================================================ + +import unittest + +from parameterized import parameterized + +from minorminer.utils.zephyr.coordinate_systems import CartesianCoord, ZephyrCoord +from minorminer.utils.zephyr.node_edge import Edge, EdgeKind, NodeKind, ZEdge, ZNode, ZShape +from minorminer.utils.zephyr.plane_shift import ZPlaneShift + + +class TestEdge(unittest.TestCase): + def test_valid_input_runs(self) -> None: + Edge(2, 4) + Edge(5, -1) + + def test_invalid_input_raises_error(self) -> None: + with self.assertRaises(TypeError): + Edge(2, "string") + Edge(2, ZNode(ZephyrCoord(0, 10, 3, 1, 3), ZShape(t=4))) + + def test_equal(self) -> None: + self.assertEqual(Edge(2, 4), Edge(4, 2)) + + +class TestZEdge(unittest.TestCase): + @parameterized.expand( + [ + (ZephyrCoord(0, 10, 3, 1, 3), ZephyrCoord(0, 10, 3, 1, 2), ZShape(6, 4)), + (CartesianCoord(4, 3, 2), CartesianCoord(4, 1, 2), (6, 3)), + ((1, 6), (5, 6), None) + ] + ) + def test_valid_input_runs(self, x, y, shape) -> None: + zn_x = ZNode(coord=x, shape=shape) + zn_y = ZNode(coord=y, shape=shape) + + ZEdge(zn_x, zn_y) + + @parameterized.expand( + [ + ((2, 4), TypeError), + ((ZNode(coord=CartesianCoord(4, 3, 2), shape=(6, 3)), ZNode(coord=CartesianCoord(4, 3, 2), shape=(6, 3))), ValueError), + ] + ) + def test_invalid_input_raises_error(self, invalid_edge, expected_err): + with self.assertRaises(expected_err): + ZEdge(*invalid_edge) + + +U_VALS = [0, 1] +J_VALS = [0, 1] +M_VALS = [1, 6, 12] +T_VALS = [1, 2, 4, 6] + + +class TestZNode(unittest.TestCase): + def setUp(self): + self.u_vals = [0, 1] + self.invalid_u_vals = [2, -1, None, 3.5] + self.invalid_w_vals = [-1] + self.j_vals = [0, 1] + self.invalid_j_vals = [2, -1, None, 3.5] + self.m_vals = [6, 1, 20] + self.invalid_m_vals = [0, 2.5, -3] + self.t_vals = [6, 1, 20] + self.invalid_t_vals = [0, 2.5, -2] + self.invalid_z_vals = [-1, None] + xym_vals = [ + ((0, 3), 1), + ((5, 2), 6), + ((16, 1), 4), + ((1, 12), 3), + ((3, 0), 4), + ((5, 4), 6), + ((0, 3), 6), + ((6, 3), 5), + ] + self.xyms = xym_vals + [(xy, None) for xy, _ in xym_vals] + self.left_up_xyms = [((0, 3), 6), ((0, 3), None), ((11, 0), None), ((0, 5), 8)] + self.right_down_xyms = [((1, 12), 3), ((16, 1), 4)] + self.midgrid_xyms = [((5, 2), 6), ((5, 4), 6), ((6, 7), 5)] + + @parameterized.expand( + [ + ((u, w, k, j, z), (m, t)) + for u in U_VALS + for j in J_VALS + for m in M_VALS + for t in T_VALS + for w in range(2 * m + 1) + for k in range(t) + for z in range(m) + ][::100] + ) + def test_znode_zcoord_runs(self, uwkjz, mt): + ZNode(coord=uwkjz, shape=mt) + + @parameterized.expand( + [ + ((0, 3), 1), + ((5, 2), 6), + ((16, 1), 4), + ((1, 12), 3), + ((3, 0), 4), + ((5, 4), 6), + ((0, 3), 6), + ((6, 3), 5), + ((3, 0), None), + ((5, 4), None), + ((0, 3), None), + ] + ) + def test_znode_ccoord_runs(self, xy, m): + ZNode(xy, ZShape(m=m)) + ZNode(xy, ZShape(m=m), convert_to_z=True) + + @parameterized.expand( + [ + ((-1, 2), 6), + ((6, -1), 4), + ((1, 3), -5), + ((1, 3), 6), + ((2, 4), 6), + ((17, 0), 4), + ((0, 17), 4), + ((0, 0, 0), 1), + ((-1, 2), None), + ] + ) + def test_bad_args_raises_error_ccoord(self, xy, m): + with self.assertRaises((ValueError, TypeError)): + ZNode(coord=xy, shape=ZShape(m=m)) + + @parameterized.expand( + [ + ((2, 0, 0, 0, 0), (6, 4)), # All good except u_val + ((None, 3, 0, 0, 4), (6, 1)), # All good except u_val + ((3.5, 8, 2, 0, 0), (8, 4)), # All good except u_val + ((0, -1, 1, 1, 3), (6, 4)), # All good except w_val + ((0, 10, 2.5, 0, 5), (6, 4)), # All good except k_val + ((1, 23, -1, 1, 5), (12, 2)), # All good except k_val + ((1, 24, 1, 3.5, 9), (12, 4)), # All good except j_val + ((1, 24, 0, 1, None), (12, 2)), # All good except z_val + ((1, 20, 3, 1, 12), (12, 6)), # All good except z_val + ((0, 0, 0, 0, 0), (0, 0)), # All good except m_val + ((0, 0, 0, 0, 0), (1, -1)), # All good except t_val + ] + ) + def test_bad_args_raises_error_ccoord(self, uwkjz, mt): + with self.assertRaises((ValueError, TypeError)): + ZNode(coord=uwkjz, shape=mt) + + @parameterized.expand( + [ + ((0, 3), 6, ZPlaneShift(-1, -1)), + ((0, 3), None, ZPlaneShift(-1, -1)), + ((11, 0), None, ZPlaneShift(-1, -1)), + ((0, 5), 8, ZPlaneShift(-1, -1)), + ((1, 12), 3, ZPlaneShift(1, 1)), + ((16, 1), 4, ZPlaneShift(1, 1)), + ] + ) + def test_add_sub_raises_error_invalid(self, xy, m, zps) -> None: + zn = ZNode(xy, ZShape(m=m)) + with self.assertRaises(ValueError): + zn + zps + + @parameterized.expand( + [ + (ZNode((5, 2), ZShape(6)), ZPlaneShift(-2, -2), ZNode((3, 0), ZShape(6))), + (ZNode((5, 2), ZShape(6)), ZPlaneShift(2, -2), ZNode((7, 0), ZShape(6))), + (ZNode((5, 2), ZShape(6)), ZPlaneShift(2, 2), ZNode((7, 4), ZShape(6))), + (ZNode((5, 2), ZShape(6)), ZPlaneShift(-2, 2), ZNode((3, 4), ZShape(6))), + (ZNode((5, 4), ZShape(4)), ZPlaneShift(-4, -4), ZNode((1, 0), ZShape(4))), + (ZNode((6, 7), ZShape(5)), ZPlaneShift(1, 11), ZNode((7, 18), ZShape(5))), + (ZNode((0, 3), ZShape(6)), ZPlaneShift(0, 0), ZNode((0, 3), ZShape(6))), + (ZNode((1, 12), ZShape(3)), ZPlaneShift(1, -1), ZNode((2, 11), ZShape(3))), + ] + ) + def test_add_sub(self, zn, zps, expected) -> None: + self.assertEqual(zn + zps, expected) + + def test_neighbors_boundary(self) -> None: + x, y, k, t = 1, 12, 4, 6 + expected_nbrs = { + ZNode((x + i, y + j, kp), ZShape(t=t)) + for i in (-1, 1) + for j in (-1, 1) + for kp in range(t) + } + expected_nbrs |= {ZNode((x + 2, y, k), ZShape(t=t)), ZNode((x + 4, y, k), ZShape(t=t))} + self.assertEqual(set(ZNode((x, y, k), ZShape(t=t)).neighbors()), expected_nbrs) + + def test_neighbors_mid(self): + x, y, k, t = 10, 5, 3, 6 + expected_nbrs = { + ZNode((x + i, y + j, kp), ZShape(t=t)) + for i in (-1, 1) + for j in (-1, 1) + for kp in range(t) + } + expected_nbrs |= {ZNode((x, y + 4, k), ZShape(t=t)), ZNode((x, y - 4, k), ZShape(t=t))} + expected_nbrs |= {ZNode((x, y + 2, k), ZShape(t=t)), ZNode((x, y - 2, k), ZShape(t=t))} + self.assertEqual(set(ZNode((x, y, k), ZShape(t=t)).neighbors()), expected_nbrs) + + def test_zcoord(self) -> None: + ZNode((11, 12, 4), ZShape(t=6)).zcoord == ZephyrCoord(1, 0, 4, 0, 2) + ZNode((1, 0)).zcoord == ZephyrCoord(1, 0, None, 0, 0) + ZNode((0, 1)).zcoord == ZephyrCoord(0, 0, None, 0, 0) + + @parameterized.expand( + [ + ((u, w, k, j, z), (m, t)) + for u in U_VALS + for j in J_VALS + for m in M_VALS + for t in T_VALS + for w in range(2 * m + 1) + for k in range(t) + for z in range(m) + ][::300] + ) + def test_direction(self, uwkjz, mt) -> None: + zn = ZNode(coord=uwkjz, shape=mt) + self.assertEqual(zn.direction, uwkjz[0]) + + @parameterized.expand( + [ + ((u, w, k, j, z), (m, t)) + for u in U_VALS + for j in J_VALS + for m in M_VALS + for t in T_VALS + for w in range(2 * m + 1) + for k in range(t) + for z in range(m) + ][::200] + ) + def test_node_kind(self, uwkjz, mt) -> None: + zn = ZNode(coord=uwkjz, shape=mt) + if uwkjz[0] == 0: + self.assertTrue(zn.is_vertical()) + self.assertEqual(zn.node_kind, NodeKind.VERTICAL) + else: + self.assertTrue(zn.is_horizontal()) + self.assertEqual(zn.node_kind, NodeKind.HORIZONTAL) + + @parameterized.expand( + [ + (ZNode((1, 0)), EdgeKind.INTERNAL), + (ZNode((1, 2)), EdgeKind.INTERNAL), + (ZNode((0, 3)), EdgeKind.ODD), + (ZNode((0, 5)), EdgeKind.EXTERNAL), + (ZNode((0, 7)), None), + (ZNode((1, 6)), None), + ] + ) + def test_neighbor_kind(self, zn1, nbr_kind) -> None: + zn0 = ZNode((0, 1)) + self.assertIs(zn0.neighbor_kind(zn1), nbr_kind) + + @parameterized.expand( + [ + (ZNode((0, 1)), {ZNode((1, 0)), ZNode((1, 2))}), + ( + ZNode((0, 1, 0), ZShape(t=4)), + {ZNode((1, 0, k), ZShape(t=4)) for k in range(4)} + | {ZNode((1, 2, k), ZShape(t=4)) for k in range(4)}, + ), + ] + ) + def test_internal_generator(self, zn, expected) -> None: + set_internal = {x for x in zn.internal_neighbors_generator()} + self.assertEqual(set_internal, expected) + + @parameterized.expand( + [ + (ZNode((0, 1)), {ZNode((0, 5))}), + (ZNode((0, 1, 2), ZShape(t=4)), {ZNode((0, 5, 2), ZShape(t=4))}), + ( + ZNode((11, 6, 3), ZShape(t=4)), + {ZNode((7, 6, 3), ZShape(t=4)), ZNode((15, 6, 3), ZShape(t=4))}, + ), + ] + ) + def test_external_generator(self, zn, expected) -> None: + set_external = {x for x in zn.external_neighbors_generator()} + self.assertEqual(set_external, expected) + + @parameterized.expand( + [ + (ZNode((0, 1)), {ZNode((0, 3))}), + (ZNode((0, 1, 2), ZShape(t=4)), {ZNode((0, 3, 2), ZShape(t=4))}), + (ZNode((15, 8)), {ZNode((13, 8)), ZNode((17, 8))}), + ] + ) + def test_odd_generator(self, zn, expected) -> None: + set_odd = {x for x in zn.odd_neighbors_generator()} + self.assertEqual(set_odd, expected) + + @parameterized.expand( + [ + ((5, 2), 6, None, 4, 4), + ((5, 2), 6, EdgeKind.INTERNAL, 4, 0), + ((5, 2), 10, EdgeKind.EXTERNAL, 0, 2), + ((5, 2), 3, EdgeKind.ODD, 0, 2), + ((6, 7), 12, None, 4, 4), + ((6, 7), 8, EdgeKind.INTERNAL, 4, 0), + ((6, 7), 12, EdgeKind.EXTERNAL, 0, 2), + ((6, 7), 12, EdgeKind.ODD, 0, 2), + ((0, 1), 4, EdgeKind.INTERNAL, 2, 0), + ((0, 1), 2, EdgeKind.ODD, 0, 1), + ((0, 1), 4, EdgeKind.EXTERNAL, 0, 1), + ((0, 1), 4, None, 2, 2), + ((24, 5), None, EdgeKind.INTERNAL, 4, 0), + ((24, 5), None, EdgeKind.ODD, 0, 2), + ((24, 5), None, EdgeKind.EXTERNAL, 0, 2), + ((24, 5), None, None, 4, 4), + ((24, 5), 6, EdgeKind.INTERNAL, 2, 0), + ((24, 5), 8, EdgeKind.ODD, 0, 2), + ((24, 5), 6, EdgeKind.EXTERNAL, 0, 2), + ((24, 5), 8, None, 4, 4), + ] + ) + def test_degree(self, xy, m, nbr_kind, a, b) -> None: + for t in [None, 1, 4, 6]: + if t is None: + coord, t_p = xy, 1 + else: + coord, t_p = xy + (0, ), t + zn = ZNode(coord=coord, shape=ZShape(m=m, t=t)) + + with self.subTest(case=t): + self.assertEqual(zn.degree(nbr_kind=nbr_kind), a * t_p + b) + diff --git a/tests/utils/zephyr/test_plane_shift.py b/tests/utils/zephyr/test_plane_shift.py new file mode 100644 index 00000000..1958cc82 --- /dev/null +++ b/tests/utils/zephyr/test_plane_shift.py @@ -0,0 +1,105 @@ +# Copyright 2025 D-Wave +# +# 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. +# +# ================================================================================================ + + +import unittest +from itertools import combinations + +from parameterized import parameterized + +from minorminer.utils.zephyr.plane_shift import PlaneShift, ZPlaneShift + + +class TestPlaneShift(unittest.TestCase): + def setUp(self) -> None: + self.shifts = [ + (0, 1), + (-1, 0), + (2, -1), + (-4, 6), + (10, 4), + (0, 0), + ] + + def test_valid_input_runs(self) -> None: + for shift in self.shifts: + PlaneShift(*shift) + + def test_multiply(self) -> None: + for shift in self.shifts: + for scale in [0, 1, 2, 5, 10, -3]: + self.assertEqual( + PlaneShift(shift[0] * scale, shift[1] * scale), PlaneShift(*shift) * scale + ) + self.assertEqual( + PlaneShift(shift[0] * scale, shift[1] * scale), scale * PlaneShift(*shift) + ) + + def test_add(self) -> None: + for s0, s1 in combinations(self.shifts, 2): + self.assertEqual( + PlaneShift(*s0) + PlaneShift(*s1), PlaneShift(s0[0] + s1[0], s0[1] + s1[1]) + ) + + @parameterized.expand( + [ + (-1, PlaneShift(0, -4), PlaneShift(0, 4)), + (3, PlaneShift(2, 10), PlaneShift(6, 30)), + ] + ) + def test_mul(self, c, ps, expected) -> None: + self.assertEqual(c * ps, expected) + + + +class TestZPlaneShift(unittest.TestCase): + def setUp(self) -> None: + self.shifts = [ + (0, 2), + (-3, -1), + (-2, 0), + (1, 1), + (1, -3), + (-4, 6), + (10, 4), + (0, 0), + ] + + def test_valid_input_runs(self) -> None: + for shift in self.shifts: + ZPlaneShift(*shift) + + @parameterized.expand( + [ + (5, TypeError), + ("NE", TypeError), + ((0, 2, None), TypeError), + ((2, 0.5), ValueError), + ((4, 1), ValueError), + ((0, 1), ValueError), + ] + ) + def test_invalid_input_gives_error(self, invalid, expected_err) -> None: + with self.assertRaises(expected_err): + ZPlaneShift(*invalid) + + def test_eq(self): + self.assertNotEqual(PlaneShift(0, 0), ZPlaneShift(0, 0)) + + def test_mul(self) -> None: + self.assertEqual(-1 * ZPlaneShift(0, 2), ZPlaneShift(0, -2)) + self.assertEqual(3 * ZPlaneShift(2, 4), ZPlaneShift(6, 12)) + diff --git a/tests/utils/test_zephyr.py b/tests/utils/zephyr/test_zephyr.py similarity index 95% rename from tests/utils/test_zephyr.py rename to tests/utils/zephyr/test_zephyr.py index 1f82a0e5..1486045d 100644 --- a/tests/utils/test_zephyr.py +++ b/tests/utils/zephyr/test_zephyr.py @@ -17,12 +17,11 @@ import unittest from random import shuffle -from dwave_networkx.generators.zephyr import zephyr_graph import networkx as nx -from parameterized import parameterized - +from dwave_networkx.generators.zephyr import zephyr_graph from minorminer.utils.diagnostic import is_valid_embedding -from minorminer.utils.zephyr import find_clique_embedding, find_biclique_embedding +from minorminer.utils.zephyr import find_biclique_embedding, find_clique_embedding +from parameterized import parameterized class Test_find_clique_embedding(unittest.TestCase): @@ -37,7 +36,7 @@ def test_k_parameter_int(self): self.assertTrue(is_valid_embedding(embedding, nx.complete_graph(k_int), ze)) def test_k_parameter_list(self): - k_nodes = ['one', 'two', 'three'] + k_nodes = ["one", "two", "three"] m = 4 # Find embedding @@ -148,9 +147,9 @@ def test_clique_missing_edges(self): class Test_find_biclique_embedding(unittest.TestCase): - ABC_DE = (['a', 'b', 'c'], ['d', 'e'], 2) - ABC_P = (['a', 'b', 'c'], 7, 2) - N_DE = (4, ['d', 'e'], 2) + ABC_DE = (["a", "b", "c"], ["d", "e"], 2) + ABC_P = (["a", "b", "c"], 7, 2) + N_DE = (4, ["d", "e"], 2) @parameterized.expand(((6, 6, 2), (16, 16, 3), (4, 5, 1), ABC_DE, ABC_P, N_DE)) def test_success(self, a, b, m): @@ -171,7 +170,7 @@ def test_success(self, a, b, m): biclique = nx.complete_bipartite_graph(left, right) self.assertTrue(is_valid_embedding({**left, **right}, biclique, ze)) - @parameterized.expand((((1, 2), (2, 3)), ((1, 2), 2), (3, (2, 3)), (('a', 'b'), ('b', 'c')))) + @parameterized.expand((((1, 2), (2, 3)), ((1, 2), 2), (3, (2, 3)), (("a", "b"), ("b", "c")))) def test_overlapping_labels(self, a, b): m = 2