Skip to content

Commit e344d52

Browse files
Merge branch 'main' into main
2 parents 6acfb9f + ebe6cb1 commit e344d52

File tree

10 files changed

+197
-9
lines changed

10 files changed

+197
-9
lines changed

CHANGELOG.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,15 @@
22

33
All notable changes to this project will be documented in this file.
44

5-
## [0.0.14] - 2025-09-26
6-
7-
### Changed
5+
## Unreleased
86

97
- Added joint locking via QP equality constraints as an alternative to removing the joints from the Mujoco model.
108

9+
### Added
10+
11+
* Improve test coverage.
12+
* Add more tests to `test_configuration_limit.py` and `test_velocity_limit.py`.
13+
1114
## [0.0.13] - 2025-09-12
1215

1316
### Bugfix

mink/configuration.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -250,10 +250,10 @@ def get_inertia_matrix(self) -> np.ndarray:
250250
"""
251251
# Run the composite rigid body inertia (CRB) algorithm to populate the joint
252252
# space inertia matrix data.qM.
253-
if mujoco.mj_version() >= 334:
253+
if mujoco.mj_version() >= 334: # pragma: no branch
254254
mujoco.mj_makeM(self.model, self.data)
255255
else:
256-
mujoco.mj_crb(self.model, self.data)
256+
mujoco.mj_crb(self.model, self.data) # pragma: no cover
257257
# data.qM is stored in a custom sparse format and can be converted to dense
258258
# format using mujoco.mj_fullM.
259259
M = np.empty((self.nv, self.nv), dtype=np.float64)

mink/lie/se3.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,12 @@ class SE3(MatrixLieGroup):
2828
tangent_dim: int = 6
2929
space_dim: int = 3
3030

31+
def __post_init__(self) -> None:
32+
if self.wxyz_xyz.shape != (self.parameters_dim,):
33+
raise ValueError(
34+
f"Expeced wxyz_xyz to be a length 7 vector but got {self.wxyz_xyz.shape[0]}."
35+
)
36+
3137
def __repr__(self) -> str:
3238
quat = np.round(self.wxyz_xyz[:4], 5)
3339
xyz = np.round(self.wxyz_xyz[4:], 5)
@@ -38,6 +44,9 @@ def __eq__(self, other: object) -> bool:
3844
return NotImplemented
3945
return np.array_equal(self.wxyz_xyz, other.wxyz_xyz)
4046

47+
def __hash__(self) -> int:
48+
return hash(self.wxyz_xyz.tobytes())
49+
4150
def copy(self) -> SE3:
4251
return SE3(wxyz_xyz=np.array(self.wxyz_xyz))
4352

mink/lie/so3.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,9 @@ def __eq__(self, other: object) -> bool:
4949
return NotImplemented
5050
return np.array_equal(self.wxyz, other.wxyz)
5151

52+
def __hash__(self) -> int:
53+
return hash(self.wxyz.tobytes())
54+
5255
def parameters(self) -> np.ndarray:
5356
return self.wxyz
5457

mink/tasks/equality_constraint_task.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ def _get_dense_constraint_jacobian(
5050
"""Return the dense constraint Jacobian for a model."""
5151

5252
def _sparse2dense(data: mujoco.MjData) -> np.ndarray:
53-
if mujoco.__version__ < "3.2.5":
53+
if mujoco.__version__ < "3.2.5": # pragma: no cover
5454
efc_J = np.zeros((data.nefc, model.nv)) # Important to zero out here.
5555
_sparse2dense_fallback(
5656
efc_J,
@@ -59,7 +59,7 @@ def _sparse2dense(data: mujoco.MjData) -> np.ndarray:
5959
data.efc_J_rowadr,
6060
data.efc_J_colind,
6161
)
62-
else:
62+
else: # pragma: no branch
6363
efc_J = np.empty((data.nefc, model.nv))
6464
mujoco.mju_sparse2dense(
6565
efc_J,

pyproject.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,6 @@ exclude_also = [
108108
"if theta_sq < get_epsilon(theta_sq\\.dtype):",
109109
"def ljac",
110110
"def rjac",
111-
"if mujoco.__version__"
112111
]
113112
omit = [
114113
"*exceptions.py",

tests/test_collision_avoidance_limit.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,69 @@ def test_contact_normal_jac_matches_mujoco(self):
133133

134134
np.testing.assert_allclose(jac, efc_J, atol=1e-7)
135135

136+
def test_qp_upper_bound_branches_with_active_contact(self):
137+
"""Exercise both upper-bound branches with an active contact.
138+
139+
We place two touching spheres (hi_bound_dist=0) and use a large detection
140+
distance so the contact is active. With a positive minimum_distance, h equals
141+
the bound_relaxation (else branch); with a negative minimum_distance, h exceeds
142+
the relaxation (if branch). G remains identical across both runs.
143+
"""
144+
xml = """
145+
<mujoco>
146+
<worldbody>
147+
<body name="left">
148+
<joint type="free"/>
149+
<geom name="gL" type="sphere" size="0.01" pos="0 0 0"/>
150+
</body>
151+
<body name="right">
152+
<joint type="free"/>
153+
<geom name="gR" type="sphere" size="0.01" pos="0.02 0 0"/>
154+
</body>
155+
</worldbody>
156+
</mujoco>
157+
"""
158+
model = mujoco.MjModel.from_xml_string(xml)
159+
cfg = Configuration(model)
160+
cfg.update(np.zeros(model.nq))
161+
162+
detect = 0.5
163+
dt = 1e-3
164+
165+
# hi_bound_dist (0) <= minimum_distance_from_collisions (positive).
166+
limit_else = CollisionAvoidanceLimit(
167+
model=model,
168+
geom_pairs=[(["gL"], ["gR"])],
169+
collision_detection_distance=detect,
170+
minimum_distance_from_collisions=5e-4, # 0 <= 5e-4 -> else
171+
bound_relaxation=1e-4,
172+
)
173+
G_else, h_else = limit_else.compute_qp_inequalities(cfg, dt)
174+
175+
# All rows that correspond to the active pair should be exactly the relaxation.
176+
self.assertGreater(h_else.size, 0)
177+
self.assertTrue(np.any(np.isfinite(h_else)))
178+
self.assertTrue(np.all(h_else[np.isfinite(h_else)] >= 1e-4 - 1e-12))
179+
180+
# hi_bound_dist (0) > minimum_distance_from_collisions (negative).
181+
limit_if = CollisionAvoidanceLimit(
182+
model=model,
183+
geom_pairs=[(["gL"], ["gR"])],
184+
collision_detection_distance=detect,
185+
minimum_distance_from_collisions=-5e-3, # 0 > -5e-3 -> if
186+
bound_relaxation=1e-4,
187+
gain=0.85,
188+
)
189+
G_if, h_if = limit_if.compute_qp_inequalities(cfg, dt)
190+
191+
# h should be strictly larger than relaxation now (gain*dist/dt + relaxation)
192+
self.assertGreater(h_if.size, 0)
193+
self.assertTrue(np.any(h_if[np.isfinite(h_if)] > 1e-4 + 1e-12))
194+
195+
# G should be identical for the same geometry, only h changes.
196+
self.assertEqual(G_else.shape, G_if.shape)
197+
np.testing.assert_allclose(G_else, G_if, atol=0, rtol=0)
198+
136199

137200
if __name__ == "__main__":
138201
absltest.main()

tests/test_configuration_limit.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import numpy as np
55
from absl.testing import absltest
66
from robot_descriptions.loaders.mujoco import load_robot_description
7+
from scipy.optimize import linprog
78

89
from mink import Configuration
910
from mink.exceptions import LimitDefinitionError
@@ -155,6 +156,40 @@ def test_configuration_limit_repulsion(self, tol=1e-10):
155156
self.assertLess(np.max(h), slack_vel * dt + tol)
156157
self.assertGreater(np.min(h), -slack_vel * dt - tol)
157158

159+
def test_configuration_limit_dt_invariance(self):
160+
"""Inequalities are invariant to dt."""
161+
limit = ConfigurationLimit(self.model, gain=0.95)
162+
G1, h1 = limit.compute_qp_inequalities(self.configuration, dt=1e-3)
163+
G2, h2 = limit.compute_qp_inequalities(self.configuration, dt=0.2)
164+
self.assertTrue(np.allclose(G1, G2))
165+
self.assertTrue(np.allclose(h1, h2))
166+
167+
def test_feasible_step_respects_position_bounds(self, tol=1e-9):
168+
"""A strictly feasible Δq (for GΔq ≤ h) keeps q_next inside [lower, upper]."""
169+
limit = ConfigurationLimit(self.model, gain=1.0)
170+
171+
# dt is irrelevant for configuration limits; pass anything.
172+
G, h = limit.compute_qp_inequalities(self.configuration, dt=0.1)
173+
174+
# We use linprog to construct a strictly feasible delta_q for the inequality
175+
# set `G Δq ≤ h - eps``. Shrinking the RHS by eps guarantees strict slack if a
176+
# solution exists. linprog with a zero cost is a convenient feasibility solver.
177+
eps = 1e-3
178+
c = np.zeros(self.configuration.nv)
179+
res = linprog(c, A_ub=G, b_ub=h - eps, bounds=(None, None), method="highs")
180+
assert res.success, "Could not find a strictly feasible Δq"
181+
delta_q = res.x
182+
183+
# Integrate one second with `v = delta_q` (so Δq = v * 1).
184+
q_next = self.configuration.integrate(delta_q, dt=1.0)
185+
186+
# Check the next configuration is inside the true bounds for limited joints.
187+
lower = limit.lower[limit.indices]
188+
upper = limit.upper[limit.indices]
189+
qn = q_next[limit.indices]
190+
assert np.all(qn <= upper + tol)
191+
assert np.all(qn >= lower - tol)
192+
158193

159194
if __name__ == "__main__":
160195
absltest.main()

tests/test_lie_operations.py

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
from mink.exceptions import InvalidMocapBody
1010
from mink.lie.base import MatrixLieGroup
11-
from mink.lie.se3 import SE3
11+
from mink.lie.se3 import SE3, _getQ
1212
from mink.lie.so3 import SO3
1313

1414
from .utils import assert_transforms_close
@@ -142,6 +142,12 @@ def test_so3_apply(self):
142142
rotated_vec = rot.apply(vec)
143143
np.testing.assert_allclose(rotated_vec, rot.as_matrix() @ vec)
144144

145+
def test_so3_matmul_with_vector_calls_apply(self):
146+
"""Using @ with an ndarray should delegate to .apply(...)."""
147+
vec = np.random.rand(3)
148+
rot = SO3.sample_uniform()
149+
np.testing.assert_allclose(rot @ vec, rot.apply(vec))
150+
145151
def test_so3_apply_throws_assertion_error_if_wrong_shape(self):
146152
rot = SO3.sample_uniform()
147153
vec = np.random.rand(2)
@@ -178,6 +184,10 @@ def test_so3_clamp(self):
178184

179185
# SE3.
180186

187+
def test_se3_raises_error_if_invalid_shape(self):
188+
with self.assertRaises(ValueError):
189+
SE3(wxyz_xyz=np.random.rand(2))
190+
181191
def test_se3_equality(self):
182192
pose_1 = SE3.identity()
183193
pose_2 = SE3.identity()
@@ -201,6 +211,12 @@ def test_se3_apply(self):
201211
T.apply(v), T.as_matrix()[:3, :3] @ v + T.translation()
202212
)
203213

214+
def test_se3_matmul_with_vector_calls_apply(self):
215+
"""Using @ with an ndarray should delegate to .apply(...)."""
216+
vec = np.random.rand(3)
217+
T = SE3.sample_uniform()
218+
np.testing.assert_allclose(T @ vec, T.apply(vec))
219+
204220
def test_se3_from_mocap_id(self):
205221
xml_str = """
206222
<mujoco>
@@ -335,5 +351,57 @@ def test_se3_clamp(self):
335351
self.assertAlmostEqual(clamped_rpy.yaw, np.pi)
336352

337353

354+
class TestHashAndSetMembership(absltest.TestCase):
355+
"""Test that SO3 and SE3 objects can be hashed and used in sets."""
356+
357+
def test_so3_hash_and_set_membership(self):
358+
a = SO3.from_rpy_radians(0.1, -0.2, 0.3)
359+
b = SO3(wxyz=a.wxyz.copy())
360+
c = SO3.from_rpy_radians(0.1, -0.2, 0.31)
361+
assert a == b
362+
assert hash(a) == hash(b)
363+
s = {a, b, c}
364+
assert a in s and b in s and c in s
365+
assert len(s) == 2
366+
367+
def test_se3_hash_and_set_membership(self):
368+
R = SO3.from_rpy_radians(0.05, 0.02, -0.01)
369+
t = np.array([0.3, -0.1, 0.2], dtype=np.float64)
370+
a = SE3.from_rotation_and_translation(R, t)
371+
b = SE3.from_rotation_and_translation(SO3(wxyz=R.wxyz.copy()), t.copy())
372+
c = SE3.from_rotation_and_translation(R, t + np.array([1e-3, 0.0, 0.0]))
373+
assert a == b
374+
assert hash(a) == hash(b)
375+
s = {a, b, c}
376+
assert len(s) == 2
377+
378+
379+
class TestSE3_getQ(absltest.TestCase):
380+
"""Covers both small-angle and general branches in mink.lie.se3._getQ."""
381+
382+
def test__getQ_small_angle_zero(self):
383+
# theta == 0 (small-angle branch); with v=0 too, Q should be exactly zero.
384+
c_small = np.zeros(6, dtype=np.float64)
385+
Q = _getQ(c_small)
386+
np.testing.assert_allclose(Q, np.zeros((3, 3), dtype=np.float64))
387+
388+
def test__getQ_general_branch_nontrivial(self):
389+
# Non-zero rotation (general branch).
390+
# Use non-zero v to avoid the trivial zero result from the small-angle test.
391+
c = np.zeros(6, dtype=np.float64)
392+
c[:3] = np.array([0.3, -0.2, 0.1], dtype=np.float64) # v
393+
c[3:] = np.array([0.4, 0.0, 0.0], dtype=np.float64) # omega (theta ≈ 0.4 > 0)
394+
395+
Q = _getQ(c)
396+
self.assertEqual(Q.shape, (3, 3))
397+
self.assertTrue(np.isfinite(Q).all())
398+
399+
# Sanity: changing v (with same omega) should change Q.
400+
c_scaled = c.copy()
401+
c_scaled[:3] *= 2.0
402+
Q_scaled = _getQ(c_scaled)
403+
self.assertGreater(np.linalg.norm(Q - Q_scaled), 1e-9)
404+
405+
338406
if __name__ == "__main__":
339407
absltest.main()

tests/test_velocity_limit.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,14 @@ def test_that_freejoint_raises_error(self):
140140
expected_error_message = "Free joint floating is not supported"
141141
self.assertEqual(str(cm.exception), expected_error_message)
142142

143+
def test_velocity_limit_scales_with_dt(self):
144+
"""RHS scales linearly with dt (sanity check for Δq = v*dt)."""
145+
vlim = VelocityLimit(self.model, self.velocities)
146+
G1, h1 = vlim.compute_qp_inequalities(self.configuration, dt=0.1)
147+
G2, h2 = vlim.compute_qp_inequalities(self.configuration, dt=0.2)
148+
assert np.allclose(G1, G2)
149+
assert np.allclose(h2, 2.0 * h1)
150+
143151

144152
if __name__ == "__main__":
145153
absltest.main()

0 commit comments

Comments
 (0)