diff --git a/doc/releases/changelog-dev.md b/doc/releases/changelog-dev.md index e23a0e14f61..0a7fb5f2c1a 100644 --- a/doc/releases/changelog-dev.md +++ b/doc/releases/changelog-dev.md @@ -4,6 +4,10 @@

New features since last release

+* Function is added for generating the spin Hamiltonian for the + [Kitaev](https://arxiv.org/abs/cond-mat/0506438) model on a lattice. + [(#6174)](https://github.com/PennyLaneAI/pennylane/pull/6174) + * Functions are added for generating spin Hamiltonians for [Emery] (https://journals.aps.org/prl/abstract/10.1103/PhysRevLett.58.2794) and [Haldane](https://journals.aps.org/prl/pdf/10.1103/PhysRevLett.61.2015) models on a lattice. diff --git a/pennylane/spin/__init__.py b/pennylane/spin/__init__.py index a5f134e8996..0b4b32fa492 100644 --- a/pennylane/spin/__init__.py +++ b/pennylane/spin/__init__.py @@ -16,4 +16,4 @@ """ from .lattice import Lattice -from .spin_hamiltonian import emery, fermi_hubbard, haldane, heisenberg, transverse_ising +from .spin_hamiltonian import emery, fermi_hubbard, haldane, heisenberg, kitaev, transverse_ising diff --git a/pennylane/spin/lattice.py b/pennylane/spin/lattice.py index add00151143..527b8a0b70d 100644 --- a/pennylane/spin/lattice.py +++ b/pennylane/spin/lattice.py @@ -34,11 +34,16 @@ class Lattice: vectors (list[list[float]]): Primitive vectors for the lattice. positions (list[list[float]]): Initial positions of spin cites. Default value is ``[[0.0]`` :math:`\times` ``number of dimensions]``. - - boundary_condition (bool or list[bool]): Defines boundary conditions in different lattice axes. - Default is ``False`` indicating open boundary condition. + boundary_condition (bool or list[bool]): Defines boundary conditions for different lattice axes, + default is ``False`` indicating open boundary condition. neighbour_order (int): Specifies the interaction level for neighbors within the lattice. - Default is 1, indicating nearest neighbour. + Default is 1, indicating nearest neighbour. This cannot be greater than 1 if custom_edges is defined. + custom_edges (Optional[list(list(tuples))]): Specifies the edges to be added in the lattice. + Default value is ``None``, which adds the edges based on ``neighbour_order``. + Each element in the list is for a separate edge, and can contain 1 or 2 tuples. + First tuple contains the indices of the starting and ending vertices of the edge. + Second tuple is optional and contains the operator on that edge and coefficient + of that operator. Default value is the index of edge in custom_edges list. distance_tol (float): Distance below which spatial points are considered equal for the purpose of identifying nearest neighbours. Default value is 1e-5. @@ -72,6 +77,7 @@ def __init__( positions=None, boundary_condition=False, neighbour_order=1, + custom_edges=None, distance_tol=1e-5, ): @@ -112,10 +118,20 @@ def __init__( n_sl = len(self.positions) self.n_sites = math.prod(n_cells) * n_sl self.lattice_points, lattice_map = self._generate_grid(neighbour_order) + if custom_edges is None: + cutoff = ( + neighbour_order * math.max(math.linalg.norm(self.vectors, axis=1)) + distance_tol + ) + edges = self._identify_neighbours(cutoff) + self.edges = Lattice._generate_true_edges(edges, lattice_map, neighbour_order) + else: + if neighbour_order > 1: + raise ValueError( + "custom_edges cannot be specified if neighbour_order argument is set to greater than 1." + ) + lattice_map = dict(zip(lattice_map, self.lattice_points)) + self.edges = self._get_custom_edges(custom_edges, lattice_map) - cutoff = neighbour_order * math.max(math.linalg.norm(self.vectors, axis=1)) + distance_tol - edges = self._identify_neighbours(cutoff) - self.edges = Lattice._generate_true_edges(edges, lattice_map, neighbour_order) self.edges_indices = [(v1, v2) for (v1, v2, color) in self.edges] def _identify_neighbours(self, cutoff): @@ -187,7 +203,113 @@ def _generate_grid(self, neighbour_order): lattice_points.append(point) lattice_map.append(node_index) - return math.array(lattice_points), math.array(lattice_map) + return math.array(lattice_points), lattice_map + + def _get_custom_edges(self, custom_edges, lattice_map): + """Generates the edges described in `custom_edges` for all unit cells. + + Args: + custom_edges (Optional[list(list(tuples))]): Specifies the edges to be added in the lattice. + Default value is None, which adds the edges based on neighbour_order. + Each element in the list is for a separate edge, and can contain 1 or 2 tuples. + First tuple contains the index of the starting and ending vertex of the edge. + Second tuple is optional and contains the operator on that edge and coefficient + of that operator. + lattice_map (list[int]): A list to represent the node number for each lattice_point. + + Returns: + List of edges. + + **Example** + + Generates a square lattice with a single diagonal and assigns a different operation + to horizontal, vertical, and diagonal edges. + >>> n_cells = [3,3] + >>> vectors = [[1, 0], [0,1]] + >>> custom_edges = [ + [(0, 1), ("XX", 0.1)], + [(0, 3), ("YY", 0.2)], + [(0, 4), ("XY", 0.3)], + ] + >>> lattice = qml.spin.Lattice(n_cells=n_cells, vectors=vectors, custom_edges=custom_edges) + >>> lattice.edges + [(0, 1, ('XX', 0.1)), + (1, 2, ('XX', 0.1)), + (3, 4, ('XX', 0.1)), + (4, 5, ('XX', 0.1)), + (6, 7, ('XX', 0.1)), + (7, 8, ('XX', 0.1)), + (0, 3, ('YY', 0.2)), + (1, 4, ('YY', 0.2)), + (2, 5, ('YY', 0.2)), + (3, 6, ('YY', 0.2)), + (4, 7, ('YY', 0.2)), + (5, 8, ('YY', 0.2)), + (0, 4, ('XY', 0.3)), + (1, 5, ('XY', 0.3)), + (3, 7, ('XY', 0.3)), + (4, 8, ('XY', 0.3)) + ] + + """ + + for edge in custom_edges: + if len(edge) not in (1, 2): + raise TypeError( + """ + The elements of custom_edges should be lists of length 1 or 2. + Inside said lists should be a tuple that contains two lattice + indices to represent the edge and, optionally, a tuple that represents + the operation and coefficient for that edge. + Every tuple must contain two lattice indices to represent the edge + and can optionally include a list to represent the operation and coefficient for that edge. + """ + ) + + if edge[0][0] >= self.n_sites or edge[0][1] >= self.n_sites: + raise ValueError( + f"The edge {edge[0]} has vertices greater than n_sites, {self.n_sites}" + ) + + edges = [] + n_sl = len(self.positions) + nsites_axis = math.cumprod([n_sl, *self.n_cells[:0:-1]])[::-1] + + for i, custom_edge in enumerate(custom_edges): + edge = custom_edge[0] + + edge_operation = custom_edge[1] if len(custom_edge) == 2 else i + + # Finds the coordinates of starting and ending vertices of the edge + # and the vector distance between the coordinates + vertex1 = lattice_map[edge[0]] + vertex2 = lattice_map[edge[1]] + edge_distance = vertex2 - vertex1 + + # Calculates the number of unit cells that a given edge spans in each direction + v1, v2 = math.mod(edge, n_sl) + translation_vector = ( + edge_distance + self.positions[v1] - self.positions[v2] + ) @ math.linalg.inv(self.vectors) + translation_vector = math.asarray(math.rint(translation_vector), dtype=int) + + # Finds the minimum and maximum range for a given edge based on boundary_conditions + edge_ranges = [] + for idx, cell in enumerate(self.n_cells): + t_point = 0 if self.boundary_condition[idx] else translation_vector[idx] + edge_ranges.append( + range(math.maximum(0, -t_point), cell - math.maximum(0, t_point)) + ) + + # Finds the indices for starting and ending vertices of the edge + for cell in itertools.product(*edge_ranges): + node1_idx = math.dot(math.mod(cell, self.n_cells), nsites_axis) + v1 + node2_idx = ( + math.dot(math.mod(cell + translation_vector, self.n_cells), nsites_axis) + v2 + ) + edges.append((node1_idx, node2_idx, edge_operation)) + + return edges def add_edge(self, edge_indices): r"""Adds a specific edge based on the site index without translating it. diff --git a/pennylane/spin/spin_hamiltonian.py b/pennylane/spin/spin_hamiltonian.py index caff6804838..fd518cb7101 100644 --- a/pennylane/spin/spin_hamiltonian.py +++ b/pennylane/spin/spin_hamiltonian.py @@ -19,7 +19,7 @@ from pennylane import X, Y, Z, math from pennylane.fermi import FermiWord -from .lattice import _generate_lattice +from .lattice import Lattice, _generate_lattice # pylint: disable=too-many-arguments, too-many-branches @@ -608,3 +608,84 @@ def haldane( qubit_ham = qml.qchem.qubit_observable(hamiltonian, mapping=mapping) return qubit_ham.simplify() + + +def kitaev(n_cells, coupling=None, boundary_condition=False): + r"""Generates the Hamiltonian for the Kitaev model on the Honeycomb lattice. + + The `Kitaev `_ model Hamiltonian is represented as: + + .. math:: + \begin{align*} + \hat{H} = K_x.\sum_{\langle i,j \rangle \in X}\sigma_i^x\sigma_j^x + + \:\: K_y.\sum_{\langle i,j \rangle \in Y}\sigma_i^y\sigma_j^y + + \:\: K_z.\sum_{\langle i,j \rangle \in Z}\sigma_i^z\sigma_j^z + \end{align*} + + where :math:`K_x`, :math:`K_y`, :math:`K_z` are the coupling constants defined for the Hamiltonian, + and :math:`X`, :math:`Y`, :math:`Z` represent the set of edges in the Honeycomb lattice between spins + :math:`i` and :math:`j` with real-space bond directions :math:`[0, 1], [\frac{\sqrt{3}}{2}, \frac{1}{2}], + \frac{\sqrt{3}}{2}, -\frac{1}{2}]`, respectively. + + Args: + n_cells (list[int]): Number of cells in each direction of the grid. + coupling (Optional[list[float] or tensor_like(float)]): Coupling between spins, it is a list of length 3. + Default value is [1.0, 1.0, 1.0]. + boundary_condition (bool or list[bool]): Defines boundary conditions for different lattice axes. + The default is ``False``, indicating open boundary conditions for all. + + Raises: + ValueError: if ``coupling`` doesn't have correct dimensions. + + Returns: + ~ops.op_math.Sum: Hamiltonian for the Kitaev model. + + **Example** + + >>> n_cells = [2,2] + >>> k = [0.5, 0.6, 0.7] + >>> spin_ham = qml.spin.kitaev(n_cells, coupling=k) + >>> spin_ham + ( + 0.5 * (X(0) @ X(1)) + + 0.5 * (X(2) @ X(3)) + + 0.5 * (X(4) @ X(5)) + + 0.5 * (X(6) @ X(7)) + + 0.6 * (Y(1) @ Y(2)) + + 0.6 * (Y(5) @ Y(6)) + + 0.7 * (Z(1) @ Z(4)) + + 0.7 * (Z(3) @ Z(6)) + ) + + """ + + if coupling is None: + coupling = [1.0, 1.0, 1.0] + + if len(coupling) != 3: + raise ValueError("The coupling parameter should be a list of length 3.") + + vectors = [[1, 0], [0.5, 0.75**0.5]] + positions = [[0, 0], [0.5, 0.5 / 3**0.5]] + custom_edges = [ + [(0, 1), ("XX", coupling[0])], + [(1, 2), ("YY", coupling[1])], + [(1, n_cells[1] * 2), ("ZZ", coupling[2])], + ] + + lattice = Lattice( + n_cells=n_cells[0:2], + vectors=vectors, + positions=positions, + boundary_condition=boundary_condition, + custom_edges=custom_edges, + ) + opmap = {"X": X, "Y": Y, "Z": Z} + hamiltonian = 0.0 * qml.I(0) + for edge in lattice.edges: + v1, v2 = edge[0:2] + op1, op2 = edge[2][0] + coeff = edge[2][1] + hamiltonian += coeff * (opmap[op1](v1) @ opmap[op2](v2)) + + return hamiltonian.simplify() diff --git a/tests/spin/test_lattice.py b/tests/spin/test_lattice.py index 392dfa5af28..4f8484945ca 100644 --- a/tests/spin/test_lattice.py +++ b/tests/spin/test_lattice.py @@ -14,6 +14,8 @@ """ Unit tests for functions and classes needed for construct a lattice. """ +import re + import numpy as np import pytest @@ -791,6 +793,78 @@ def test_shape_error(): _generate_lattice(lattice=lattice, n_cells=n_cells) +def test_neighbour_order_error(): + r"""Test that an error is raised if neighbour order is greater than 1 when custom_edges are provided.""" + + vectors = [[0, 1], [1, 0]] + n_cells = [3, 3] + custom_edges = [[(0, 1)], [(0, 5)], [(0, 4)]] + with pytest.raises( + ValueError, + match="custom_edges cannot be specified if neighbour_order argument is set to greater than 1.", + ): + Lattice(n_cells=n_cells, vectors=vectors, neighbour_order=2, custom_edges=custom_edges) + + +def test_custom_edge_type_error(): + r"""Test that an error is raised if custom_edges are not provided as a list of length 1 or 2.""" + + vectors = [[0, 1], [1, 0]] + n_cells = [3, 3] + custom_edges = [[(0, 1), 1, 3], [(0, 5)], [(0, 4)]] + with pytest.raises( + TypeError, match="The elements of custom_edges should be lists of length 1 or 2." + ): + Lattice(n_cells=n_cells, vectors=vectors, custom_edges=custom_edges) + + +def test_custom_edge_value_error(): + r"""Test that an error is raised if the custom_edges contains an edge with site_index greater than number of sites""" + + vectors = [[0, 1], [1, 0]] + n_cells = [3, 3] + custom_edges = [[(0, 1)], [(0, 5)], [(0, 12)]] + with pytest.raises( + ValueError, match=re.escape("The edge (0, 12) has vertices greater than n_sites, 9") + ): + Lattice(n_cells=n_cells, vectors=vectors, custom_edges=custom_edges) + + +@pytest.mark.parametrize( + # expected_edges here were obtained manually + ("vectors", "positions", "n_cells", "custom_edges", "expected_edges"), + [ + ( + [[0, 1], [1, 0]], + [[0, 0]], + [3, 3], + [[(0, 1)], [(0, 5)], [(0, 4)]], + [(0, 1, 0), (0, 5, 1), (0, 4, 2), (1, 2, 0), (3, 4, 0), (0, 4, 2), (1, 5, 2)], + ), + ( + [[0, 1], [1, 0]], + [[0, 0]], + [3, 4], + [[(0, 1)], [(1, 4)], [(1, 5)]], + [(0, 1, 0), (1, 2, 0), (2, 3, 0), (1, 4, 1), (2, 5, 1), (0, 4, 2), (2, 6, 2)], + ), + ( + [[1, 0], [0.5, np.sqrt(3) / 2]], + [[0.5, 0.5 / 3**0.5], [1, 1 / 3**0.5]], + [2, 2], + [[(0, 1)], [(1, 2)], [(1, 5)]], + [(2, 3, 0), (4, 5, 0), (1, 2, 1), (5, 6, 1), (1, 5, 2), (3, 7, 2)], + ), + ], +) +def test_custom_edges(vectors, positions, n_cells, custom_edges, expected_edges): + r"""Test that the edges are added as per custom_edges provided""" + lattice = Lattice( + n_cells=n_cells, vectors=vectors, positions=positions, custom_edges=custom_edges + ) + assert np.all(np.isin(expected_edges, lattice.edges)) + + def test_dimension_error(): r"""Test that an error is raised if wrong dimension is provided for a given lattice shape.""" n_cells = [5, 5, 5] diff --git a/tests/spin/test_spin_hamiltonian.py b/tests/spin/test_spin_hamiltonian.py index bd7d31c86fc..1d489e2a124 100644 --- a/tests/spin/test_spin_hamiltonian.py +++ b/tests/spin/test_spin_hamiltonian.py @@ -21,7 +21,7 @@ import pennylane as qml from pennylane import I, X, Y, Z -from pennylane.spin import emery, fermi_hubbard, haldane, heisenberg, transverse_ising +from pennylane.spin import emery, fermi_hubbard, haldane, heisenberg, kitaev, transverse_ising # pylint: disable=too-many-arguments pytestmark = pytest.mark.usefixtures("new_opmath_only") @@ -1301,3 +1301,96 @@ def test_haldane_hamiltonian_matrix(shape, n_cells, t1, t2, phi, boundary_condit ) qml.assert_equal(haldane_ham, expected_ham) + + +def test_coupling_error_kitaev(): + r"""Test that an error is raised when the provided coupling shape is wrong for + Kitaev Hamiltonian.""" + with pytest.raises( + ValueError, + match=re.escape("The coupling parameter should be a list of length 3."), + ): + kitaev(n_cells=[3, 4], coupling=[1.0, 2.0]) + + +@pytest.mark.parametrize( + # expected_ham here was obtained manually + ("n_cells", "j", "boundary_condition", "expected_ham"), + [ + ( + [2, 2, 1], + None, + False, + 1.0 * (Z(1) @ Z(4)) + + 1.0 * (Z(3) @ Z(6)) + + 1.0 * (X(0) @ X(1)) + + 1.0 * (X(2) @ X(3)) + + 1.0 * (X(4) @ X(5)) + + 1.0 * (X(6) @ X(7)) + + 1.0 * (Y(1) @ Y(2)) + + 1.0 * (Y(5) @ Y(6)), + ), + ( + [2, 2], + [0.5, 0.6, 0.7], + False, + 0.7 * (Z(1) @ Z(4)) + + 0.7 * (Z(3) @ Z(6)) + + 0.5 * (X(0) @ X(1)) + + 0.5 * (X(2) @ X(3)) + + 0.5 * (X(4) @ X(5)) + + 0.5 * (X(6) @ X(7)) + + 0.6 * (Y(1) @ Y(2)) + + 0.6 * (Y(5) @ Y(6)), + ), + ( + [2, 3], + [0.1, 0.2, 0.3], + True, + 0.3 * (Z(1) @ Z(6)) + + 0.3 * (Z(3) @ Z(8)) + + 0.3 * (Z(5) @ Z(10)) + + 0.3 * (Z(0) @ Z(7)) + + 0.3 * (Z(2) @ Z(9)) + + 0.3 * (Z(4) @ Z(11)) + + 0.1 * (X(0) @ X(1)) + + 0.1 * (X(2) @ X(3)) + + 0.1 * (X(4) @ X(5)) + + 0.1 * (X(6) @ X(7)) + + 0.1 * (X(8) @ X(9)) + + 0.1 * (X(10) @ X(11)) + + 0.2 * (Y(1) @ Y(2)) + + 0.2 * (Y(3) @ Y(4)) + + 0.2 * (Y(0) @ Y(5)) + + 0.2 * (Y(7) @ Y(8)) + + 0.2 * (Y(9) @ Y(10)) + + 0.2 * (Y(11) @ Y(6)), + ), + ( + [2, 3], + [0.1, 0.2, 0.3], + [True, False], + 0.3 * (Z(1) @ Z(6)) + + 0.3 * (Z(3) @ Z(8)) + + 0.3 * (Z(5) @ Z(10)) + + 0.3 * (Z(0) @ Z(7)) + + 0.3 * (Z(2) @ Z(9)) + + 0.3 * (Z(4) @ Z(11)) + + 0.1 * (X(0) @ X(1)) + + 0.1 * (X(2) @ X(3)) + + 0.1 * (X(4) @ X(5)) + + 0.1 * (X(6) @ X(7)) + + 0.1 * (X(8) @ X(9)) + + 0.1 * (X(10) @ X(11)) + + 0.2 * (Y(1) @ Y(2)) + + 0.2 * (Y(3) @ Y(4)) + + 0.2 * (Y(7) @ Y(8)) + + 0.2 * (Y(9) @ Y(10)), + ), + ], +) +def test_kitaev_hamiltonian(n_cells, j, boundary_condition, expected_ham): + r"""Test that the correct Hamiltonian is generated""" + kitaev_ham = kitaev(n_cells=n_cells, coupling=j, boundary_condition=boundary_condition) + + qml.assert_equal(kitaev_ham, expected_ham)