Repository: qutip/qutip

Issue:
Incorporate rtol into __eq__
### Problem Description

CoreOptions has an [`rtol`](https://github.com/qutip/qutip/blob/e92f82b84acfe832f7022e7185e93e1948e4aa7a/qutip/core/options.py#L79) property, but only `atol` gets used in `Qobj.__eq__`

### Proposed Solution

Incorporate `rtol` into equality testing. Ideally, we could have:

```python
 with CoreOptions(atol=1e-08, rtol=1e-05):
    a == b
```

which would allow comparisons similar to [np.allclose](https://numpy.org/doc/stable/reference/generated/numpy.allclose.html)

### Alternate Solutions

_No response_

### Additional Context

_No response_


Discussion:

Resolving patch:
diff --git a/qutip/core/qobj.py b/qutip/core/qobj.py
--- a/qutip/core/qobj.py
+++ b/qutip/core/qobj.py
@@ -510,8 +510,8 @@ def __eq__(self, other):
             return True
         if not isinstance(other, Qobj) or self._dims != other._dims:
             return False
-        return _data.iszero(_data.sub(self._data, other._data),
-                            tol=settings.core['atol'])
+        # isequal uses both atol and rtol from settings.core
+        return _data.isequal(self._data, other._data)
 
     def __pow__(self, n, m=None):  # calculates powers of Qobj
         if (


Test patch:
diff --git a/qutip/tests/core/data/conftest.py b/qutip/tests/core/data/conftest.py
--- a/qutip/tests/core/data/conftest.py
+++ b/qutip/tests/core/data/conftest.py
@@ -67,7 +67,7 @@ def random_scipy_csr(shape, density, sorted_):
     cols = np.random.choice(np.arange(shape[1]), nnz)
     sci = scipy.sparse.coo_matrix((data, (rows, cols)), shape=shape).tocsr()
     if not sorted_:
-        shuffle_indices_scipy_csr(sci)
+        sci = shuffle_indices_scipy_csr(sci)
     return sci
 
 
diff --git a/qutip/tests/core/data/test_properties.py b/qutip/tests/core/data/test_properties.py
--- a/qutip/tests/core/data/test_properties.py
+++ b/qutip/tests/core/data/test_properties.py
@@ -3,6 +3,8 @@
 
 from qutip import data as _data
 from qutip import CoreOptions
+from . import conftest
+from qutip.core.data.dia import clean_dia
 
 @pytest.fixture(params=[_data.CSR, _data.Dense, _data.Dia], ids=["CSR", "Dense", "Dia"])
 def datatype(request):
@@ -177,3 +179,67 @@ def test_isdiag(self, shape, datatype):
         mat[1, 0] = 1
         data = _data.to(datatype, _data.Dense(mat))
         assert not _data.isdiag(data)
+
+
+class TestIsEqual:
+    def op_numpy(self, left, right, atol, rtol):
+        return np.allclose(left.to_array(), right.to_array(), rtol, atol)
+
+    def rand_dense(shape):
+        return conftest.random_dense(shape, False)
+
+    def rand_diag(shape):
+        return conftest.random_diag(shape, 0.5, True)
+
+    def rand_csr(shape):
+        return conftest.random_csr(shape, 0.5, True)
+
+    @pytest.mark.parametrize("factory", [rand_dense, rand_diag, rand_csr])
+    @pytest.mark.parametrize("shape", [(1, 20), (20, 20), (20, 2)])
+    def test_same_shape(self, factory, shape):
+        atol = 1e-8
+        rtol = 1e-6
+        A = factory(shape)
+        B = factory(shape)
+        assert _data.isequal(A, A, atol, rtol)
+        assert _data.isequal(B, B, atol, rtol)
+        assert (
+            _data.isequal(A, B, atol, rtol) == self.op_numpy(A, B, atol, rtol)
+        )
+
+    @pytest.mark.parametrize("factory", [rand_dense, rand_diag, rand_csr])
+    @pytest.mark.parametrize("shapeA", [(1, 10), (9, 9), (10, 2)])
+    @pytest.mark.parametrize("shapeB", [(1, 9), (10, 10), (10, 1)])
+    def test_different_shape(self, factory, shapeA, shapeB):
+        A = factory(shapeA)
+        B = factory(shapeB)
+        assert not _data.isequal(A, B, np.inf, np.inf)
+
+    @pytest.mark.parametrize("rtol", [1e-6, 100])
+    @pytest.mark.parametrize("factory", [rand_dense, rand_diag, rand_csr])
+    @pytest.mark.parametrize("shape", [(1, 20), (20, 20), (20, 2)])
+    def test_rtol(self, factory, shape, rtol):
+        mat = factory(shape)
+        assert _data.isequal(mat + mat * (rtol / 10), mat, 1e-14, rtol)
+        assert not _data.isequal(mat * (1 + rtol * 10), mat, 1e-14, rtol)
+
+    @pytest.mark.parametrize("atol", [1e-14, 1e-6, 100])
+    @pytest.mark.parametrize("factory", [rand_dense, rand_diag, rand_csr])
+    @pytest.mark.parametrize("shape", [(1, 20), (20, 20), (20, 2)])
+    def test_atol(self, factory, shape, atol):
+        A = factory(shape)
+        B = factory(shape)
+        assert _data.isequal(A, A + B * (atol / 10), atol, 0)
+        assert not _data.isequal(A, A + B * (atol * 10), atol, 0)
+
+    @pytest.mark.parametrize("shape", [(1, 20), (20, 20), (20, 2)])
+    def test_csr_mismatch_sort(self, shape):
+        A = conftest.random_csr(shape, 0.5, False)
+        B = A.copy().sort_indices()
+        assert _data.isequal(A, B)
+
+    @pytest.mark.parametrize("shape", [(1, 20), (20, 20), (20, 2)])
+    def test_dia_mismatch_sort(self, shape):
+        A = conftest.random_diag(shape, 0.5, False)
+        B = clean_dia(A)
+        assert _data.isequal(A, B)
diff --git a/qutip/tests/core/test_qobj.py b/qutip/tests/core/test_qobj.py
--- a/qutip/tests/core/test_qobj.py
+++ b/qutip/tests/core/test_qobj.py
@@ -442,6 +442,15 @@ def test_QobjEquals():
     q2 = qutip.Qobj(-data)
     assert q1 != q2
 
+    # data's entry are of order 1,
+    with qutip.CoreOptions(atol=10):
+        assert q1 == q2
+        assert q1 != q2 * 100
+
+    with qutip.CoreOptions(rtol=10):
+        assert q1 == q2
+        assert q1 == q2 * 100
+
 
 def test_QobjGetItem():
     "qutip.Qobj getitem"
@@ -1273,4 +1282,4 @@ def test_qobj_dtype(dtype):
 @pytest.mark.parametrize('dtype', ["CSR", "Dense", "Dia"])
 def test_dtype_in_info_string(dtype):
     obj = qutip.qeye(2, dtype=dtype)
-    assert dtype.lower() in str(obj).lower()
\ No newline at end of file
+    assert dtype.lower() in str(obj).lower()