diff --git a/.pylintdict b/.pylintdict index 96f0d9f0a..1bf007d13 100644 --- a/.pylintdict +++ b/.pylintdict @@ -78,6 +78,7 @@ chkfile cholesky chuang ci +classmethod clbit clbits clifford @@ -301,6 +302,7 @@ kwargs kwds labelled langle +lbl lbrace lda ldots @@ -326,6 +328,7 @@ lvert lysine macos majorana +majoranaop makefile matmul matplotlib @@ -453,6 +456,7 @@ pxd py pydata pyquante +pyright pyscf qarg qargs diff --git a/qiskit_nature/second_q/operators/__init__.py b/qiskit_nature/second_q/operators/__init__.py index 5ac9b844e..95e6bb445 100644 --- a/qiskit_nature/second_q/operators/__init__.py +++ b/qiskit_nature/second_q/operators/__init__.py @@ -23,6 +23,7 @@ ElectronicIntegrals FermionicOp + MajoranaOp BosonicOp SparseLabelOp SpinOp @@ -44,6 +45,7 @@ from .electronic_integrals import ElectronicIntegrals from .fermionic_op import FermionicOp +from .majorana_op import MajoranaOp from .bosonic_op import BosonicOp from .spin_op import SpinOp from .vibrational_op import VibrationalOp @@ -55,6 +57,7 @@ __all__ = [ "ElectronicIntegrals", "FermionicOp", + "MajoranaOp", "BosonicOp", "SpinOp", "VibrationalOp", diff --git a/qiskit_nature/second_q/operators/fermionic_op.py b/qiskit_nature/second_q/operators/fermionic_op.py index 2fb19bee6..3c846b547 100644 --- a/qiskit_nature/second_q/operators/fermionic_op.py +++ b/qiskit_nature/second_q/operators/fermionic_op.py @@ -143,7 +143,6 @@ class FermionicOp(SparseLabelOp): However, a FermionicOp containing parameters does not support the following methods: - ``is_hermitian`` - - ``to_matrix`` """ _OPERATION_REGEX = re.compile(r"([\+\-]_\d+\s)*[\+\-]_\d+") @@ -459,7 +458,8 @@ def index_order(self) -> FermionicOp: } ) - def _index_order(self, terms: list[tuple[str, int]], coeff: _TCoeff) -> tuple[str, _TCoeff]: + @classmethod + def _index_order(cls, terms: list[tuple[str, int]], coeff: _TCoeff) -> tuple[str, _TCoeff]: if not terms: return "", coeff diff --git a/qiskit_nature/second_q/operators/majorana_op.py b/qiskit_nature/second_q/operators/majorana_op.py new file mode 100644 index 000000000..b6c67f884 --- /dev/null +++ b/qiskit_nature/second_q/operators/majorana_op.py @@ -0,0 +1,513 @@ +# This code is part of a Qiskit project. +# +# (C) Copyright IBM 2023, 2024. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +"""The Majorana-particle Operator.""" + + +from __future__ import annotations + +import re +from collections import defaultdict +from collections.abc import Collection, Mapping +from typing import Iterator, Sequence + +import numpy as np + +from qiskit_nature.exceptions import QiskitNatureError + +from .polynomial_tensor import PolynomialTensor +from .sparse_label_op import _TCoeff, SparseLabelOp, _to_number +from .fermionic_op import FermionicOp + + +class MajoranaOp(SparseLabelOp): + r"""N-mode Majorana operator. + + A ``MajoranaOp`` represents a weighted sum of Majorana fermion operator terms. + These terms are encoded as sparse labels, which are strings consisting of a space-separated list + of expressions. Each expression must look like :code:`_`, where the :code:`` is a + non-negative integer representing the index of the mode on which the Majorana operator is + applied. The maximum value of :code:`index` is bound by ``num_modes``. Note that, when + converting from a ``FermionicOp`` there are two modes per spin orbital, i.e. ``num_modes`` is + :code:`2 * FermionicOp.num_spin_orbitals - 1` + + **Initialization** + + A ``MajoranaOp`` is initialized with a dictionary, mapping terms to their respective + coefficients: + + .. code-block:: python + + from qiskit_nature.second_q.operators import MajoranaOp + + op = MajoranaOp( + { + "_0 _1": .25j, + "_1 _0": -.25j, + "_2 _3": -.25j, + "_3 _2": .25j, + }, + num_modes=4, + ) + + By default, this way of initializing will create a full copy of the dictionary of coefficients. + If you have very restricted memory resources available, or would like to avoid the additional + copy, the dictionary will be stored by reference if you disable ``copy`` like so: + + .. code-block:: python + + some_big_data = { + "_0 _1": .25j, + "_1 _0": -.25j, + # ... + } + + op = MajoranaOp( + some_big_data, + num_modes=4, + copy=False, + ) + + + .. note:: + + It is the users' responsibility, that in the above scenario, :code:`some_big_data` is not + changed after initialization of the ``MajoranaOp``, since the operator contents are not + guaranteed to remain unaffected by such changes. + + **Construction from Fermionic operator** + + As an alternative to the manual construction above, a more convenient way of initializing a + `MajoranaOp` is, to construct it from an existing `FermionicOp`: + + .. code-block:: python + + from qiskit_nature.second_q.operators import FermionicOp, MajoranaOp + f_op = FermionicOp({"+_0 -_1": 1}, num_spin_orbitals=2) + m_op = MajoranaOp.from_fermionic_op(f_op) + + Note that each ``FerminonicOp``-term consisting of :math:`n` expressions will result in a + ``MajoranaOp``-term consisting of :math:`2^n` expressions. The conversion uses the convention + that + + .. math:: + + a_i = \frac{1}{2}(\gamma_{2i} + i \gamma_{2i+1}), \quad + a_i^\dagger = \frac{1}{2}(\gamma_{2i} - i \gamma_{2i+1}) \,, + + where :math:`a_i` and :math:`a_i^\dagger` are the Fermionic annihilation and creation operators + and :math:`\gamma_i` the Majorana operators. + + **Construction from a ``PolynomialTensor``** + + Using the :meth:`from_polynomial_tensor` constructor method, a ``MajoranaOp`` can be constructed + from a :class:`~.PolynomialTensor`. In this case, the underscore character :code:`_` is the only + allowed character in the keys of the ``PolynomialTensor``. + For example, + + .. code-block:: python + + p_t = PolynomialTensor( + { + "_": np.arange(1, 3), + "__": np.arange(1, 5).reshape((2, 2)), + } + ) + op = MajoranaOp.from_polynomial_tensor(p_t) + + # op is then + MajoranaOp({'_0': 1, '_1': 2, '_0 _0': 1, '_0 _1': 2, '_1 _0': 3, '_1 _1': 4}, num_modes=2) + + **Algebra** + + This class supports the following basic arithmetic operations: addition, subtraction, scalar + multiplication, operator multiplication, and adjoint. + For example, + + Addition + + .. code-block:: python + + MajoranaOp({"_1": 1}, num_modes=2) + MajoranaOp({"_0": 1}, num_modes=2) + + Sum + + .. code-block:: python + + sum(MajoranaOp({label: 1}, num_modes=4) for label in ["_0", "_1", "_2 _3"]) + + Scalar multiplication + + .. code-block:: python + + 0.5 * MajoranaOp({"_1": 1}, num_modes=2) + + Operator multiplication + + .. code-block:: python + + op1 = MajoranaOp({"_0 _1": 1}, num_modes=3) + op2 = MajoranaOp({"_0 _1 _2": 1}, num_modes=3) + print(op1 @ op2) + + Tensor multiplication + + .. code-block:: python + + op = MajoranaOp({"_0 _1": 1}, num_modes=2) + print(op ^ op) + + Adjoint + + .. code-block:: python + + MajoranaOp({"_0 _1": 1j}, num_modes=2).adjoint() + + .. note:: + + Since Majorana operators are self-adjoined, the adjoint of a ``MajoranaOp`` is the original + operator with all strings reversed, e.g. :code:`"_0 _1"` becomes :code:`"_1 _0"` in the + example above, and coefficients become complex conjugated. + + **Iteration** + + Instances of ``MajoranaOp`` are iterable. Iterating a ``MajoranaOp`` yields + ``(term, coefficient)`` pairs describing the terms contained in the operator. + + Attributes: + num_modes (int | None): the number of modes on which this operator acts. + This is considered a lower bound, which means that mathematical operations acting on two + or more operators will result in a new operator with the maximum number of modes of any + of the involved operators. + When converting from a ``FermionicOp``, this is twice the number of spin orbitals. + + .. note:: + + ``MajoranaOp`` can contain :class:`qiskit.circuit.ParameterExpression` objects as + coefficients. However, a ``MajoranaOp`` containing parameters does not support the following + methods: + + - ``is_hermitian`` + """ + + _OPERATION_REGEX = re.compile(r"(_\d+\s)*_\d+") + + def __init__( + self, + data: Mapping[str, _TCoeff], + num_modes: int | None = None, + *, + copy: bool = True, + validate: bool = True, + ) -> None: + """ + Args: + data: the operator data, mapping string-based keys to numerical values. + num_modes: the number of modes on which this operator acts. + copy: when set to False the ``data`` will not be copied and the dictionary will be + stored by reference rather than by value (which is the default; ``copy=True``). + Note, that this requires you to not change the contents of the dictionary after + constructing the operator. This also implies ``validate=False``. Use with care! + validate: when set to False the ``data`` keys will not be validated. Note, that the + SparseLabelOp base class, makes no assumption about the data keys, so will not + perform any validation by itself. Only concrete subclasses are encouraged to + implement a key validation method. Disable this setting with care! + + Raises: + QiskitNatureError: when an invalid key is encountered during validation. + """ + self.num_modes = num_modes + # if num_modes is None, it is set during _validate_keys + super().__init__(data, copy=copy, validate=validate) + + @property + def register_length(self) -> int: + if self.num_modes is None: + max_index = max(int(term[1:]) for key in self._data for term in key.split()) + return max_index + 1 + return self.num_modes + + def _new_instance( + self, data: Mapping[str, _TCoeff], *, other: MajoranaOp | None = None + ) -> MajoranaOp: + num_modes = self.num_modes + if other is not None: + other_num_modes = other.num_modes + if num_modes is None: + num_modes = other_num_modes + elif other_num_modes is not None: + num_modes = max(num_modes, other_num_modes) + + return self.__class__(data, copy=False, num_modes=num_modes) + + def _validate_keys(self, keys: Collection[str]) -> None: + super()._validate_keys(keys) + + num_modes = self.num_modes + + max_index = -1 + + for key in keys: + # 0. explicitly allow the empty key + if key == "": + continue + + # 1. validate overall key structure + if not re.fullmatch(MajoranaOp._OPERATION_REGEX, key): + raise QiskitNatureError(f"{key} is not a valid MajoranaOp label.") + + # 2. validate all indices against register length + for term in key.split(): + index = int(term[1:]) + if num_modes is None: + max_index = max(max_index, index) + elif index >= num_modes: + raise QiskitNatureError( + f"The index, {index}, from the label, {key}, exceeds the number of " + f"modes, {num_modes}." + ) + + if num_modes is None: + self.num_modes = max_index + 1 + + @classmethod + def _validate_polynomial_tensor_key(cls, keys: Collection[str]) -> None: + allowed_chars = {"_"} + + for key in keys: + if set(key) - allowed_chars: + raise QiskitNatureError( + f"The key {key} is invalid. PolynomialTensor keys may only consists of `_` " + "characters, for them to be expandable into a MajoranaOp." + ) + + @classmethod + def from_polynomial_tensor(cls, tensor: PolynomialTensor) -> MajoranaOp: + cls._validate_polynomial_tensor_key(tensor.keys()) + + data: dict[str, _TCoeff] = {} + + for key in tensor: + if key == "": + data[""] = tensor[key].item() + continue + + mat = tensor[key] + + empty_string_key = [""] * len(key) # label format for Majorana is just '_' + label_template = mat.label_template.format(*empty_string_key) + + for value, index in mat.coord_iter(): + data[label_template.format(*index)] = value + + num_modes = tensor.register_length + return cls(data, copy=False, num_modes=num_modes).chop() + + def __repr__(self) -> str: + data_str = f"{dict(self.items())}" + + return "MajoranaOp(" f"{data_str}, " f"num_modes={self.num_modes}, " ")" + + def __str__(self) -> str: + pre = "Majorana Operator\n" f"number modes={self.num_modes}, number terms={len(self)}\n" + ret = " " + "\n+ ".join( + [f"{coeff} * ( {label} )" if label else f"{coeff}" for label, coeff in self.items()] + ) + return pre + ret + + def terms(self) -> Iterator[tuple[list[tuple[str, int]], _TCoeff]]: + """Provides an iterator analogous to :meth:`items` but with the labels already split into + pairs of operation characters and indices. + + Yields: + A tuple with two items; the first one being a list of pairs of the form ('', int) + where the empty string is for compatibility with other :class:`SparseLabelOp` and + the integer corresponds to the mode index on which the operator gets applied; the second + item of the returned tuple is the coefficient of this term. + """ + for label in iter(self): + if not label: + yield ([], self[label]) + continue + # label.split() will return lbl = '_' for each term + # lbl[1:] corresponds to the index + terms = [("", int(lbl[1:])) for lbl in label.split()] + yield (terms, self[label]) + + @classmethod + def from_terms(cls, terms: Sequence[tuple[list[tuple[str, int]], _TCoeff]]) -> MajoranaOp: + data = {" ".join(f"_{index}" for _, index in label): value for label, value in terms} + return cls(data) + + @classmethod + def from_fermionic_op(cls, op: FermionicOp, *, simplify: bool = True) -> MajoranaOp: + """Constructs the operator from a :class:`~.FermionicOp`. + + Args: + op: the :class:`~.FermionicOp` to convert. + simplify: whether to index order and simplify the resulting operator. + + Returns: + The converted :class:`~.MajoranaOp`. + """ + data = defaultdict(complex) # type: dict[str, _TCoeff] + for label, coeff in op._data.items(): + terms = label.split() + for i in range(2 ** len(terms)): + majorana_label = "" + coeff_power = 0 + for j, term in enumerate(terms): + if majorana_label: + majorana_label += " " + odd_index = (i >> j) & 1 + index = 2 * int(term[2:]) + odd_index + if odd_index: + if term[0] == "-": + coeff_power += 1 + else: + coeff_power += 3 + majorana_label += f"_{index}" + new_coeff = 1j**coeff_power * coeff / (2 ** len(terms)) + if simplify: + trms = next(trm for trm, _ in MajoranaOp({majorana_label: new_coeff}).terms()) + majorana_label, new_coeff = FermionicOp._index_order(trms, new_coeff) + majorana_label, new_coeff = cls._simplify_label(majorana_label, new_coeff) + data[majorana_label] += new_coeff + return cls(data, num_modes=2 * op.num_spin_orbitals) + + def _permute_term( + self, term: list[tuple[str, int]], permutation: Sequence[int] + ) -> list[tuple[str, int]]: + return [(action, permutation[index]) for action, index in term] + + def compose(self, other: MajoranaOp, qargs=None, front: bool = False) -> MajoranaOp: + if not isinstance(other, MajoranaOp): + raise TypeError( + f"Unsupported operand type(s) for *: 'MajoranaOp' and '{type(other).__name__}'" + ) + + if front: + return self._tensor(self, other, offset=False) + else: + return self._tensor(other, self, offset=False) + + def tensor(self, other: MajoranaOp) -> MajoranaOp: + return self._tensor(self, other) + + def expand(self, other: MajoranaOp) -> MajoranaOp: + return self._tensor(other, self) + + @classmethod + def _tensor(cls, a: MajoranaOp, b: MajoranaOp, *, offset: bool = True) -> MajoranaOp: + shift = a.num_modes if offset else 0 + + new_data: dict[str, _TCoeff] = {} + for label1, cf1 in a.items(): + for terms2, cf2 in b.terms(): + new_label = f"{label1} {' '.join(f'_{i+shift}' for _, i in terms2)}".strip() + if new_label in new_data: + new_data[new_label] += cf1 * cf2 + else: + new_data[new_label] = cf1 * cf2 + + new_op = a._new_instance(new_data, other=b) + if offset: + new_op.num_modes = a.num_modes + b.num_modes + return new_op + + def transpose(self) -> MajoranaOp: + data = {} + + for label, coeff in self.items(): + data[" ".join(lbl for lbl in reversed(label.split()))] = coeff + + return self._new_instance(data) + + def index_order(self) -> MajoranaOp: + """Convert to the equivalent operator with the terms of each label ordered by index. + + Returns a new operator (the original operator is not modified). + + .. note:: + + You can use this method to achieve the most aggressive simplification. + :meth:`simplify` does *not* reorder the terms. For instance, using only :meth:`simplify` + will reduce ``_2 _0 _1 _0 _0`` to ``_2 _0 _1`` but cannot deduce this label to be + identical to ``_0 _1 _2``. + Calling this method will reorder the former label to + ``_0 _0 _0 _1 _2``, after which :meth:`simplify` will be able to correctly + collapse these two labels into one. + + Returns: + The index ordered operator. + """ + data = defaultdict(complex) # type: dict[str, _TCoeff] + for terms, coeff in self.terms(): + # index ordering is identical to FermionicOp, hence we call classmethod there: + label, coeff = FermionicOp._index_order(terms, coeff) + data[label] += coeff + + # after successful index ordering, we remove all zero coefficients + return self._new_instance( + { + label: coeff + for label, coeff in data.items() + if not np.isclose(_to_number(coeff), 0.0, atol=self.atol) + } + ) + + def is_hermitian(self, atol: float | None = None) -> bool: + """Checks whether the operator is hermitian. + + Args: + atol: Absolute numerical tolerance. The default behavior is to use ``self.atol``. + + Returns: + True if the operator is hermitian up to numerical tolerance, False otherwise. + + Raises: + ValueError: Operator contains parameters. + """ + if self.is_parameterized(): + raise ValueError("is_hermitian is not supported for operators containing parameters.") + atol = self.atol if atol is None else atol + diff = (self - self.adjoint()).simplify(atol=atol) + return all(np.isclose(coeff, 0.0, atol=atol) for coeff in diff.values()) + + def simplify(self, atol: float | None = None) -> MajoranaOp: + atol = self.atol if atol is None else atol + + data = defaultdict(complex) # type: dict[str, _TCoeff] + # TODO: use parallel_map to make this more efficient (?) (see FermionicOp) + for label, coeff in self.items(): + label, coeff = self._simplify_label(label, coeff) + data[label] += coeff + simplified_data = { + label: coeff + for label, coeff in data.items() + if not np.isclose(_to_number(coeff), 0.0, atol=atol) + } + return self._new_instance(simplified_data) + + @classmethod + def _simplify_label(cls, label: str, coeff: _TCoeff) -> tuple[str, _TCoeff]: + new_label_list = [] + for lbl in label.split()[::-1]: + index = int(lbl[1:]) + if index not in new_label_list: + new_label_list.append(index) + else: + if (len(new_label_list) - new_label_list.index(index)) % 2 == 0: + coeff *= -1 + new_label_list.remove(index) + new_label_list.reverse() + return " ".join(map(lambda index: f"_{index}", new_label_list)), coeff diff --git a/releasenotes/notes/add-majoranaop-1cbf9d4a1d4c264e.yaml b/releasenotes/notes/add-majoranaop-1cbf9d4a1d4c264e.yaml new file mode 100644 index 000000000..947070ddf --- /dev/null +++ b/releasenotes/notes/add-majoranaop-1cbf9d4a1d4c264e.yaml @@ -0,0 +1,20 @@ +--- +features: + - | + Adds a new operator class, :class:`~qiskit_nature.second_q.operators.MajoranaOp` + to handle operators that are sums of tensor products of Majorana fermion operators. + + Majorana operators use a string representation with underscore only, e.g. ``'_0 _1'`` + corresponds to :math:`\gamma_0 \gamma_1` where there are twice the number of spin orbitals + operators satisfying :math:`\{\gamma_i,\gamma_j\} = 2 \delta_{ij}`. + + Methods of :class:`~qiskit_nature.second_q.operators.MajoranaOp` follow the same API as for + :class:`~qiskit_nature.second_q.operators.FermionicOp` except for normal ordering, which is + unnecessary. A Majorana operator can be created from a Fermionic operator using the + :meth:`~qiskit_nature.second_q.operators.MajoranaOp.from_fermionic_op` class method. E.g.: + + .. code-block:: python + + from qiskit_nature.second_q.operators import FermionicOp, MajoranaOp + f_op = FermionicOp({"+_0 -_1": 1}, num_spin_orbitals=2) + m_op = MajoranaOp.from_fermionic_op(f_op) diff --git a/test/second_q/operators/test_majorana_op.py b/test/second_q/operators/test_majorana_op.py new file mode 100644 index 000000000..03654aade --- /dev/null +++ b/test/second_q/operators/test_majorana_op.py @@ -0,0 +1,737 @@ +# This code is part of a Qiskit project. +# +# (C) Copyright IBM 2023. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +"""Test for MajoranaOp""" + +import unittest + +from test import QiskitNatureTestCase + +import numpy as np +from ddt import data, ddt, unpack +from qiskit.circuit import Parameter + +from qiskit_nature.exceptions import QiskitNatureError +from qiskit_nature.second_q.operators import MajoranaOp, FermionicOp, PolynomialTensor +from qiskit_nature.second_q.operators.commutators import anti_commutator +import qiskit_nature.optionals as _optionals + + +@ddt +class TestMajoranaOp(QiskitNatureTestCase): + """MajoranaOp tests.""" + + a = Parameter("a") + b = Parameter("b") + + op1 = MajoranaOp({"_0 _1": 1}) + op2 = MajoranaOp({"_1 _0": 2}) + op3 = MajoranaOp({"_0 _1": 1, "_1 _0": 2}) + op4 = MajoranaOp({"_0 _1": a}) + + def test_anticommutation_relation(self): + """Test anticommutation relation""" + mop1 = MajoranaOp({"_0": 1}) + mop2 = MajoranaOp({"_0": 1}) + + self.assertTrue(anti_commutator(mop1, mop2).equiv(MajoranaOp({"": 2}))) + + def test_neg(self): + """Test __neg__""" + maj_op = -self.op1 + targ = MajoranaOp({"_0 _1": -1}, num_modes=2) + self.assertEqual(maj_op, targ) + + maj_op = -self.op4 + targ = MajoranaOp({"_0 _1": -self.a}) + self.assertEqual(maj_op, targ) + + def test_mul(self): + """Test __mul__, and __rmul__""" + with self.subTest("rightmul"): + maj_op = self.op1 * 2 + targ = MajoranaOp({"_0 _1": 2}, num_modes=2) + self.assertEqual(maj_op, targ) + + maj_op = self.op1 * self.a + targ = MajoranaOp({"_0 _1": self.a}) + self.assertEqual(maj_op, targ) + + with self.subTest("left mul"): + maj_op = (2 + 1j) * self.op3 + targ = MajoranaOp({"_0 _1": (2 + 1j), "_1 _0": (4 + 2j)}, num_modes=2) + self.assertEqual(maj_op, targ) + + def test_div(self): + """Test __truediv__""" + maj_op = self.op1 / 2 + targ = MajoranaOp({"_0 _1": 0.5}, num_modes=2) + self.assertEqual(maj_op, targ) + + maj_op = self.op1 / self.a + targ = MajoranaOp({"_0 _1": 1 / self.a}) + self.assertEqual(maj_op, targ) + + def test_add(self): + """Test __add__""" + maj_op = self.op1 + self.op2 + targ = self.op3 + self.assertEqual(maj_op, targ) + + maj_op = self.op1 + self.op4 + targ = MajoranaOp({"_0 _1": 1 + self.a}) + self.assertEqual(maj_op, targ) + + with self.subTest("sum"): + maj_op = sum(MajoranaOp({label: 1}) for label in ["_0", "_1", "_2 _3"]) + targ = MajoranaOp({"_0": 1, "_1": 1, "_2 _3": 1}) + self.assertEqual(maj_op, targ) + + def test_sub(self): + """Test __sub__""" + maj_op = self.op3 - self.op2 + targ = MajoranaOp({"_0 _1": 1, "_1 _0": 0}, num_modes=2) + self.assertEqual(maj_op, targ) + + maj_op = self.op4 - self.op1 + targ = MajoranaOp({"_0 _1": self.a - 1}) + self.assertEqual(maj_op, targ) + + def test_compose(self): + """Test operator composition""" + with self.subTest("single compose"): + maj_op = MajoranaOp({"_0 _2": 1}, num_modes=4) @ MajoranaOp({"_1": 1}, num_modes=4) + targ = MajoranaOp({"_0 _2 _1": 1}, num_modes=4) + self.assertEqual(maj_op, targ) + + with self.subTest("single compose with parameters"): + maj_op = MajoranaOp({"_0 _2": self.a}) @ MajoranaOp({"_1": 1}) + targ = MajoranaOp({"_0 _2 _1": self.a}) + self.assertEqual(maj_op, targ) + + with self.subTest("multi compose"): + maj_op = MajoranaOp({"_0 _2 _3": 1, "_1 _2 _3": 1}, num_modes=4) @ MajoranaOp( + {"": 1, "_1 _3": 1}, num_modes=4 + ) + maj_op = maj_op.simplify() + targ = MajoranaOp( + {"_0 _2 _3": 1, "_1 _2 _3": 1, "_0 _2 _1": -1, "_2": 1}, + num_modes=4, + ) + self.assertEqual(maj_op, targ) + + with self.subTest("multi compose with parameters"): + maj_op = MajoranaOp({"_0 _2 _3": self.a, "_1 _0 _3": 1}) @ MajoranaOp( + {"": 1, "_0 _3": self.b} + ) + maj_op = maj_op.simplify() + targ = MajoranaOp( + { + "_0 _2 _3": self.a, + "_1 _0 _3": 1, + "_2": self.a * self.b, + "_1": -self.b, + } + ) + self.assertEqual(maj_op, targ) + + def test_tensor(self): + """Test tensor multiplication""" + maj_op = self.op1.tensor(self.op2) + targ = MajoranaOp({"_0 _1 _3 _2": 2}, num_modes=4) + self.assertEqual(maj_op, targ) + + maj_op = self.op4.tensor(self.op2) + targ = MajoranaOp({"_0 _1 _3 _2": 2 * self.a}) + self.assertEqual(maj_op, targ) + + def test_expand(self): + """Test reversed tensor multiplication""" + maj_op = self.op1.expand(self.op2) + targ = MajoranaOp({"_1 _0 _2 _3": 2}, num_modes=4) + self.assertEqual(maj_op, targ) + + maj_op = self.op4.expand(self.op2) + targ = MajoranaOp({"_1 _0 _2 _3": 2 * self.a}) + self.assertEqual(maj_op, targ) + + def test_pow(self): + """Test __pow__""" + with self.subTest("square"): + maj_op = MajoranaOp({"_0 _1 _2": 3, "_1 _0 _3": 1}, num_modes=4) ** 2 + maj_op = maj_op.simplify() + targ = MajoranaOp({"": -10, "_2 _3": 3, "_3 _2": 3}, num_modes=4) + self.assertEqual(maj_op, targ) + + with self.subTest("3rd power"): + maj_op = (3 * MajoranaOp.one()) ** 3 + targ = 27 * MajoranaOp.one() + self.assertEqual(maj_op, targ) + + with self.subTest("0th power"): + maj_op = MajoranaOp({"_0 _1 _2": 3, "_1 _0 _3": 1}, num_modes=4) ** 0 + maj_op = maj_op.simplify() + targ = MajoranaOp.one() + self.assertEqual(maj_op, targ) + + with self.subTest("square with parameters"): + maj_op = MajoranaOp({"_0 _1 _2": self.a, "_1 _0 _3": 1}, num_modes=4) ** 2 + maj_op = maj_op.simplify() + square = (2 * self.a.log()).exp() # qiskit.circuit.Parameter has no pow method + targ = MajoranaOp({"": -1 - square, "_2 _3": self.a, "_3 _2": self.a}, num_modes=4) + self.assertEqual(maj_op, targ) + + def test_adjoint(self): + """Test adjoint method""" + maj_op = MajoranaOp( + {"": 1j, "_0 _1 _2": 3, "_0 _1 _3": 1, "_1 _3": 2 + 4j}, num_modes=6 + ).adjoint() + targ = MajoranaOp({"": -1j, "_2 _1 _0": 3, "_3 _1 _0": 1, "_3 _1": 2 - 4j}, num_modes=6) + self.assertEqual(maj_op, targ) + + maj_op = MajoranaOp( + {"": 1j, "_0 _1 _2": 3, "_0 _1 _3": self.a, "_1 _3": 2 + 4j}, num_modes=6 + ).adjoint() + targ = MajoranaOp( + {"": -1j, "_2 _1 _0": 3, "_3 _1 _0": self.a.conjugate(), "_3 _1": 2 - 4j}, + num_modes=6, + ) + self.assertEqual(maj_op, targ) + + def test_simplify(self): + """Test simplify""" + with self.subTest("simplify integer"): + maj_op = MajoranaOp({"_0 _1": 1, "_0 _1 _1 _1": 1}, num_modes=2) + simplified_op = maj_op.simplify() + targ = MajoranaOp({"_0 _1": 2}, num_modes=2) + self.assertEqual(simplified_op, targ) + + with self.subTest("simplify complex"): + maj_op = MajoranaOp({"_0 _1": 1, "_0 _1 _0 _0": 1j}, num_modes=2) + simplified_op = maj_op.simplify() + targ = MajoranaOp({"_0 _1": 1 + 1j}, num_modes=2) + self.assertEqual(simplified_op, targ) + + with self.subTest("simplify doesn't reorder"): + maj_op = MajoranaOp({"_1 _2": 1 + 0j}, num_modes=4) + simplified_op = maj_op.simplify() + self.assertEqual(simplified_op, maj_op) + + maj_op = MajoranaOp({"_3 _0": 1 + 0j}, num_modes=4) + simplified_op = maj_op.simplify() + self.assertEqual(simplified_op, maj_op) + + with self.subTest("simplify zero"): + maj_op = self.op1 - self.op1 + simplified_op = maj_op.simplify() + targ = MajoranaOp.zero() + self.assertEqual(simplified_op, targ) + + with self.subTest("simplify parameters"): + maj_op = MajoranaOp({"_0 _1": self.a, "_0 _1 _0 _0": 1j}) + simplified_op = maj_op.simplify() + targ = MajoranaOp({"_0 _1": self.a + 1j}) + self.assertEqual(simplified_op, targ) + + with self.subTest("simplify + index order"): + orig = MajoranaOp({"_3 _1 _0 _1": 1, "_0 _3": 2}) + maj_op = orig.simplify().index_order() + targ = MajoranaOp({"_0 _3": 3}) + self.assertEqual(maj_op, targ) + + def test_hermiticity(self): + """test is_hermitian""" + with self.subTest("operator hermitian"): + maj_op = ( + 1j * MajoranaOp({"_0 _1 _2 _3": 1}, num_modes=4) + - 1j * MajoranaOp({"_3 _2 _1 _0": 1}, num_modes=4) + + MajoranaOp({"_0 _1": 1}, num_modes=4) + + MajoranaOp({"_1 _0": 1}, num_modes=4) + ) + self.assertTrue(maj_op.is_hermitian()) + + with self.subTest("operator not hermitian"): + maj_op = ( + 1j * MajoranaOp({"_0 _1 _2 _3": 1}, num_modes=4) + + 1j * MajoranaOp({"_3 _2 _1 _0": 1}, num_modes=4) + + MajoranaOp({"_0 _1": 1}, num_modes=4) + - MajoranaOp({"_1 _0": 1}, num_modes=4) + ) + self.assertFalse(maj_op.is_hermitian()) + + with self.subTest("test passing atol"): + maj_op = MajoranaOp({"_0 _1": 1}, num_modes=4) + (1 + 1e-7) * MajoranaOp( + {"_1 _0": 1}, num_modes=4 + ) + self.assertFalse(maj_op.is_hermitian()) + self.assertFalse(maj_op.is_hermitian(atol=1e-8)) + self.assertTrue(maj_op.is_hermitian(atol=1e-6)) + + with self.subTest("parameters"): + maj_op = MajoranaOp({"_0": self.a}) + with self.assertRaisesRegex(ValueError, "parameter"): + _ = maj_op.is_hermitian() + + def test_equiv(self): + """test equiv""" + prev_atol = MajoranaOp.atol + prev_rtol = MajoranaOp.rtol + op3 = self.op1 + (1 + 0.00005) * self.op2 + self.assertFalse(op3.equiv(self.op3)) + MajoranaOp.atol = 1e-4 + MajoranaOp.rtol = 1e-4 + self.assertTrue(op3.equiv(self.op3)) + MajoranaOp.atol = prev_atol + MajoranaOp.rtol = prev_rtol + + def test_index_order(self): + """Test index_order method""" + ordered_op = MajoranaOp({"_0 _1": 1}) + reverse_op = MajoranaOp({"_1 _0": -1}) + maj_op = ordered_op.index_order() + self.assertEqual(maj_op, ordered_op) + maj_op = reverse_op.index_order() + self.assertEqual(maj_op, ordered_op) + + def test_index_order_simplify_example(self): + """Test that _2 _0 _1 _0 _0 equals _0 _1 _2 only after index_order""" + op = MajoranaOp({"_2 _0 _1 _0 _0": 1}) + op1 = op.simplify() + op2 = op.index_order().simplify() + self.assertEqual(op1, MajoranaOp({"_2 _0 _1": 1})) + self.assertNotEqual(op1, MajoranaOp({"_0 _1 _2": 1})) + self.assertEqual(op2, MajoranaOp({"_0 _1 _2": 1})) + + def test_induced_norm(self): + """Test induced norm.""" + op1 = 3 * MajoranaOp({"_0": 1}, num_modes=2) + 4j * MajoranaOp({"_1": 1}, num_modes=2) + op2 = 3 * MajoranaOp({"_0": 1}, num_modes=2) + 4j * MajoranaOp({"_0": 1}, num_modes=2) + self.assertAlmostEqual(op1.induced_norm(), 7.0) + self.assertAlmostEqual(op1.induced_norm(2), 5.0) + self.assertAlmostEqual(op2.induced_norm(), 5.0) + self.assertAlmostEqual(op2.induced_norm(2), 5.0) + + @unpack + @data( + ("", 1, True), # empty string + ("_0", 1, True), # single term + ("_0 _1", 2, True), # multiple terms + ("_0 _3", 4, True), # multiple orbitals + ("_1 _1", 2, True), # identical terms + ("_10", 11, True), # multiple digits + (" _0", 1, False), # leading whitespace + ("_0 ", 1, False), # trailing whitespace + ("_0 _0", 1, False), # multiple separating spaces + ("_0a", 1, False), # incorrect term pattern + ("_a0", 1, False), # incorrect term pattern + ("0_", 1, False), # incorrect term pattern + ("+_0", 1, False), # incorrect fermionic pattern + ("something", 1, False), # incorrect term pattern + ("_1", 2, True), # 1 spin orbital takes two registers + ("_2", 2, False), # register length is too short + ) + def test_validate(self, key: str, length: int, valid: bool): + """Test key validation.""" + if valid: + _ = MajoranaOp({key: 1.0}, num_modes=length) + else: + with self.assertRaises(QiskitNatureError): + _ = MajoranaOp({key: 1.0}, num_modes=length) + + def test_no_copy(self): + """Test constructor with copy=False""" + test_dict = {"_0 _1": 1} + op = MajoranaOp(test_dict, copy=False) + test_dict["_0 _1"] = 2 + self.assertEqual(op, MajoranaOp({"_0 _1": 2})) + + def test_no_validate(self): + """Test skipping validation""" + with self.subTest("no validation"): + op = MajoranaOp({"_0 _1": 1}, num_modes=2, validate=False) + self.assertEqual(op, MajoranaOp({"_0 _1": 1})) + + with self.subTest("no validation no num_modes"): + op = MajoranaOp({"_0 _1": 1}, validate=False) + self.assertEqual(op.num_modes, None) + + with self.subTest("no validation with wrong label"): + op = MajoranaOp({"test": 1}, validate=False) + with self.assertRaises(ValueError): + list(op.terms()) + + with self.subTest("no validation with wrong num_modes"): + op = MajoranaOp({"_1 _2": 1}, num_modes=2, validate=False) + op2 = MajoranaOp.from_terms(op.terms()) + self.assertEqual(op2.num_modes, 3) + + def test_from_polynomial_tensor(self): + """Test from PolynomialTensor construction""" + + with self.subTest("dense tensor"): + p_t = PolynomialTensor( + { + "_": np.arange(1, 3), + "__": np.arange(1, 5).reshape((2, 2)), + } + ) + op = MajoranaOp.from_polynomial_tensor(p_t) + + expected = MajoranaOp( + { + "_0": 1, + "_1": 2, + "_0 _0": 1, + "_0 _1": 2, + "_1 _0": 3, + "_1 _1": 4, + }, + num_modes=2, + ) + + self.assertEqual(op, expected) + + if _optionals.HAS_SPARSE: + import sparse as sp # pyright: ignore # pylint: disable=import-error + + with self.subTest("sparse tensor"): + r_l = 2 + p_t = PolynomialTensor( + { + "__": sp.as_coo({(0, 0): 1, (1, 0): 2}, shape=(r_l, r_l)), + "____": sp.as_coo( + {(0, 0, 0, 1): 1, (1, 0, 1, 1): 2}, shape=(r_l, r_l, r_l, r_l) + ), + } + ) + op = MajoranaOp.from_polynomial_tensor(p_t) + + expected = MajoranaOp( + { + "_0 _0": 1, + "_1 _0": 2, + "_0 _0 _0 _1": 1, + "_1 _0 _1 _1": 2, + }, + num_modes=r_l, + ) + + self.assertEqual(op, expected) + + with self.subTest("compose operation order"): + r_l = 2 + p_t = PolynomialTensor( + { + "__": np.arange(1, 5).reshape((r_l, r_l)), + "____": np.arange(1, 17).reshape((r_l, r_l, r_l, r_l)), + } + ) + op = MajoranaOp.from_polynomial_tensor(p_t) + + a = op @ op + b = MajoranaOp.from_polynomial_tensor(p_t @ p_t) + self.assertEqual(a, b) + + with self.subTest("tensor operation order"): + r_l = 2 + p_t = PolynomialTensor( + { + "__": np.arange(1, 5).reshape((r_l, r_l)), + "____": np.arange(1, 17).reshape((r_l, r_l, r_l, r_l)), + } + ) + op = MajoranaOp.from_polynomial_tensor(p_t) + + self.assertEqual(op ^ op, MajoranaOp.from_polynomial_tensor(p_t ^ p_t)) + + def test_no_num_modes(self): + """Test operators with automatic register length""" + op0 = MajoranaOp({"": 1}) + op1 = MajoranaOp({"_0 _1": 1}) + op2 = MajoranaOp({"_0 _1 _2": 2}) + + with self.subTest("Inferred register length"): + self.assertEqual(op0.num_modes, 0) + self.assertEqual(op1.num_modes, 2) + self.assertEqual(op2.num_modes, 3) + + with self.subTest("Mathematical operations"): + self.assertEqual((op0 + op2).num_modes, 3) + self.assertEqual((op1 + op2).num_modes, 3) + self.assertEqual((op0 @ op2).num_modes, 3) + self.assertEqual((op1 @ op2).num_modes, 3) + self.assertEqual((op1 ^ op2).num_modes, 5) + + with self.subTest("Equality"): + op3 = MajoranaOp({"_0 _1": 1}, num_modes=6) + self.assertEqual(op1, op3) + self.assertTrue(op1.equiv(1.000001 * op3)) + + def test_terms(self): + """Test terms generator.""" + op = MajoranaOp( + { + "_0": 1, + "_0 _1": 2, + "_1 _2 _3": 2, + } + ) + + terms = [([("", 0)], 1), ([("", 0), ("", 1)], 2), ([("", 1), ("", 2), ("", 3)], 2)] + + with self.subTest("terms"): + self.assertEqual(list(op.terms()), terms) + + with self.subTest("from_terms"): + self.assertEqual(MajoranaOp.from_terms(terms), op) + + def test_permute_indices(self): + """Test index permutation method.""" + op = MajoranaOp( + { + "_0 _1": 1, + "_1 _2": 2, + }, + num_modes=4, + ) + + with self.subTest("wrong permutation length"): + with self.assertRaises(ValueError): + _ = op.permute_indices([1, 0]) + + with self.subTest("actual permutation"): + permuted_op = op.permute_indices([2, 1, 3, 0]) + + self.assertEqual(permuted_op, MajoranaOp({"_2 _1": 1, "_1 _3": 2}, num_modes=4)) + + def test_reg_len_with_skipped_key_validation(self): + """Test the behavior of `register_length` after key validation was skipped.""" + new_op = MajoranaOp({"_0 _1": 1}, validate=False) + self.assertIsNone(new_op.num_modes) + self.assertEqual(new_op.register_length, 2) + + def test_from_fermionic_op(self): + """Test conversion from FermionicOp.""" + original_ops = [ + FermionicOp({"+_0 -_1": 1}, num_spin_orbitals=2), + FermionicOp({"+_0 -_0 +_1 -_1": 2}, num_spin_orbitals=2), + FermionicOp({"+_0 +_1 -_2 -_1": 3}, num_spin_orbitals=3), + ] + expected_ops_no_simp_no_order = [ + MajoranaOp( + {"_0 _2": 0.25, "_0 _3": 0.25j, "_1 _2": -0.25j, "_1 _3": 0.25}, num_modes=4 + ), + 2 + * MajoranaOp( + { + "_0 _0 _2 _2": 1 / 16, + "_0 _1 _2 _2": 1j / 16, + "_1 _0 _2 _2": -1j / 16, + "_1 _1 _2 _2": 1 / 16, + # + "_0 _0 _2 _3": 1j / 16, + "_0 _1 _2 _3": -1 / 16, + "_1 _0 _2 _3": 1 / 16, + "_1 _1 _2 _3": 1j / 16, + # + "_0 _0 _3 _2": -1j / 16, + "_0 _1 _3 _2": 1 / 16, + "_1 _0 _3 _2": -1 / 16, + "_1 _1 _3 _2": -1j / 16, + # + "_0 _0 _3 _3": 1 / 16, + "_0 _1 _3 _3": 1j / 16, + "_1 _0 _3 _3": -1j / 16, + "_1 _1 _3 _3": 1 / 16, + }, + num_modes=4, + ), + 3 + * MajoranaOp( + { + "_0 _2 _4 _2": 1 / 16, + "_0 _3 _4 _2": -1j / 16, + "_1 _2 _4 _2": -1j / 16, + "_1 _3 _4 _2": -1 / 16, + # + "_0 _2 _4 _3": 1j / 16, + "_0 _3 _4 _3": 1 / 16, + "_1 _2 _4 _3": 1 / 16, + "_1 _3 _4 _3": -1j / 16, + # + "_0 _2 _5 _2": 1j / 16, + "_0 _3 _5 _2": 1 / 16, + "_1 _2 _5 _2": 1 / 16, + "_1 _3 _5 _2": -1j / 16, + # + "_0 _2 _5 _3": -1 / 16, + "_0 _3 _5 _3": 1j / 16, + "_1 _2 _5 _3": 1j / 16, + "_1 _3 _5 _3": 1 / 16, + }, + num_modes=6, + ), + ] + expected_ops_no_simplify = [ + MajoranaOp( + {"_0 _2": 0.25, "_0 _3": 0.25j, "_1 _2": -0.25j, "_1 _3": 0.25}, num_modes=4 + ), + 2 + * MajoranaOp( + { + "_0 _0 _2 _2": 1 / 16, + "_0 _1 _2 _2": 1j / 8, + "_1 _1 _2 _2": 1 / 16, + "_0 _0 _2 _3": 1j / 8, + "_0 _1 _2 _3": -1 / 4, + "_1 _1 _2 _3": 1j / 8, + "_0 _0 _3 _3": 1 / 16, + "_0 _1 _3 _3": 1j / 8, + "_1 _1 _3 _3": 1 / 16, + }, + num_modes=4, + ), + 3 + * MajoranaOp( + { + "_0 _2 _2 _4": -1 / 16, + "_0 _2 _3 _4": -1j / 8, + "_1 _2 _2 _4": 1j / 16, + "_1 _2 _3 _4": -1 / 8, + "_0 _3 _3 _4": -1 / 16, + "_1 _3 _3 _4": 1j / 16, + "_0 _2 _2 _5": -1j / 16, + "_0 _2 _3 _5": 1 / 8, + "_1 _2 _2 _5": -1 / 16, + "_1 _2 _3 _5": -1j / 8, + "_0 _3 _3 _5": -1j / 16, + "_1 _3 _3 _5": -1 / 16, + }, + num_modes=6, + ), + ] + expected_ops_no_order = [ + MajoranaOp( + {"_0 _2": 0.25, "_0 _3": 0.25j, "_1 _2": -0.25j, "_1 _3": 0.25}, num_modes=4 + ), + 2 + * MajoranaOp( + { + "": 1 / 4, + "_0 _1": 1j / 8, + "_1 _0": -1j / 8, + "_2 _3": 1j / 8, + "_0 _1 _2 _3": -1 / 16, + "_1 _0 _2 _3": 1 / 16, + "_3 _2": -1j / 8, + "_0 _1 _3 _2": 1 / 16, + "_1 _0 _3 _2": -1 / 16, + }, + num_modes=4, + ), + 3 + * MajoranaOp( + { + "_0 _4": -1 / 8, + "_0 _5": -1j / 8, + "_1 _4": 1j / 8, + "_1 _5": -1 / 8, + # + "_0 _2 _4 _3": 1j / 16, + "_0 _2 _5 _3": -1 / 16, + "_0 _3 _4 _2": -1j / 16, + "_0 _3 _5 _2": 1 / 16, + "_1 _2 _4 _3": 1 / 16, + "_1 _2 _5 _3": 1j / 16, + "_1 _3 _4 _2": -1 / 16, + "_1 _3 _5 _2": -1j / 16, + }, + num_modes=6, + ), + ] + expected_ops = [ + MajoranaOp( + {"_0 _2": 0.25, "_0 _3": 0.25j, "_1 _2": -0.25j, "_1 _3": 0.25}, num_modes=4 + ), + 2 + * MajoranaOp( + {"": 1 / 4, "_0 _1": 1j / 4, "_2 _3": 1j / 4, "_0 _1 _2 _3": -1 / 4}, + num_modes=4, + ), + 3 + * MajoranaOp( + { + "_0 _4": -1 / 8, + "_0 _2 _3 _4": -1j / 8, + "_1 _4": 1j / 8, + "_1 _2 _3 _4": -1 / 8, + "_0 _5": -1j / 8, + "_0 _2 _3 _5": 1 / 8, + "_1 _5": -1 / 8, + "_1 _2 _3 _5": -1j / 8, + }, + num_modes=6, + ), + ] + with self.subTest("conversion"): + for f_op, e_op in zip(original_ops, expected_ops): + t_op = MajoranaOp.from_fermionic_op(f_op) + self.assertEqual(t_op, e_op) + + with self.subTest("sum of operators"): + f_op = original_ops[0] + original_ops[1] + e_op = expected_ops[0] + expected_ops[1] + t_op = MajoranaOp.from_fermionic_op(f_op) + self.assertEqual(t_op, e_op) + + with self.subTest("composed operators"): + f_op = original_ops[0] @ original_ops[1] + e_op = expected_ops[0] @ expected_ops[1] + t_op = MajoranaOp.from_fermionic_op(f_op) + e_op_simplified = e_op.index_order().simplify() + t_op_simplified = t_op.index_order().simplify() + self.assertEqual(t_op_simplified, e_op_simplified) + + with self.subTest("tensored operators"): + f_op = original_ops[0] ^ original_ops[1] + e_op = expected_ops[0] ^ expected_ops[1] + t_op = MajoranaOp.from_fermionic_op(f_op) + self.assertEqual(t_op, e_op) + + with self.subTest("no simplify"): + for f_op, e_op in zip(original_ops, expected_ops_no_simplify): + t_op = MajoranaOp.from_fermionic_op(f_op, simplify=False) + t_op = t_op.index_order() + self.assertEqual(t_op, e_op) + with self.subTest("no order"): + for f_op, e_op in zip(original_ops, expected_ops_no_order): + t_op = MajoranaOp.from_fermionic_op(f_op, simplify=False) + t_op = t_op.simplify() + self.assertEqual(t_op, e_op) + + with self.subTest("no simplify no order"): + for f_op, e_op in zip(original_ops, expected_ops_no_simp_no_order): + t_op = MajoranaOp.from_fermionic_op(f_op, simplify=False) + self.assertEqual(t_op, e_op) + + def test_index_ordering_commutes(self): + """Test that index ordering before vs after conversion from FermionicOp to MajoranaOp + yields same result.""" + fop = FermionicOp({"+_2 -_0 +_1": 1.0}) + self.assertFalse(fop.equiv(fop.index_order())) + mop1 = MajoranaOp.from_fermionic_op(fop).index_order() + mop2 = MajoranaOp.from_fermionic_op(fop.index_order()) + self.assertTrue(mop1.equiv(mop2)) + + +if __name__ == "__main__": + unittest.main()