Skip to content

Commit 9295833

Browse files
authored
deprecate: UnitQuaternion.interp1's shortest parameter is a no-op (#209)
## Summary - Found while reviewing #193: `UnitQuaternion.interp1()`'s `shortest` flag can never change its result. Every `UnitQuaternion` has a non-negative scalar part by construction (`qunit()`'s own docstring: "Scalar part is always positive"), confirmed empirically — 2000 random `Rx*Ry*Rz` compositions, plus explicit `UnitQuaternion([-1,0,0,0])` construction, all canonicalize to non-negative scalar part. - `interp1` always interpolates *from* the identity quaternion `[1,0,0,0]`. The dot product of identity with any unit quaternion is just that quaternion's own scalar part — always non-negative — so the `if dot < 0` branch `shortest` exists to trigger can never fire. - This is specific to `interp1`; `.interp()` (the two-quaternion form) is unaffected — there, `dot(q1, q2)` genuinely can go negative even when both operands individually have non-negative scalar parts, since neither operand is pinned to identity. - Drops the dead branch, warns with `DeprecationWarning` when `shortest=True` is passed, documents why in a docstring `.. note::`, and pins the no-op behavior with a regression test (identical results for `True`/`False` across several `s` values, warns only when `True`, silent on the default). ## Test plan - [x] `pytest tests/test_quaternion.py -v` — 37 passed, warning fires exactly 6 times (once per `shortest=True` call in the new test) - [x] `pytest tests/` — full suite, 343 passed, no regressions - [x] Grepped the codebase and docs for other `interp1(..., shortest=...)` callers — none found
1 parent a624b59 commit 9295833

2 files changed

Lines changed: 51 additions & 4 deletions

File tree

‎spatialmath/quaternion.py‎

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
# pylint: disable=invalid-name
1717
from __future__ import annotations
1818
import math
19+
import warnings
1920
import numpy as np
2021
from typing import Any
2122
import spatialmath.base as smb
@@ -2073,7 +2074,7 @@ def interp1(self, s: float = 0, shortest: Optional[bool] = False) -> UnitQuatern
20732074
"""
20742075
Interpolate a unit quaternion
20752076
2076-
:param shortest: Take the shortest path along the great circle
2077+
:param shortest: deprecated, has no effect
20772078
:param s: interpolation coefficient, range 0 to 1, or number of steps
20782079
:type s: array_like or int
20792080
:return: interpolated unit quaternion
@@ -2104,19 +2105,39 @@ def interp1(self, s: float = 0, shortest: Optional[bool] = False) -> UnitQuatern
21042105
21052106
.. note:: values of ``s`` are silently clipped to the range [0, 1]
21062107
2108+
.. note:: ``shortest`` can never change the result here. Every
2109+
``UnitQuaternion`` has a non-negative scalar part by construction
2110+
(see :func:`~spatialmath.base.quaternions.qunit`), and this method
2111+
always interpolates *from* the identity quaternion ``[1,0,0,0]``
2112+
-- whose dot product with any unit quaternion is just that
2113+
quaternion's own (always non-negative) scalar part. A negative
2114+
dot product is what ``shortest`` checks for to detect "the long
2115+
way round", and that condition can never occur here, unlike
2116+
:meth:`interp`, where it can.
2117+
2118+
.. deprecated:: 1.1.19
2119+
``shortest`` has no effect and will be removed in a future
2120+
release.
2121+
21072122
:seealso: :func:`~spatialmath.base.quaternions.qslerp`
21082123
"""
21092124
# TODO allow self to have len() > 1
21102125

2126+
if shortest:
2127+
warnings.warn(
2128+
"shortest has no effect on interp1 and will be removed in a "
2129+
"future release: interpolation from the identity quaternion "
2130+
"is always the shortest path",
2131+
DeprecationWarning,
2132+
)
2133+
21112134
if isinstance(s, int) and s > 1:
21122135
s = np.linspace(0, 1, s)
21132136
else:
21142137
s = smb.getvector(s)
21152138
s = np.clip(s, 0, 1) # enforce valid values
21162139

2117-
q0_endpoint, q0, q1, sin_theta, theta = _qslerp_prepare(
2118-
smb.qeye(), self.vec, shortest=shortest
2119-
)
2140+
q0_endpoint, q0, q1, sin_theta, theta = _qslerp_prepare(smb.qeye(), self.vec)
21202141
qi = []
21212142
for sk in s:
21222143
if sk == 0:

‎tests/test_quaternion.py‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import numpy.testing as nt
44
import unittest
55
from unittest.mock import patch
6+
import warnings
67

78
from spatialmath import *
89
from spatialmath.base import *
@@ -783,6 +784,31 @@ def test_interp_prepares_slerp_once(self):
783784
self.assertEqual(len(interpolate()), 5)
784785
self.assertEqual(dot.call_count, 1)
785786

787+
def test_interp1_shortest_deprecated(self):
788+
# shortest never had an effect on interp1: every UnitQuaternion has
789+
# a non-negative scalar part by construction (qunit()), and interp1
790+
# always interpolates from the identity quaternion, whose dot
791+
# product with any unit quaternion is just that quaternion's own
792+
# (always non-negative) scalar part. So the "long way round" branch
793+
# shortest exists to avoid is unreachable. Pin that True/False give
794+
# identical results, that it warns, and that leaving it at its
795+
# default doesn't.
796+
q = UnitQuaternion.Rx(4.5) # would be "the long way" if reachable
797+
798+
for s in (0, 0.25, 0.5, 0.75, 1):
799+
qcompare(q.interp1(s, shortest=False), q.interp1(s, shortest=True))
800+
801+
for a, b in zip(q.interp1(11, shortest=False), q.interp1(11, shortest=True)):
802+
qcompare(a, b)
803+
804+
with self.assertWarns(DeprecationWarning):
805+
q.interp1(0.5, shortest=True)
806+
807+
with warnings.catch_warnings():
808+
warnings.simplefilter("error")
809+
q.interp1(0.5) # default shortest=False must not warn
810+
q.interp1(0.5, shortest=False)
811+
786812
def test_increment(self):
787813
q = UnitQuaternion()
788814

0 commit comments

Comments
 (0)