Skip to content

Commit 6ed7eff

Browse files
authored
Fold latitude angles correctly across complete turns (#241)
`wrap_mpi2_pi2()` returns latitude angles outside its documented `[-pi/2, pi/2]` range after complete turns: `wrap_mpi2_pi2(2*pi)` currently returns `-2*pi`, and `3*pi/2` is left unchanged. Reduce complete turns while preserving the sign and precision of small angles, then reflect the remaining angle about the poles. Scalar/vector return behavior is unchanged, and the general `angle_wrap(..., mode="-pi/2:pi/2")` dispatcher receives the same correction. Regression tests cover positive/negative complete and half turns from -4*pi through4*pi, scalar/vector forms, the dispatcher, both sides of each pole, exact1e-16 angles and empty vectors. The multiple-turn regression fails before the fix. All20 vector tests pass after it; the broader base suite passes149 tests with1 skip and5 existing warnings. The repository-pinned Black23.10.0 and `git diff --check` pass. Prepared with AI assistance.
1 parent 65daaad commit 6ed7eff

2 files changed

Lines changed: 28 additions & 7 deletions

File tree

‎spatialmath/base/vectors.py‎

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -567,13 +567,10 @@ def wrap_mpi2_pi2(theta: ArrayLike) -> Union[float, NDArray]:
567567
568568
"""
569569
theta = getvector(theta)
570-
n = theta / np.pi * 2
571-
if isinstance(n, np.ndarray):
572-
n = n.astype(int)
573-
else:
574-
n = np.fix(n).astype(int)
575-
576-
y = np.where(np.bitwise_and(n, 1) == 0, theta - n * np.pi, n * np.pi - theta)
570+
theta = np.fmod(theta, 2 * np.pi)
571+
theta = np.where(theta > np.pi, theta - 2 * np.pi, theta)
572+
theta = np.where(theta < -np.pi, theta + 2 * np.pi, theta)
573+
y = np.where(np.abs(theta) <= np.pi / 2, theta, np.copysign(np.pi, theta) - theta)
577574
if isinstance(y, np.ndarray) and len(y) == 1:
578575
return float(y[0])
579576
else:

‎tests/base/test_vectors.py‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,30 @@ def test_angdiff(self):
278278
nt.assert_array_almost_equal(x, [0, 0, 0])
279279
self.assertIsInstance(x, np.ndarray)
280280

281+
def test_latitude_wrap_multiple_turns(self):
282+
angles = pi * np.arange(-4, 4.5, 0.5)
283+
expected = pi * np.array(
284+
[0, 0.5, 0, -0.5, 0, 0.5, 0, -0.5, 0, 0.5, 0, -0.5, 0, 0.5, 0, -0.5, 0]
285+
)
286+
nt.assert_allclose(wrap_mpi2_pi2(angles), expected, atol=1e-14)
287+
nt.assert_allclose(angle_wrap(angles, mode="-pi/2:pi/2"), expected, atol=1e-14)
288+
for angle, folded in zip(angles, expected):
289+
self.assertAlmostEqual(wrap_mpi2_pi2(angle), folded)
290+
291+
def test_latitude_wrap_boundaries_and_small_angles(self):
292+
epsilon = 1e-12
293+
angles = np.array(
294+
[-pi / 2 - epsilon, -pi / 2 + epsilon, pi / 2 - epsilon, pi / 2 + epsilon]
295+
)
296+
expected = np.array(
297+
[-pi / 2 + epsilon, -pi / 2 + epsilon, pi / 2 - epsilon, pi / 2 - epsilon]
298+
)
299+
nt.assert_allclose(wrap_mpi2_pi2(angles), expected, rtol=0, atol=1e-15)
300+
nt.assert_allclose(
301+
wrap_mpi2_pi2([1e-16, -1e-16]), [1e-16, -1e-16], rtol=0, atol=0
302+
)
303+
self.assertEqual(wrap_mpi2_pi2([]).size, 0)
304+
281305
def test_wrap(self):
282306
self.assertAlmostEqual(wrap_0_2pi(0), 0)
283307
self.assertAlmostEqual(wrap_0_2pi(2 * pi), 0)

0 commit comments

Comments
 (0)