Repository: simpeg/simpeg (python)

Issue:
Unintended behaviour of `SimPEG.utils.coterminal`?
## Question

If we pass angles equal to $-\pi$ or $\pi$ we'll get $\pi$ or $-\pi$, respectively. This means that the function will return floats with different signs for this pair of coterminal angles ($-\pi$ and $\pi$ are coterminal angles).

For example:

```python
import numpy as np
from SimPEG.utils import coterminal

angles = np.array([-np.pi, np.pi])
print(coterminal(angles))
```
```
[ 3.14159265 -3.14159265]
```

This may cause issues when trying to regularize such angles: if one cell has a vector with angle $-\pi$, and the next one has an angle of $\pi$, then a smoothness regularization on angles will introduce a penalty (because the difference between these two floats is non-zero), while it shouldn't because there's no change in angles between the two cells.

**Is this the intended behaviour?**

## Idea

Wouldn't be better if the function returns angles in the interval $(-\pi, \pi]$ ?
In that case, passing $-\pi$ and $\pi$ would return the same value for both of them. Therefore it should have the following behaviour for the previous example:


```python
import numpy as np
from SimPEG.utils import coterminal

angles = np.array([-np.pi, np.pi])
print(coterminal(angles))
```
```
[ 3.14159265 3.14159265]
```

## If we decide to go forward with this

If we want to change the behaviour of this function I think we should:

1. Modify the implementation of `SimPEG.utils.coterminal` accordingly.
2. Improve the docstrings to reflect the new change. Probably the $\gamma$ angle could be defined as belonging to $(-\pi, \pi]$.
3. Add test functions that check the function under these circumstances:
    - Passing $-\pi$, $\pi$, $3\pi$, $-3\pi$ should return the same angle: $\pi$
    - Are angles within the interval not being modified?
    - Are angles outisde the interval being correctly brought to that interval?

Pull request:
Fix implementation of coterminal function
Fix the implementation of the `coterminal` function: use the `mod` operator to ensure that the functions returns the correct coterminal angle defined in $[-\pi, \pi)$. Add more tests.

Fixes #1333

---

PR to address issue #1333 , where the sign of coterminal angles is not preserved. Also fixes potential issues with angle > $2 \pi$

Running the example/03-magnetics/plot_inv_mag_MVI_Sparse_TreeMesh

Before
![old](https://github.com/simpeg/simpeg/assets/55204635/5c8cfa8d-28f9-47d7-8918-bb57c180ad8f)



After
![new](https://github.com/simpeg/simpeg/assets/55204635/bbb7a89d-d49e-4c70-83ff-5928b821a370)



Patch:
diff --git a/SimPEG/utils/mat_utils.py b/SimPEG/utils/mat_utils.py
index 5d128d997..c7b3d07fc 100644
--- a/SimPEG/utils/mat_utils.py
+++ b/SimPEG/utils/mat_utils.py
@@ -396,7 +396,7 @@ def coterminal(theta):
         \theta = 2\pi N + \gamma
 
     and *N* is an integer, the function returns the value of :math:`\gamma`.
-    The coterminal angle :math:`\gamma` is within the range :math:`[-\pi , \pi]`.
+    The coterminal angle :math:`\gamma` is within the range :math:`[-\pi , \pi)`.
 
     Parameters
     ----------
@@ -409,12 +409,8 @@ def coterminal(theta):
         Coterminal angles
 
     """
-    sub = theta[np.abs(theta) >= np.pi]
-    sub = -np.sign(sub) * (2 * np.pi - np.abs(sub))
-
-    theta[np.abs(theta) >= np.pi] = sub
-
-    return theta
+    coterminal = (theta + np.pi) % (2 * np.pi) - np.pi
+    return coterminal
 
 
 def define_plane_from_points(xyz1, xyz2, xyz3):


Test patch:
diff --git a/tests/base/test_utils.py b/tests/base/test_utils.py
index 541ed1642..5952b755f 100644
--- a/tests/base/test_utils.py
+++ b/tests/base/test_utils.py
@@ -1,4 +1,5 @@
 import unittest
+import pytest
 import numpy as np
 import scipy.sparse as sp
 import os
@@ -22,6 +23,7 @@ from SimPEG.utils import (
     Counter,
     download,
     surface2ind_topo,
+    coterminal,
 )
 import discretize
 
@@ -342,5 +344,35 @@ class TestDownload(unittest.TestCase):
         shutil.rmtree(os.path.expanduser("./test_url"))
 
 
+class TestCoterminalAngle:
+    """
+    Tests for the coterminal function
+    """
+
+    @pytest.mark.parametrize(
+        "coterminal_angle",
+        (1 / 4 * np.pi, 3 / 4 * np.pi, -3 / 4 * np.pi, -1 / 4 * np.pi),
+        ids=("pi/4", "3/4 pi", "-3/4 pi", "-pi/4"),
+    )
+    def test_angles_in_quadrants(self, coterminal_angle):
+        """
+        Test coterminal for angles in each quadrant
+        """
+        angles = np.array([2 * n * np.pi + coterminal_angle for n in range(-3, 4)])
+        np.testing.assert_allclose(coterminal(angles), coterminal_angle)
+
+    @pytest.mark.parametrize(
+        "coterminal_angle",
+        (0, np.pi / 2, -np.pi, -np.pi / 2),
+        ids=("0", "pi/2", "-pi", "-pi/2"),
+    )
+    def test_right_angles(self, coterminal_angle):
+        """
+        Test coterminal for right angles
+        """
+        angles = np.array([2 * n * np.pi + coterminal_angle for n in range(-3, 4)])
+        np.testing.assert_allclose(coterminal(angles), coterminal_angle)
+
+
 if __name__ == "__main__":
     unittest.main()