From 7d1134d0a2b2d022f1272e2a7901495b959f9b39 Mon Sep 17 00:00:00 2001 From: grossardt <99898527+grossardt@users.noreply.github.com> Date: Sun, 12 Nov 2023 00:29:26 +0100 Subject: [PATCH 01/13] Add MajoranaOp class --- .pylintdict | 4 + qiskit_nature/second_q/operators/__init__.py | 3 + .../second_q/operators/fermionic_op.py | 5 +- .../second_q/operators/majorana_op.py | 508 +++++++++++++ .../add-majoranaop-1cbf9d4a1d4c264e.yaml | 25 + test/second_q/operators/test_majorana_op.py | 693 ++++++++++++++++++ 6 files changed, 1236 insertions(+), 2 deletions(-) create mode 100644 qiskit_nature/second_q/operators/majorana_op.py create mode 100644 releasenotes/notes/add-majoranaop-1cbf9d4a1d4c264e.yaml create mode 100644 test/second_q/operators/test_majorana_op.py diff --git a/.pylintdict b/.pylintdict index d5213ea68..5d74edd8c 100644 --- a/.pylintdict +++ b/.pylintdict @@ -78,6 +78,7 @@ chkfile cholesky chuang ci +classmethod clbit clbits clifford @@ -300,6 +301,7 @@ kwargs kwds labelled langle +lbl lbrace lda ldots @@ -325,6 +327,7 @@ lvert lysine macos majorana +majoranaop makefile matmul matplotlib @@ -450,6 +453,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 d2e508a5a..8211f4024 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+") @@ -460,7 +459,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 @@ -501,6 +501,7 @@ def simplify(self, atol: float | None = None) -> FermionicOp: data = defaultdict(complex) # type: dict[str, _TCoeff] # TODO: use parallel_map to make this more efficient (?) + # (if this is done, apply equally to MajoranaOp.simplify()) for label, coeff in self.items(): label, coeff = self._simplify_label(label, coeff) data[label] += 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..4dd035554 --- /dev/null +++ b/qiskit_nature/second_q/operators/majorana_op.py @@ -0,0 +1,508 @@ +# 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. + +"""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 + creation/annihilation operator is applied. The value of :code:`index` is bound by twice the + number of spin orbitals (``num_spin_orbitals``) of the operator (Note: since Python indices are + 0-based, the maximum value an index can take is given by :code:`2 * 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_spin_orbitals=2, + ) + + 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_spin_orbitals=2, + 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** + + The default way to construct a ``MajoranaOp`` is 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 every term of the ``FermionicOp`` will result in :math:`2^n` terms in the + ``MajoranaOp``, where :math:`n` is the number of fermionic modes in the term. 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. + + .. note:: + + When creating a ``MajoranaOp`` from a ``PolynomialTensor`` using + :meth:`from_polynomial_tensor`, the underscore character :code:`_` is the only allowed + character in the keys of the ``PolynomialTensor``. + + **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_spin_orbitals=2) + MajoranaOp({"_0": 1}, num_spin_orbitals=2) + + Sum + + .. code-block:: python + + sum(MajoranaOp({label: 1}, num_spin_orbitals=3) for label in ["_0", "_1", "_2 _3"]) + + Scalar multiplication + + .. code-block:: python + + 0.5 * MajoranaOp({"_1": 1}, num_spin_orbitals=2) + + Operator multiplication + + .. code-block:: python + + op1 = MajoranaOp({"_0 _1": 1}, num_spin_orbitals=2) + op2 = MajoranaOp({"_0 _1 _2": 1}, num_spin_orbitals=2) + print(op1 @ op2) + + Tensor multiplication + + .. code-block:: python + + op = MajoranaOp({"_0 _1": 1}, num_spin_orbitals=2) + print(op ^ op) + + Adjoint + + .. code-block:: python + + MajoranaOp({"_0 _1": 1j}, num_spin_orbitals=2).adjoint() + + .. note:: + + Since Majorana generators 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 complex conjugated. + + **Iteration** + + Instances of ``MajoranaOp`` are iterable. Iterating a ``MajoranaOp`` yields (term, coefficient) + pairs describing the terms contained in the operator. + + Attributes: + num_spin_orbitals (int | None): the number of spin orbitals 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 spin orbitals + of any of the involved operators. + + .. 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_spin_orbitals: int | None = None, + *, + copy: bool = True, + validate: bool = True, + ) -> None: + """ + Args: + data: the operator data, mapping string-based keys to numerical values. + num_spin_orbitals: the number of spin orbitals 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_spin_orbitals = num_spin_orbitals + # if num_spin_orbitals is None, it is set during _validate_keys + super().__init__(data, copy=copy, validate=validate) + + @property + def register_length(self) -> int: + if self.num_spin_orbitals is None: + max_index = max(int(term[1:]) for key in self._data for term in key.split()) + if max_index % 2 == 0: + max_index += 1 + return max_index + 1 + + return 2 * self.num_spin_orbitals + + def _new_instance( + self, data: Mapping[str, _TCoeff], *, other: MajoranaOp | None = None + ) -> MajoranaOp: + num_so = self.num_spin_orbitals + if other is not None: + other_num_so = other.num_spin_orbitals + if num_so is None: + num_so = other_num_so + elif other_num_so is not None: + num_so = max(num_so, other_num_so) + + return self.__class__(data, copy=False, num_spin_orbitals=num_so) + + def _validate_keys(self, keys: Collection[str]) -> None: + super()._validate_keys(keys) + + num_so = self.num_spin_orbitals + + 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_so is None: + if index > max_index: + max_index = index + elif index >= 2 * num_so: + raise QiskitNatureError( + f"The index, {index}, from the label, {key}, exceeds twice the number of " + f"spin orbitals, {num_so}." + ) + + if num_so is None: + self.num_spin_orbitals = (max_index + 1 if max_index % 2 else max_index + 2) // 2 + + @classmethod + def _validate_polynomial_tensor_key(cls, keys: Collection[str]) -> None: + # PolynomialTensor keys cannot be built from empty string, + # hence we choose _ to be the only allowed character + 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 = ["" for _ in 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_so = (tensor.register_length + 1) // 2 + return cls(data, copy=False, num_spin_orbitals=num_so).chop() + + def __repr__(self) -> str: + data_str = f"{dict(self.items())}" + + return "MajoranaOp(" f"{data_str}, " f"num_spin_orbitals={self.num_spin_orbitals}, " ")" + + def __str__(self) -> str: + pre = ( + "Majorana Operator\n" + f"number spin orbitals={self.num_spin_orbitals}, 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 (char, int) + where char is always an empty string (for compatibility with other SparseLabelOps) 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, order: bool = True + ) -> MajoranaOp: + """Constructs the operator from a :class:`~.FermionicOp`. + + Args: + op: the :class:`~.FermionicOp` to convert. + simplify: whether to simplify the resulting operator. + order: whether to perform index ordering on 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 order: + trms = next(trm for trm, _ in MajoranaOp({majorana_label: new_coeff}).terms()) + majorana_label, new_coeff = FermionicOp._index_order(trms, new_coeff) + if simplify: + majorana_label, new_coeff = cls._simplify_label(majorana_label, new_coeff) + data[majorana_label] += new_coeff + return cls(data, num_spin_orbitals=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 = 2 * a.num_spin_orbitals 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_spin_orbitals = a.num_spin_orbitals + b.num_spin_orbitals + 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 and, thus, cannot deduce + ``_0 _1 _2`` and ``_2 _0 _1 _0 _0`` to be identical labels. + Calling this method will reorder the latter 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] + 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..48615b609 --- /dev/null +++ b/releasenotes/notes/add-majoranaop-1cbf9d4a1d4c264e.yaml @@ -0,0 +1,25 @@ +--- +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. + This is needed for the implementation of the Ternary Tree Mapper, but might be useful + later on for other purposes as well. + + 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` are the same 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. +other: + - | + The hidden method :meth:`~qiskit_nature.second_q.operators.FermionicOp._index_order` + has been converted to a class method to be able to be called within + :meth:`~qiskit_nature.second_q.operators.MajoranaOp.index_order`. + - | + Reference to `to_matrix` method in the documentation of + :class:`~qiskit_nature.second_q.operators.FermionicOp` has been removed as there is no such + method. 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..3413a505b --- /dev/null +++ b/test/second_q/operators/test_majorana_op.py @@ -0,0 +1,693 @@ +# 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 +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_neg(self): + """Test __neg__""" + maj_op = -self.op1 + targ = MajoranaOp({"_0 _1": -1}, num_spin_orbitals=1) + 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_spin_orbitals=1) + 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_spin_orbitals=1) + self.assertEqual(maj_op, targ) + + def test_div(self): + """Test __truediv__""" + maj_op = self.op1 / 2 + targ = MajoranaOp({"_0 _1": 0.5}, num_spin_orbitals=1) + 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_spin_orbitals=1) + 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_spin_orbitals=2) @ MajoranaOp( + {"_1": 1}, num_spin_orbitals=2 + ) + targ = MajoranaOp({"_0 _2 _1": 1}, num_spin_orbitals=2) + 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_spin_orbitals=2) @ MajoranaOp( + {"": 1, "_1 _3": 1}, num_spin_orbitals=2 + ) + maj_op = maj_op.simplify() + targ = MajoranaOp( + {"_0 _2 _3": 1, "_1 _2 _3": 1, "_0 _2 _1": -1, "_2": 1}, + num_spin_orbitals=2, + ) + 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_spin_orbitals=2) + 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_spin_orbitals=2) + 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_spin_orbitals=2) ** 2 + maj_op = maj_op.simplify() + targ = MajoranaOp({"": -10, "_2 _3": 3, "_3 _2": 3}, num_spin_orbitals=2) + 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_spin_orbitals=2) ** 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_spin_orbitals=2) ** 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_spin_orbitals=2 + ) + 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_spin_orbitals=3 + ).adjoint() + targ = MajoranaOp( + {"": -1j, "_2 _1 _0": 3, "_3 _1 _0": 1, "_3 _1": 2 - 4j}, num_spin_orbitals=3 + ) + self.assertEqual(maj_op, targ) + + maj_op = MajoranaOp( + {"": 1j, "_0 _1 _2": 3, "_0 _1 _3": self.a, "_1 _3": 2 + 4j}, num_spin_orbitals=3 + ).adjoint() + targ = MajoranaOp( + {"": -1j, "_2 _1 _0": 3, "_3 _1 _0": self.a.conjugate(), "_3 _1": 2 - 4j}, + num_spin_orbitals=3, + ) + 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_spin_orbitals=1) + simplified_op = maj_op.simplify() + targ = MajoranaOp({"_0 _1": 2}, num_spin_orbitals=1) + self.assertEqual(simplified_op, targ) + + with self.subTest("simplify complex"): + maj_op = MajoranaOp({"_0 _1": 1, "_0 _1 _0 _0": 1j}, num_spin_orbitals=1) + simplified_op = maj_op.simplify() + targ = MajoranaOp({"_0 _1": 1 + 1j}, num_spin_orbitals=1) + self.assertEqual(simplified_op, targ) + + with self.subTest("simplify doesn't reorder"): + maj_op = MajoranaOp({"_1 _2": 1 + 0j}, num_spin_orbitals=2) + simplified_op = maj_op.simplify() + self.assertEqual(simplified_op, maj_op) + + maj_op = MajoranaOp({"_3 _0": 1 + 0j}, num_spin_orbitals=2) + 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_spin_orbitals=2) + - 1j * MajoranaOp({"_3 _2 _1 _0": 1}, num_spin_orbitals=2) + + MajoranaOp({"_0 _1": 1}, num_spin_orbitals=2) + + MajoranaOp({"_1 _0": 1}, num_spin_orbitals=2) + ) + self.assertTrue(maj_op.is_hermitian()) + + with self.subTest("operator not hermitian"): + maj_op = ( + 1j * MajoranaOp({"_0 _1 _2 _3": 1}, num_spin_orbitals=2) + + 1j * MajoranaOp({"_3 _2 _1 _0": 1}, num_spin_orbitals=2) + + MajoranaOp({"_0 _1": 1}, num_spin_orbitals=2) + - MajoranaOp({"_1 _0": 1}, num_spin_orbitals=2) + ) + self.assertFalse(maj_op.is_hermitian()) + + with self.subTest("test passing atol"): + maj_op = MajoranaOp({"_0 _1": 1}, num_spin_orbitals=2) + (1 + 1e-7) * MajoranaOp( + {"_1 _0": 1}, num_spin_orbitals=2 + ) + 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_induced_norm(self): + """Test induced norm.""" + op1 = 3 * MajoranaOp({"_0": 1}, num_spin_orbitals=1) + 4j * MajoranaOp( + {"_1": 1}, num_spin_orbitals=1 + ) + op2 = 3 * MajoranaOp({"_0": 1}, num_spin_orbitals=1) + 4j * MajoranaOp( + {"_0": 1}, num_spin_orbitals=1 + ) + 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 term pattern: fermionic op + ("something", 1, False), # incorrect term pattern + ("_1", 1, True), # 1 spin orbital takes two registers + ("_2", 1, False), # register length is too short + ) + def test_validate(self, key: str, length: int, valid: bool): + """Test key validation.""" + num_so = (length + 1) // 2 + if valid: + _ = MajoranaOp({key: 1.0}, num_spin_orbitals=num_so) + else: + with self.assertRaises(QiskitNatureError): + _ = MajoranaOp({key: 1.0}, num_spin_orbitals=num_so) + + 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_spin_orbitals=1, + ) + + 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_spin_orbitals=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_spin_orbitals(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_spin_orbitals, 0) + self.assertEqual(op1.num_spin_orbitals, 1) + self.assertEqual(op2.num_spin_orbitals, 2) + + with self.subTest("Mathematical operations"): + self.assertEqual((op0 + op2).num_spin_orbitals, 2) + self.assertEqual((op1 + op2).num_spin_orbitals, 2) + self.assertEqual((op0 @ op2).num_spin_orbitals, 2) + self.assertEqual((op1 @ op2).num_spin_orbitals, 2) + self.assertEqual((op1 ^ op2).num_spin_orbitals, 3) + + with self.subTest("Equality"): + op3 = MajoranaOp({"_0 _1": 1}, num_spin_orbitals=3) + 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_spin_orbitals=2, + ) + + 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_spin_orbitals=2)) + + 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_spin_orbitals) + 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_spin_orbitals=2 + ), + 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_spin_orbitals=2, + ), + 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_spin_orbitals=3, + ), + ] + expected_ops_no_simplify = [ + MajoranaOp( + {"_0 _2": 0.25, "_0 _3": 0.25j, "_1 _2": -0.25j, "_1 _3": 0.25}, num_spin_orbitals=2 + ), + 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_spin_orbitals=2, + ), + 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_spin_orbitals=3, + ), + ] + expected_ops_no_order = [ + MajoranaOp( + {"_0 _2": 0.25, "_0 _3": 0.25j, "_1 _2": -0.25j, "_1 _3": 0.25}, num_spin_orbitals=2 + ), + 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_spin_orbitals=2, + ), + 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_spin_orbitals=3, + ), + ] + expected_ops = [ + MajoranaOp( + {"_0 _2": 0.25, "_0 _3": 0.25j, "_1 _2": -0.25j, "_1 _3": 0.25}, num_spin_orbitals=2 + ), + 2 + * MajoranaOp( + {"": 1 / 4, "_0 _1": 1j / 4, "_2 _3": 1j / 4, "_0 _1 _2 _3": -1 / 4}, + num_spin_orbitals=2, + ), + 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_spin_orbitals=3, + ), + ] + 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) + 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, order=False) + 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, order=False) + self.assertEqual(t_op, e_op) + + +if __name__ == "__main__": + unittest.main() From 922eca33b6239fd9296bc6113acb10768e17a723 Mon Sep 17 00:00:00 2001 From: grossardt <99898527+grossardt@users.noreply.github.com> Date: Sun, 19 Nov 2023 00:18:10 +0100 Subject: [PATCH 02/13] New style of MajoranaOp.terms(), some new tests --- .../second_q/operators/majorana_op.py | 21 ++++++++++--- test/second_q/operators/test_majorana_op.py | 30 +++++++++++++++++-- 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/qiskit_nature/second_q/operators/majorana_op.py b/qiskit_nature/second_q/operators/majorana_op.py index 4dd035554..1c5edf6fb 100644 --- a/qiskit_nature/second_q/operators/majorana_op.py +++ b/qiskit_nature/second_q/operators/majorana_op.py @@ -40,6 +40,13 @@ class MajoranaOp(SparseLabelOp): number of spin orbitals (``num_spin_orbitals``) of the operator (Note: since Python indices are 0-based, the maximum value an index can take is given by :code:`2 * num_spin_orbitals - 1`). + .. note:: + + For compatibility reasons (e.g. with mappers) :meth:`MajoranaOp.terms()` returns a list of + tuples of the form `("+", index)`, i.e. is compatible with the format for + :class:`FermionicOp` with all operators being treated as creation operators. + When using the :meth:`MajoranaOp.from_terms()` constructor, any label string is accepted. + **Initialization** A ``MajoranaOp`` is initialized with a dictionary, mapping terms to their respective @@ -267,6 +274,11 @@ def _validate_keys(self, keys: Collection[str]) -> None: if num_so is None: self.num_spin_orbitals = (max_index + 1 if max_index % 2 else max_index + 2) // 2 + @staticmethod + def _majorana_label(label: str) -> str: + """Converts a Fermionic label into a Majorana label.""" + return label.replace("+", "").replace("-", "") + @classmethod def _validate_polynomial_tensor_key(cls, keys: Collection[str]) -> None: # PolynomialTensor keys cannot be built from empty string, @@ -323,7 +335,7 @@ def terms(self) -> Iterator[tuple[list[tuple[str, int]], _TCoeff]]: Yields: A tuple with two items; the first one being a list of pairs of the form (char, int) - where char is always an empty string (for compatibility with other SparseLabelOps) and + where char is always '+' (for compatibility with other SparseLabelOps) 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. """ @@ -333,7 +345,7 @@ def terms(self) -> Iterator[tuple[list[tuple[str, int]], _TCoeff]]: continue # label.split() will return lbl = '_' for each term # lbl[1:] corresponds to the index - terms = [("", int(lbl[1:])) for lbl in label.split()] + terms = [("+", int(lbl[1:])) for lbl in label.split()] yield (terms, self[label]) @classmethod @@ -375,7 +387,8 @@ def from_fermionic_op( new_coeff = 1j**coeff_power * coeff / 2 ** len(terms) if order: trms = next(trm for trm, _ in MajoranaOp({majorana_label: new_coeff}).terms()) - majorana_label, new_coeff = FermionicOp._index_order(trms, new_coeff) + fermion_label, new_coeff = FermionicOp._index_order(trms, new_coeff) + majorana_label = cls._majorana_label(fermion_label) if simplify: majorana_label, new_coeff = cls._simplify_label(majorana_label, new_coeff) data[majorana_label] += new_coeff @@ -450,7 +463,7 @@ def index_order(self) -> MajoranaOp: 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 + data[self._majorana_label(label)] += coeff # after successful index ordering, we remove all zero coefficients return self._new_instance( diff --git a/test/second_q/operators/test_majorana_op.py b/test/second_q/operators/test_majorana_op.py index 3413a505b..726822d13 100644 --- a/test/second_q/operators/test_majorana_op.py +++ b/test/second_q/operators/test_majorana_op.py @@ -327,7 +327,7 @@ def test_induced_norm(self): ("_0a", 1, False), # incorrect term pattern ("_a0", 1, False), # incorrect term pattern ("0_", 1, False), # incorrect term pattern - ("+_0", 1, False), # incorrect term pattern: fermionic op + ("+_0", 1, False), # incorrect fermionic pattern ("something", 1, False), # incorrect term pattern ("_1", 1, True), # 1 spin orbital takes two registers ("_2", 1, False), # register length is too short @@ -341,6 +341,32 @@ def test_validate(self, key: str, length: int, valid: bool): with self.assertRaises(QiskitNatureError): _ = MajoranaOp({key: 1.0}, num_spin_orbitals=num_so) + 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_spin_orbitals=1, validate=False) + self.assertEqual(op, MajoranaOp({"_0 _1": 1})) + + with self.subTest("no validation no num_spin_orbitals"): + op = MajoranaOp({"_0 _1": 1}, validate=False) + self.assertEqual(op.num_spin_orbitals, 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_spin_orbitals"): + op = MajoranaOp({"_1 _2": 1}, num_spin_orbitals=1, validate=False) + self.assertEqual(MajoranaOp.from_terms(op.terms()).num_spin_orbitals, 2) + def test_from_polynomial_tensor(self): """Test from PolynomialTensor construction""" @@ -453,7 +479,7 @@ def test_terms(self): } ) - terms = [([("", 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) From 97625dd298ec2c8488c157bfb6930c2c0c74004e Mon Sep 17 00:00:00 2001 From: grossardt <99898527+grossardt@users.noreply.github.com> Date: Mon, 27 Nov 2023 22:21:53 +0100 Subject: [PATCH 03/13] Update majorana_op.py --- qiskit_nature/second_q/operators/majorana_op.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qiskit_nature/second_q/operators/majorana_op.py b/qiskit_nature/second_q/operators/majorana_op.py index 1c5edf6fb..471b5b3d1 100644 --- a/qiskit_nature/second_q/operators/majorana_op.py +++ b/qiskit_nature/second_q/operators/majorana_op.py @@ -384,7 +384,7 @@ def from_fermionic_op( else: coeff_power += 3 majorana_label += f"_{index}" - new_coeff = 1j**coeff_power * coeff / 2 ** len(terms) + new_coeff = 1j**coeff_power * coeff / 2**len(terms) if order: trms = next(trm for trm, _ in MajoranaOp({majorana_label: new_coeff}).terms()) fermion_label, new_coeff = FermionicOp._index_order(trms, new_coeff) From 4c84cec01125f008ebad2600c84fcff184226c77 Mon Sep 17 00:00:00 2001 From: grossardt <99898527+grossardt@users.noreply.github.com> Date: Mon, 27 Nov 2023 22:38:01 +0100 Subject: [PATCH 04/13] Update majorana_op.py --- qiskit_nature/second_q/operators/majorana_op.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qiskit_nature/second_q/operators/majorana_op.py b/qiskit_nature/second_q/operators/majorana_op.py index 471b5b3d1..1f156778e 100644 --- a/qiskit_nature/second_q/operators/majorana_op.py +++ b/qiskit_nature/second_q/operators/majorana_op.py @@ -384,7 +384,7 @@ def from_fermionic_op( else: coeff_power += 3 majorana_label += f"_{index}" - new_coeff = 1j**coeff_power * coeff / 2**len(terms) + new_coeff = 1j**coeff_power * coeff / (2 ** len(terms)) if order: trms = next(trm for trm, _ in MajoranaOp({majorana_label: new_coeff}).terms()) fermion_label, new_coeff = FermionicOp._index_order(trms, new_coeff) From 700aa591624fc9b574aaf9825548bc0a4a080fae Mon Sep 17 00:00:00 2001 From: grossardt <99898527+grossardt@users.noreply.github.com> Date: Tue, 28 Nov 2023 23:37:12 +0100 Subject: [PATCH 05/13] Some easier reviews done. More will follow. --- .../second_q/operators/fermionic_op.py | 1 - .../second_q/operators/majorana_op.py | 24 ++++++++++--------- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/qiskit_nature/second_q/operators/fermionic_op.py b/qiskit_nature/second_q/operators/fermionic_op.py index 1125d950d..fc50609de 100644 --- a/qiskit_nature/second_q/operators/fermionic_op.py +++ b/qiskit_nature/second_q/operators/fermionic_op.py @@ -539,7 +539,6 @@ def simplify(self, atol: float | None = None) -> FermionicOp: data = defaultdict(complex) # type: dict[str, _TCoeff] # TODO: use parallel_map to make this more efficient (?) - # (if this is done, apply equally to MajoranaOp.simplify()) for label, coeff in self.items(): label, coeff = self._simplify_label(label, coeff) data[label] += coeff diff --git a/qiskit_nature/second_q/operators/majorana_op.py b/qiskit_nature/second_q/operators/majorana_op.py index 1f156778e..3de6e54a1 100644 --- a/qiskit_nature/second_q/operators/majorana_op.py +++ b/qiskit_nature/second_q/operators/majorana_op.py @@ -35,10 +35,10 @@ class MajoranaOp(SparseLabelOp): 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 - creation/annihilation operator is applied. The value of :code:`index` is bound by twice the - number of spin orbitals (``num_spin_orbitals``) of the operator (Note: since Python indices are - 0-based, the maximum value an index can take is given by :code:`2 * num_spin_orbitals - 1`). + non-negative integer representing the index of the mode on which the Majorana operator is + applied. The value of :code:`index` is bound by twice the number of spin orbitals + (``num_spin_orbitals``) of the operator (Note: since Python indices are 0-based, the maximum + value an index can take is given by :code:`2 * num_spin_orbitals - 1`). .. note:: @@ -101,9 +101,9 @@ class MajoranaOp(SparseLabelOp): f_op = FermionicOp({"+_0 -_1": 1}, num_spin_orbitals=2) m_op = MajoranaOp.from_fermionic_op(f_op) - Note that every term of the ``FermionicOp`` will result in :math:`2^n` terms in the - ``MajoranaOp``, where :math:`n` is the number of fermionic modes in the term. The conversion - uses the convention that + 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:: @@ -166,9 +166,9 @@ class MajoranaOp(SparseLabelOp): .. note:: - Since Majorana generators are self-adjoined, the adjoint of a ``MajoranaOp`` is the original + 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 complex conjugated. + example above, and coefficients become complex conjugated. **Iteration** @@ -450,8 +450,9 @@ def index_order(self) -> MajoranaOp: .. note:: You can use this method to achieve the most aggressive simplification. - :meth:`simplify` does *not* reorder the terms and, thus, cannot deduce - ``_0 _1 _2`` and ``_2 _0 _1 _0 _0`` to be identical labels. + :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 these labels to be + identical to ``_0 _1 _2``. Calling this method will reorder the latter label to ``_0 _0 _0 _1 _2``, after which :meth:`simplify` will be able to correctly collapse these two labels into one. @@ -496,6 +497,7 @@ 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 From 0ea65bf96a4e695a56b44d2cfee270c6b8a2a4b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9?= <99898527+grossardt@users.noreply.github.com> Date: Tue, 28 Nov 2023 23:44:43 +0100 Subject: [PATCH 06/13] Update releasenotes/notes/add-majoranaop-1cbf9d4a1d4c264e.yaml Co-authored-by: Max Rossmannek --- releasenotes/notes/add-majoranaop-1cbf9d4a1d4c264e.yaml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/releasenotes/notes/add-majoranaop-1cbf9d4a1d4c264e.yaml b/releasenotes/notes/add-majoranaop-1cbf9d4a1d4c264e.yaml index 48615b609..9300e0646 100644 --- a/releasenotes/notes/add-majoranaop-1cbf9d4a1d4c264e.yaml +++ b/releasenotes/notes/add-majoranaop-1cbf9d4a1d4c264e.yaml @@ -15,10 +15,6 @@ features: 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. other: - - | - The hidden method :meth:`~qiskit_nature.second_q.operators.FermionicOp._index_order` - has been converted to a class method to be able to be called within - :meth:`~qiskit_nature.second_q.operators.MajoranaOp.index_order`. - | Reference to `to_matrix` method in the documentation of :class:`~qiskit_nature.second_q.operators.FermionicOp` has been removed as there is no such From 2e1dd95d49464361cd2970363d07d49e4f98a7bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9?= <99898527+grossardt@users.noreply.github.com> Date: Tue, 28 Nov 2023 23:45:58 +0100 Subject: [PATCH 07/13] Update releasenotes/notes/add-majoranaop-1cbf9d4a1d4c264e.yaml Co-authored-by: Max Rossmannek --- releasenotes/notes/add-majoranaop-1cbf9d4a1d4c264e.yaml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/releasenotes/notes/add-majoranaop-1cbf9d4a1d4c264e.yaml b/releasenotes/notes/add-majoranaop-1cbf9d4a1d4c264e.yaml index 9300e0646..1eff8ea95 100644 --- a/releasenotes/notes/add-majoranaop-1cbf9d4a1d4c264e.yaml +++ b/releasenotes/notes/add-majoranaop-1cbf9d4a1d4c264e.yaml @@ -15,7 +15,3 @@ features: 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. other: - - | - Reference to `to_matrix` method in the documentation of - :class:`~qiskit_nature.second_q.operators.FermionicOp` has been removed as there is no such - method. From 60e6473bc35671d20c65553a21fb192200ba8ad0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9?= <99898527+grossardt@users.noreply.github.com> Date: Fri, 1 Dec 2023 21:19:14 +0100 Subject: [PATCH 08/13] Update qiskit_nature/second_q/operators/majorana_op.py Co-authored-by: Max Rossmannek --- qiskit_nature/second_q/operators/majorana_op.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/qiskit_nature/second_q/operators/majorana_op.py b/qiskit_nature/second_q/operators/majorana_op.py index 3de6e54a1..25c19873c 100644 --- a/qiskit_nature/second_q/operators/majorana_op.py +++ b/qiskit_nature/second_q/operators/majorana_op.py @@ -451,9 +451,9 @@ def index_order(self) -> MajoranaOp: 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 these labels to be + 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 latter label to + 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. From d72af532cb22d7d3aa5def8af8531cf0ad8c1fc4 Mon Sep 17 00:00:00 2001 From: grossardt <99898527+grossardt@users.noreply.github.com> Date: Sat, 2 Dec 2023 00:42:42 +0100 Subject: [PATCH 09/13] Reviewed changes --- .../second_q/operators/majorana_op.py | 150 +++++++------- .../add-majoranaop-1cbf9d4a1d4c264e.yaml | 1 - test/second_q/operators/test_majorana_op.py | 189 +++++++++--------- 3 files changed, 172 insertions(+), 168 deletions(-) diff --git a/qiskit_nature/second_q/operators/majorana_op.py b/qiskit_nature/second_q/operators/majorana_op.py index 25c19873c..53b0577bc 100644 --- a/qiskit_nature/second_q/operators/majorana_op.py +++ b/qiskit_nature/second_q/operators/majorana_op.py @@ -36,16 +36,9 @@ class MajoranaOp(SparseLabelOp): 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 value of :code:`index` is bound by twice the number of spin orbitals - (``num_spin_orbitals``) of the operator (Note: since Python indices are 0-based, the maximum - value an index can take is given by :code:`2 * num_spin_orbitals - 1`). - - .. note:: - - For compatibility reasons (e.g. with mappers) :meth:`MajoranaOp.terms()` returns a list of - tuples of the form `("+", index)`, i.e. is compatible with the format for - :class:`FermionicOp` with all operators being treated as creation operators. - When using the :meth:`MajoranaOp.from_terms()` constructor, any label string is accepted. + applied. The 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** @@ -63,7 +56,7 @@ class MajoranaOp(SparseLabelOp): "_2 _3": -.25j, "_3 _2": .25j, }, - num_spin_orbitals=2, + num_modes=4, ) By default, this way of initializing will create a full copy of the dictionary of coefficients. @@ -80,7 +73,7 @@ class MajoranaOp(SparseLabelOp): op = MajoranaOp( some_big_data, - num_spin_orbitals=2, + num_modes=4, copy=False, ) @@ -113,11 +106,25 @@ class MajoranaOp(SparseLabelOp): where :math:`a_i` and :math:`a_i^\dagger` are the Fermionic annihilation and creation operators and :math:`\gamma_i` the Majorana operators. - .. note:: + **Construction from Polynomial Tensor** + + 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, - When creating a ``MajoranaOp`` from a ``PolynomialTensor`` using - :meth:`from_polynomial_tensor`, the underscore character :code:`_` is the only allowed - character in the keys of the ``PolynomialTensor``. + .. 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** @@ -129,40 +136,40 @@ class MajoranaOp(SparseLabelOp): .. code-block:: python - MajoranaOp({"_1": 1}, num_spin_orbitals=2) + MajoranaOp({"_0": 1}, num_spin_orbitals=2) + MajoranaOp({"_1": 1}, num_modes=2) + MajoranaOp({"_0": 1}, num_modes=2) Sum .. code-block:: python - sum(MajoranaOp({label: 1}, num_spin_orbitals=3) for label in ["_0", "_1", "_2 _3"]) + 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_spin_orbitals=2) + 0.5 * MajoranaOp({"_1": 1}, num_modes=2) Operator multiplication .. code-block:: python - op1 = MajoranaOp({"_0 _1": 1}, num_spin_orbitals=2) - op2 = MajoranaOp({"_0 _1 _2": 1}, num_spin_orbitals=2) + 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_spin_orbitals=2) + op = MajoranaOp({"_0 _1": 1}, num_modes=2) print(op ^ op) Adjoint .. code-block:: python - MajoranaOp({"_0 _1": 1j}, num_spin_orbitals=2).adjoint() + MajoranaOp({"_0 _1": 1j}, num_modes=2).adjoint() .. note:: @@ -176,10 +183,11 @@ class MajoranaOp(SparseLabelOp): pairs describing the terms contained in the operator. Attributes: - num_spin_orbitals (int | None): the number of spin orbitals on which this operator acts. + 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 spin orbitals - of any of the involved operators. + 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:: @@ -195,6 +203,7 @@ class MajoranaOp(SparseLabelOp): def __init__( self, data: Mapping[str, _TCoeff], + num_modes: int | None = None, num_spin_orbitals: int | None = None, *, copy: bool = True, @@ -203,7 +212,10 @@ def __init__( """ Args: data: the operator data, mapping string-based keys to numerical values. - num_spin_orbitals: the number of spin orbitals on which this operator acts. + num_modes: the number of modes on which this operator acts. + num_spin_orbitals: the number of spin orbitals. Providing :code:`num_spin_orbitals=n` + is equivalent to providing :code:`num_modes=2*n`. Ignored if ``num_modes`` is + provided. 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 @@ -216,37 +228,36 @@ def __init__( Raises: QiskitNatureError: when an invalid key is encountered during validation. """ - self.num_spin_orbitals = num_spin_orbitals - # if num_spin_orbitals is None, it is set during _validate_keys + if num_modes is None and num_spin_orbitals is not None: + num_modes = num_spin_orbitals * 2 + 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_spin_orbitals is None: + if self.num_modes is None: max_index = max(int(term[1:]) for key in self._data for term in key.split()) - if max_index % 2 == 0: - max_index += 1 return max_index + 1 - - return 2 * self.num_spin_orbitals + return self.num_modes def _new_instance( self, data: Mapping[str, _TCoeff], *, other: MajoranaOp | None = None ) -> MajoranaOp: - num_so = self.num_spin_orbitals + num_modes = self.num_modes if other is not None: - other_num_so = other.num_spin_orbitals - if num_so is None: - num_so = other_num_so - elif other_num_so is not None: - num_so = max(num_so, other_num_so) + 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_spin_orbitals=num_so) + return self.__class__(data, copy=False, num_modes=num_modes) def _validate_keys(self, keys: Collection[str]) -> None: super()._validate_keys(keys) - num_so = self.num_spin_orbitals + num_modes = self.num_modes max_index = -1 @@ -262,22 +273,17 @@ def _validate_keys(self, keys: Collection[str]) -> None: # 2. validate all indices against register length for term in key.split(): index = int(term[1:]) - if num_so is None: + if num_modes is None: if index > max_index: max_index = index - elif index >= 2 * num_so: + elif index >= num_modes: raise QiskitNatureError( - f"The index, {index}, from the label, {key}, exceeds twice the number of " - f"spin orbitals, {num_so}." + f"The index, {index}, from the label, {key}, exceeds the number of " + f"modes, {num_modes}." ) - if num_so is None: - self.num_spin_orbitals = (max_index + 1 if max_index % 2 else max_index + 2) // 2 - - @staticmethod - def _majorana_label(label: str) -> str: - """Converts a Fermionic label into a Majorana label.""" - return label.replace("+", "").replace("-", "") + if num_modes is None: + self.num_modes = max_index + 1 @classmethod def _validate_polynomial_tensor_key(cls, keys: Collection[str]) -> None: @@ -311,19 +317,16 @@ def from_polynomial_tensor(cls, tensor: PolynomialTensor) -> MajoranaOp: for value, index in mat.coord_iter(): data[label_template.format(*index)] = value - num_so = (tensor.register_length + 1) // 2 - return cls(data, copy=False, num_spin_orbitals=num_so).chop() + 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_spin_orbitals={self.num_spin_orbitals}, " ")" + return "MajoranaOp(" f"{data_str}, " f"num_modes={self.num_modes}, " ")" def __str__(self) -> str: - pre = ( - "Majorana Operator\n" - f"number spin orbitals={self.num_spin_orbitals}, number terms={len(self)}\n" - ) + 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()] ) @@ -334,8 +337,8 @@ def terms(self) -> Iterator[tuple[list[tuple[str, int]], _TCoeff]]: pairs of operation characters and indices. Yields: - A tuple with two items; the first one being a list of pairs of the form (char, int) - where char is always '+' (for compatibility with other SparseLabelOps) and + 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. """ @@ -345,7 +348,7 @@ def terms(self) -> Iterator[tuple[list[tuple[str, int]], _TCoeff]]: continue # label.split() will return lbl = '_' for each term # lbl[1:] corresponds to the index - terms = [("+", int(lbl[1:])) for lbl in label.split()] + terms = [("", int(lbl[1:])) for lbl in label.split()] yield (terms, self[label]) @classmethod @@ -354,15 +357,12 @@ def from_terms(cls, terms: Sequence[tuple[list[tuple[str, int]], _TCoeff]]) -> M return cls(data) @classmethod - def from_fermionic_op( - cls, op: FermionicOp, simplify: bool = True, order: bool = True - ) -> MajoranaOp: + 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 simplify the resulting operator. - order: whether to perform index ordering on the resulting operator. + simplify: whether to index order and simplify the resulting operator. Returns: The converted :class:`~.MajoranaOp`. @@ -385,14 +385,12 @@ def from_fermionic_op( coeff_power += 3 majorana_label += f"_{index}" new_coeff = 1j**coeff_power * coeff / (2 ** len(terms)) - if order: - trms = next(trm for trm, _ in MajoranaOp({majorana_label: new_coeff}).terms()) - fermion_label, new_coeff = FermionicOp._index_order(trms, new_coeff) - majorana_label = cls._majorana_label(fermion_label) 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_spin_orbitals=op.num_spin_orbitals) + return cls(data, num_modes=2 * op.num_spin_orbitals) def _permute_term( self, term: list[tuple[str, int]], permutation: Sequence[int] @@ -418,7 +416,7 @@ def expand(self, other: MajoranaOp) -> MajoranaOp: @classmethod def _tensor(cls, a: MajoranaOp, b: MajoranaOp, *, offset: bool = True) -> MajoranaOp: - shift = 2 * a.num_spin_orbitals if offset else 0 + shift = a.num_modes if offset else 0 new_data: dict[str, _TCoeff] = {} for label1, cf1 in a.items(): @@ -431,7 +429,7 @@ def _tensor(cls, a: MajoranaOp, b: MajoranaOp, *, offset: bool = True) -> Majora new_op = a._new_instance(new_data, other=b) if offset: - new_op.num_spin_orbitals = a.num_spin_orbitals + b.num_spin_orbitals + new_op.num_modes = a.num_modes + b.num_modes return new_op def transpose(self) -> MajoranaOp: @@ -464,7 +462,7 @@ def index_order(self) -> MajoranaOp: 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[self._majorana_label(label)] += coeff + data[label] += coeff # after successful index ordering, we remove all zero coefficients return self._new_instance( diff --git a/releasenotes/notes/add-majoranaop-1cbf9d4a1d4c264e.yaml b/releasenotes/notes/add-majoranaop-1cbf9d4a1d4c264e.yaml index 1eff8ea95..9a587f143 100644 --- a/releasenotes/notes/add-majoranaop-1cbf9d4a1d4c264e.yaml +++ b/releasenotes/notes/add-majoranaop-1cbf9d4a1d4c264e.yaml @@ -14,4 +14,3 @@ features: :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. -other: diff --git a/test/second_q/operators/test_majorana_op.py b/test/second_q/operators/test_majorana_op.py index 726822d13..e8b41a011 100644 --- a/test/second_q/operators/test_majorana_op.py +++ b/test/second_q/operators/test_majorana_op.py @@ -40,7 +40,7 @@ class TestMajoranaOp(QiskitNatureTestCase): def test_neg(self): """Test __neg__""" maj_op = -self.op1 - targ = MajoranaOp({"_0 _1": -1}, num_spin_orbitals=1) + targ = MajoranaOp({"_0 _1": -1}, num_modes=2) self.assertEqual(maj_op, targ) maj_op = -self.op4 @@ -51,7 +51,7 @@ def test_mul(self): """Test __mul__, and __rmul__""" with self.subTest("rightmul"): maj_op = self.op1 * 2 - targ = MajoranaOp({"_0 _1": 2}, num_spin_orbitals=1) + targ = MajoranaOp({"_0 _1": 2}, num_modes=2) self.assertEqual(maj_op, targ) maj_op = self.op1 * self.a @@ -60,13 +60,13 @@ def test_mul(self): with self.subTest("left mul"): maj_op = (2 + 1j) * self.op3 - targ = MajoranaOp({"_0 _1": (2 + 1j), "_1 _0": (4 + 2j)}, num_spin_orbitals=1) + 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_spin_orbitals=1) + targ = MajoranaOp({"_0 _1": 0.5}, num_modes=2) self.assertEqual(maj_op, targ) maj_op = self.op1 / self.a @@ -91,7 +91,7 @@ def test_add(self): def test_sub(self): """Test __sub__""" maj_op = self.op3 - self.op2 - targ = MajoranaOp({"_0 _1": 1, "_1 _0": 0}, num_spin_orbitals=1) + targ = MajoranaOp({"_0 _1": 1, "_1 _0": 0}, num_modes=2) self.assertEqual(maj_op, targ) maj_op = self.op4 - self.op1 @@ -101,10 +101,8 @@ def test_sub(self): def test_compose(self): """Test operator composition""" with self.subTest("single compose"): - maj_op = MajoranaOp({"_0 _2": 1}, num_spin_orbitals=2) @ MajoranaOp( - {"_1": 1}, num_spin_orbitals=2 - ) - targ = MajoranaOp({"_0 _2 _1": 1}, num_spin_orbitals=2) + 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"): @@ -113,13 +111,13 @@ def test_compose(self): self.assertEqual(maj_op, targ) with self.subTest("multi compose"): - maj_op = MajoranaOp({"_0 _2 _3": 1, "_1 _2 _3": 1}, num_spin_orbitals=2) @ MajoranaOp( - {"": 1, "_1 _3": 1}, num_spin_orbitals=2 + 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_spin_orbitals=2, + num_modes=4, ) self.assertEqual(maj_op, targ) @@ -141,7 +139,7 @@ def test_compose(self): def test_tensor(self): """Test tensor multiplication""" maj_op = self.op1.tensor(self.op2) - targ = MajoranaOp({"_0 _1 _3 _2": 2}, num_spin_orbitals=2) + targ = MajoranaOp({"_0 _1 _3 _2": 2}, num_modes=4) self.assertEqual(maj_op, targ) maj_op = self.op4.tensor(self.op2) @@ -151,7 +149,7 @@ def test_tensor(self): def test_expand(self): """Test reversed tensor multiplication""" maj_op = self.op1.expand(self.op2) - targ = MajoranaOp({"_1 _0 _2 _3": 2}, num_spin_orbitals=2) + targ = MajoranaOp({"_1 _0 _2 _3": 2}, num_modes=4) self.assertEqual(maj_op, targ) maj_op = self.op4.expand(self.op2) @@ -161,9 +159,9 @@ def test_expand(self): def test_pow(self): """Test __pow__""" with self.subTest("square"): - maj_op = MajoranaOp({"_0 _1 _2": 3, "_1 _0 _3": 1}, num_spin_orbitals=2) ** 2 + 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_spin_orbitals=2) + targ = MajoranaOp({"": -10, "_2 _3": 3, "_3 _2": 3}, num_modes=4) self.assertEqual(maj_op, targ) with self.subTest("3rd power"): @@ -172,59 +170,55 @@ def test_pow(self): self.assertEqual(maj_op, targ) with self.subTest("0th power"): - maj_op = MajoranaOp({"_0 _1 _2": 3, "_1 _0 _3": 1}, num_spin_orbitals=2) ** 0 + 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_spin_orbitals=2) ** 2 + 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_spin_orbitals=2 - ) + 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_spin_orbitals=3 + {"": 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_spin_orbitals=3 - ) + 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_spin_orbitals=3 + {"": 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_spin_orbitals=3, + 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_spin_orbitals=1) + 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_spin_orbitals=1) + 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_spin_orbitals=1) + 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_spin_orbitals=1) + 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_spin_orbitals=2) + 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_spin_orbitals=2) + maj_op = MajoranaOp({"_3 _0": 1 + 0j}, num_modes=4) simplified_op = maj_op.simplify() self.assertEqual(simplified_op, maj_op) @@ -250,25 +244,25 @@ def test_hermiticity(self): """test is_hermitian""" with self.subTest("operator hermitian"): maj_op = ( - 1j * MajoranaOp({"_0 _1 _2 _3": 1}, num_spin_orbitals=2) - - 1j * MajoranaOp({"_3 _2 _1 _0": 1}, num_spin_orbitals=2) - + MajoranaOp({"_0 _1": 1}, num_spin_orbitals=2) - + MajoranaOp({"_1 _0": 1}, num_spin_orbitals=2) + 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_spin_orbitals=2) - + 1j * MajoranaOp({"_3 _2 _1 _0": 1}, num_spin_orbitals=2) - + MajoranaOp({"_0 _1": 1}, num_spin_orbitals=2) - - MajoranaOp({"_1 _0": 1}, num_spin_orbitals=2) + 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_spin_orbitals=2) + (1 + 1e-7) * MajoranaOp( - {"_1 _0": 1}, num_spin_orbitals=2 + 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)) @@ -292,7 +286,7 @@ def test_equiv(self): MajoranaOp.rtol = prev_rtol def test_index_order(self): - """test index_order method""" + """Test index_order method""" ordered_op = MajoranaOp({"_0 _1": 1}) reverse_op = MajoranaOp({"_1 _0": -1}) maj_op = ordered_op.index_order() @@ -300,14 +294,19 @@ def test_index_order(self): 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_spin_orbitals=1) + 4j * MajoranaOp( - {"_1": 1}, num_spin_orbitals=1 - ) - op2 = 3 * MajoranaOp({"_0": 1}, num_spin_orbitals=1) + 4j * MajoranaOp( - {"_0": 1}, num_spin_orbitals=1 - ) + 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) @@ -329,17 +328,16 @@ def test_induced_norm(self): ("0_", 1, False), # incorrect term pattern ("+_0", 1, False), # incorrect fermionic pattern ("something", 1, False), # incorrect term pattern - ("_1", 1, True), # 1 spin orbital takes two registers - ("_2", 1, False), # register length is too short + ("_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.""" - num_so = (length + 1) // 2 if valid: - _ = MajoranaOp({key: 1.0}, num_spin_orbitals=num_so) + _ = MajoranaOp({key: 1.0}, num_modes=length) else: with self.assertRaises(QiskitNatureError): - _ = MajoranaOp({key: 1.0}, num_spin_orbitals=num_so) + _ = MajoranaOp({key: 1.0}, num_modes=length) def test_no_copy(self): """Test constructor with copy=False""" @@ -351,21 +349,22 @@ def test_no_copy(self): def test_no_validate(self): """Test skipping validation""" with self.subTest("no validation"): - op = MajoranaOp({"_0 _1": 1}, num_spin_orbitals=1, validate=False) + op = MajoranaOp({"_0 _1": 1}, num_modes=2, validate=False) self.assertEqual(op, MajoranaOp({"_0 _1": 1})) - with self.subTest("no validation no num_spin_orbitals"): + with self.subTest("no validation no num_modes"): op = MajoranaOp({"_0 _1": 1}, validate=False) - self.assertEqual(op.num_spin_orbitals, None) + 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_spin_orbitals"): - op = MajoranaOp({"_1 _2": 1}, num_spin_orbitals=1, validate=False) - self.assertEqual(MajoranaOp.from_terms(op.terms()).num_spin_orbitals, 2) + 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""" @@ -388,7 +387,7 @@ def test_from_polynomial_tensor(self): "_1 _0": 3, "_1 _1": 4, }, - num_spin_orbitals=1, + num_modes=2, ) self.assertEqual(op, expected) @@ -415,7 +414,7 @@ def test_from_polynomial_tensor(self): "_0 _0 _0 _1": 1, "_1 _0 _1 _1": 2, }, - num_spin_orbitals=r_l, + num_modes=r_l, ) self.assertEqual(op, expected) @@ -446,29 +445,35 @@ def test_from_polynomial_tensor(self): self.assertEqual(op ^ op, MajoranaOp.from_polynomial_tensor(p_t ^ p_t)) - def test_no_num_spin_orbitals(self): + 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_spin_orbitals, 0) - self.assertEqual(op1.num_spin_orbitals, 1) - self.assertEqual(op2.num_spin_orbitals, 2) + 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_spin_orbitals, 2) - self.assertEqual((op1 + op2).num_spin_orbitals, 2) - self.assertEqual((op0 @ op2).num_spin_orbitals, 2) - self.assertEqual((op1 @ op2).num_spin_orbitals, 2) - self.assertEqual((op1 ^ op2).num_spin_orbitals, 3) + 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_spin_orbitals=3) + op3 = MajoranaOp({"_0 _1": 1}, num_modes=6) self.assertEqual(op1, op3) self.assertTrue(op1.equiv(1.000001 * op3)) + def test_creation_with_spin_orbitals(self): + """Test creation with spin orbitals.""" + op1 = MajoranaOp({"_0 _1": 1}, num_spin_orbitals=1) + op2 = MajoranaOp({"_0 _1": 1}, num_modes=2) + self.assertEqual(op1, op2) + def test_terms(self): """Test terms generator.""" op = MajoranaOp( @@ -479,7 +484,7 @@ def test_terms(self): } ) - terms = [([("+", 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) @@ -494,7 +499,7 @@ def test_permute_indices(self): "_0 _1": 1, "_1 _2": 2, }, - num_spin_orbitals=2, + num_modes=4, ) with self.subTest("wrong permutation length"): @@ -504,12 +509,12 @@ def test_permute_indices(self): 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_spin_orbitals=2)) + 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_spin_orbitals) + self.assertIsNone(new_op.num_modes) self.assertEqual(new_op.register_length, 2) def test_from_fermionic_op(self): @@ -521,7 +526,7 @@ def test_from_fermionic_op(self): ] expected_ops_no_simp_no_order = [ MajoranaOp( - {"_0 _2": 0.25, "_0 _3": 0.25j, "_1 _2": -0.25j, "_1 _3": 0.25}, num_spin_orbitals=2 + {"_0 _2": 0.25, "_0 _3": 0.25j, "_1 _2": -0.25j, "_1 _3": 0.25}, num_modes=4 ), 2 * MajoranaOp( @@ -546,7 +551,7 @@ def test_from_fermionic_op(self): "_1 _0 _3 _3": -1j / 16, "_1 _1 _3 _3": 1 / 16, }, - num_spin_orbitals=2, + num_modes=4, ), 3 * MajoranaOp( @@ -571,12 +576,12 @@ def test_from_fermionic_op(self): "_1 _2 _5 _3": 1j / 16, "_1 _3 _5 _3": 1 / 16, }, - num_spin_orbitals=3, + 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_spin_orbitals=2 + {"_0 _2": 0.25, "_0 _3": 0.25j, "_1 _2": -0.25j, "_1 _3": 0.25}, num_modes=4 ), 2 * MajoranaOp( @@ -591,7 +596,7 @@ def test_from_fermionic_op(self): "_0 _1 _3 _3": 1j / 8, "_1 _1 _3 _3": 1 / 16, }, - num_spin_orbitals=2, + num_modes=4, ), 3 * MajoranaOp( @@ -609,12 +614,12 @@ def test_from_fermionic_op(self): "_0 _3 _3 _5": -1j / 16, "_1 _3 _3 _5": -1 / 16, }, - num_spin_orbitals=3, + 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_spin_orbitals=2 + {"_0 _2": 0.25, "_0 _3": 0.25j, "_1 _2": -0.25j, "_1 _3": 0.25}, num_modes=4 ), 2 * MajoranaOp( @@ -629,7 +634,7 @@ def test_from_fermionic_op(self): "_0 _1 _3 _2": 1 / 16, "_1 _0 _3 _2": -1 / 16, }, - num_spin_orbitals=2, + num_modes=4, ), 3 * MajoranaOp( @@ -648,17 +653,17 @@ def test_from_fermionic_op(self): "_1 _3 _4 _2": -1 / 16, "_1 _3 _5 _2": -1j / 16, }, - num_spin_orbitals=3, + num_modes=6, ), ] expected_ops = [ MajoranaOp( - {"_0 _2": 0.25, "_0 _3": 0.25j, "_1 _2": -0.25j, "_1 _3": 0.25}, num_spin_orbitals=2 + {"_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_spin_orbitals=2, + num_modes=4, ), 3 * MajoranaOp( @@ -672,7 +677,7 @@ def test_from_fermionic_op(self): "_1 _5": -1 / 8, "_1 _2 _3 _5": -1j / 8, }, - num_spin_orbitals=3, + num_modes=6, ), ] with self.subTest("conversion"): @@ -703,15 +708,17 @@ def test_from_fermionic_op(self): 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, order=False) + 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, order=False) + t_op = MajoranaOp.from_fermionic_op(f_op, simplify=False) self.assertEqual(t_op, e_op) From 72ffda732d925ca341bfebfab2579a3931ceb0ff Mon Sep 17 00:00:00 2001 From: grossardt <99898527+grossardt@users.noreply.github.com> Date: Wed, 13 Dec 2023 01:55:08 +0100 Subject: [PATCH 10/13] final suggestions by mrossinek --- .../second_q/operators/majorana_op.py | 25 +++++++------------ .../add-majoranaop-1cbf9d4a1d4c264e.yaml | 2 +- test/second_q/operators/test_majorana_op.py | 23 ++++++++++++----- 3 files changed, 27 insertions(+), 23 deletions(-) diff --git a/qiskit_nature/second_q/operators/majorana_op.py b/qiskit_nature/second_q/operators/majorana_op.py index 53b0577bc..9302e61b4 100644 --- a/qiskit_nature/second_q/operators/majorana_op.py +++ b/qiskit_nature/second_q/operators/majorana_op.py @@ -36,8 +36,8 @@ class MajoranaOp(SparseLabelOp): 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 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 + 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** @@ -86,7 +86,8 @@ class MajoranaOp(SparseLabelOp): **Construction from Fermionic operator** - The default way to construct a ``MajoranaOp`` is from an existing ``FermionicOp``: + 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 @@ -106,7 +107,7 @@ class MajoranaOp(SparseLabelOp): 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 Polynomial Tensor** + **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 @@ -179,8 +180,8 @@ class MajoranaOp(SparseLabelOp): **Iteration** - Instances of ``MajoranaOp`` are iterable. Iterating a ``MajoranaOp`` yields (term, coefficient) - pairs describing the terms contained in the operator. + 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. @@ -204,7 +205,6 @@ def __init__( self, data: Mapping[str, _TCoeff], num_modes: int | None = None, - num_spin_orbitals: int | None = None, *, copy: bool = True, validate: bool = True, @@ -213,9 +213,6 @@ def __init__( Args: data: the operator data, mapping string-based keys to numerical values. num_modes: the number of modes on which this operator acts. - num_spin_orbitals: the number of spin orbitals. Providing :code:`num_spin_orbitals=n` - is equivalent to providing :code:`num_modes=2*n`. Ignored if ``num_modes`` is - provided. 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 @@ -228,8 +225,6 @@ def __init__( Raises: QiskitNatureError: when an invalid key is encountered during validation. """ - if num_modes is None and num_spin_orbitals is not None: - num_modes = num_spin_orbitals * 2 self.num_modes = num_modes # if num_modes is None, it is set during _validate_keys super().__init__(data, copy=copy, validate=validate) @@ -287,8 +282,6 @@ def _validate_keys(self, keys: Collection[str]) -> None: @classmethod def _validate_polynomial_tensor_key(cls, keys: Collection[str]) -> None: - # PolynomialTensor keys cannot be built from empty string, - # hence we choose _ to be the only allowed character allowed_chars = {"_"} for key in keys: @@ -311,7 +304,7 @@ def from_polynomial_tensor(cls, tensor: PolynomialTensor) -> MajoranaOp: mat = tensor[key] - empty_string_key = ["" for _ in key] # label format for Majorana is just '_' + 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(): @@ -357,7 +350,7 @@ def from_terms(cls, terms: Sequence[tuple[list[tuple[str, int]], _TCoeff]]) -> M return cls(data) @classmethod - def from_fermionic_op(cls, op: FermionicOp, simplify: bool = True) -> MajoranaOp: + def from_fermionic_op(cls, op: FermionicOp, *, simplify: bool = True) -> MajoranaOp: """Constructs the operator from a :class:`~.FermionicOp`. Args: diff --git a/releasenotes/notes/add-majoranaop-1cbf9d4a1d4c264e.yaml b/releasenotes/notes/add-majoranaop-1cbf9d4a1d4c264e.yaml index 9a587f143..63c406d29 100644 --- a/releasenotes/notes/add-majoranaop-1cbf9d4a1d4c264e.yaml +++ b/releasenotes/notes/add-majoranaop-1cbf9d4a1d4c264e.yaml @@ -10,7 +10,7 @@ features: 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` are the same as for + 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. diff --git a/test/second_q/operators/test_majorana_op.py b/test/second_q/operators/test_majorana_op.py index e8b41a011..03654aade 100644 --- a/test/second_q/operators/test_majorana_op.py +++ b/test/second_q/operators/test_majorana_op.py @@ -22,6 +22,7 @@ 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 @@ -37,6 +38,13 @@ class TestMajoranaOp(QiskitNatureTestCase): 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 @@ -468,12 +476,6 @@ def test_no_num_modes(self): self.assertEqual(op1, op3) self.assertTrue(op1.equiv(1.000001 * op3)) - def test_creation_with_spin_orbitals(self): - """Test creation with spin orbitals.""" - op1 = MajoranaOp({"_0 _1": 1}, num_spin_orbitals=1) - op2 = MajoranaOp({"_0 _1": 1}, num_modes=2) - self.assertEqual(op1, op2) - def test_terms(self): """Test terms generator.""" op = MajoranaOp( @@ -721,6 +723,15 @@ def test_from_fermionic_op(self): 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() From 42face7af34ca82b2be60bf1ebdb5e6b15b09cb7 Mon Sep 17 00:00:00 2001 From: grossardt <99898527+grossardt@users.noreply.github.com> Date: Mon, 26 Feb 2024 00:00:59 +0100 Subject: [PATCH 11/13] release notes: added example --- .../notes/add-majoranaop-1cbf9d4a1d4c264e.yaml | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/releasenotes/notes/add-majoranaop-1cbf9d4a1d4c264e.yaml b/releasenotes/notes/add-majoranaop-1cbf9d4a1d4c264e.yaml index 63c406d29..947070ddf 100644 --- a/releasenotes/notes/add-majoranaop-1cbf9d4a1d4c264e.yaml +++ b/releasenotes/notes/add-majoranaop-1cbf9d4a1d4c264e.yaml @@ -3,8 +3,6 @@ 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. - This is needed for the implementation of the Ternary Tree Mapper, but might be useful - later on for other purposes as well. 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 @@ -13,4 +11,10 @@ features: 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. + :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) From ec08359b5b735fef3a778a56f9281fad35b0738e Mon Sep 17 00:00:00 2001 From: grossardt <99898527+grossardt@users.noreply.github.com> Date: Mon, 26 Feb 2024 01:14:16 +0100 Subject: [PATCH 12/13] suggestions from pylint for tests to pass --- qiskit_nature/second_q/operators/bosonic_op.py | 3 +-- qiskit_nature/second_q/operators/fermionic_op.py | 3 +-- qiskit_nature/second_q/operators/majorana_op.py | 3 +-- qiskit_nature/second_q/operators/spin_op.py | 3 +-- qiskit_nature/second_q/problems/properties_container.py | 3 +-- 5 files changed, 5 insertions(+), 10 deletions(-) diff --git a/qiskit_nature/second_q/operators/bosonic_op.py b/qiskit_nature/second_q/operators/bosonic_op.py index 86e5ae43d..2f9397589 100644 --- a/qiskit_nature/second_q/operators/bosonic_op.py +++ b/qiskit_nature/second_q/operators/bosonic_op.py @@ -214,8 +214,7 @@ def _validate_keys(self, keys: Collection[str]) -> None: for term in key.split(): index = int(term[2:]) if num_so is None: - if index > max_index: - max_index = index + max_index = max(max_index, index) elif index >= num_so: raise QiskitNatureError( f"The index, {index}, from the label, {key}, exceeds the number of spin " diff --git a/qiskit_nature/second_q/operators/fermionic_op.py b/qiskit_nature/second_q/operators/fermionic_op.py index fc50609de..8d2116840 100644 --- a/qiskit_nature/second_q/operators/fermionic_op.py +++ b/qiskit_nature/second_q/operators/fermionic_op.py @@ -215,8 +215,7 @@ def _validate_keys(self, keys: Collection[str]) -> None: for term in key.split(): index = int(term[2:]) if num_so is None: - if index > max_index: - max_index = index + max_index = max(max_index, index) elif index >= num_so: raise QiskitNatureError( f"The index, {index}, from the label, {key}, exceeds the number of spin " diff --git a/qiskit_nature/second_q/operators/majorana_op.py b/qiskit_nature/second_q/operators/majorana_op.py index 9302e61b4..9a40f0316 100644 --- a/qiskit_nature/second_q/operators/majorana_op.py +++ b/qiskit_nature/second_q/operators/majorana_op.py @@ -269,8 +269,7 @@ def _validate_keys(self, keys: Collection[str]) -> None: for term in key.split(): index = int(term[1:]) if num_modes is None: - if index > max_index: - max_index = index + max_index = max(max_index, index) elif index >= num_modes: raise QiskitNatureError( f"The index, {index}, from the label, {key}, exceeds the number of " diff --git a/qiskit_nature/second_q/operators/spin_op.py b/qiskit_nature/second_q/operators/spin_op.py index adc53dee0..574132da0 100644 --- a/qiskit_nature/second_q/operators/spin_op.py +++ b/qiskit_nature/second_q/operators/spin_op.py @@ -275,8 +275,7 @@ def _validate_keys(self, keys: Collection[str]) -> None: # sub_terms[0] is the base, sub_terms[1] is the (optional) exponent index = int(sub_terms[0][2:]) if num_s is None: - if index > max_index: - max_index = index + max_index = max(max_index, index) elif index >= num_s: raise QiskitNatureError( f"The index, {index}, from the label, {key}, exceeds the number of " diff --git a/qiskit_nature/second_q/problems/properties_container.py b/qiskit_nature/second_q/problems/properties_container.py index 3fa04794b..8b7d1b328 100644 --- a/qiskit_nature/second_q/problems/properties_container.py +++ b/qiskit_nature/second_q/problems/properties_container.py @@ -80,8 +80,7 @@ def __len__(self) -> int: return len(self._properties) def __iter__(self) -> Generator[SparseLabelOpsFactory, None, None]: - for prop in self._properties.values(): - yield prop + yield from self._properties.values() def _getter(self, _type: type) -> SparseLabelOpsFactory | None: """An internal utility method to handle the attribute getter implementation. From d2464b664bcd371fb649ba9c08562cece1e7b579 Mon Sep 17 00:00:00 2001 From: grossardt <99898527+grossardt@users.noreply.github.com> Date: Mon, 26 Feb 2024 01:22:24 +0100 Subject: [PATCH 13/13] Fixed copyright header for modified files --- qiskit_nature/second_q/operators/bosonic_op.py | 2 +- qiskit_nature/second_q/operators/fermionic_op.py | 2 +- qiskit_nature/second_q/operators/majorana_op.py | 2 +- qiskit_nature/second_q/operators/spin_op.py | 2 +- qiskit_nature/second_q/problems/properties_container.py | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/qiskit_nature/second_q/operators/bosonic_op.py b/qiskit_nature/second_q/operators/bosonic_op.py index 2f9397589..56ce46bc5 100644 --- a/qiskit_nature/second_q/operators/bosonic_op.py +++ b/qiskit_nature/second_q/operators/bosonic_op.py @@ -1,6 +1,6 @@ # This code is part of a Qiskit project. # -# (C) Copyright IBM 2023. +# (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 diff --git a/qiskit_nature/second_q/operators/fermionic_op.py b/qiskit_nature/second_q/operators/fermionic_op.py index 8d2116840..3c846b547 100644 --- a/qiskit_nature/second_q/operators/fermionic_op.py +++ b/qiskit_nature/second_q/operators/fermionic_op.py @@ -1,6 +1,6 @@ # This code is part of a Qiskit project. # -# (C) Copyright IBM 2021, 2023. +# (C) Copyright IBM 2021, 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 diff --git a/qiskit_nature/second_q/operators/majorana_op.py b/qiskit_nature/second_q/operators/majorana_op.py index 9a40f0316..b6c67f884 100644 --- a/qiskit_nature/second_q/operators/majorana_op.py +++ b/qiskit_nature/second_q/operators/majorana_op.py @@ -1,6 +1,6 @@ # This code is part of a Qiskit project. # -# (C) Copyright IBM 2023. +# (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 diff --git a/qiskit_nature/second_q/operators/spin_op.py b/qiskit_nature/second_q/operators/spin_op.py index 574132da0..5dd9eaebb 100644 --- a/qiskit_nature/second_q/operators/spin_op.py +++ b/qiskit_nature/second_q/operators/spin_op.py @@ -1,6 +1,6 @@ # This code is part of a Qiskit project. # -# (C) Copyright IBM 2021, 2023. +# (C) Copyright IBM 2021, 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 diff --git a/qiskit_nature/second_q/problems/properties_container.py b/qiskit_nature/second_q/problems/properties_container.py index 8b7d1b328..4a751d25b 100644 --- a/qiskit_nature/second_q/problems/properties_container.py +++ b/qiskit_nature/second_q/problems/properties_container.py @@ -1,6 +1,6 @@ # This code is part of a Qiskit project. # -# (C) Copyright IBM 2022, 2023. +# (C) Copyright IBM 2022, 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