@@ -77,6 +77,155 @@ def _build_rotational_d6(
7777 return builder .finalize (device = "cpu" ), d6
7878
7979
80+ @dataclass (frozen = True )
81+ class _Fixture :
82+ """Model and D6 layout metadata for one conformance fixture."""
83+
84+ model : newton .Model
85+ q_start : int
86+ qd_start : int
87+ target_q_start : int
88+
89+
90+ @dataclass (frozen = True )
91+ class _Probe :
92+ """Raw coordinate, velocity, and effort data from a solver rollout."""
93+
94+ q : np .ndarray
95+ qd : np .ndarray
96+ effort : np .ndarray
97+ coord_count : int
98+ dof_count : int
99+
100+
101+ def _build_fixture (
102+ fixed_base : bool ,
103+ axes : tuple [newton .Axis , newton .Axis , newton .Axis ],
104+ * ,
105+ stiffness : float = 0.0 ,
106+ drive_damping : float = 0.0 ,
107+ armature : float = 0.0 ,
108+ passive_damping : float = 0.0 ,
109+ lower : float | np .ndarray = - newton .MAXVAL ,
110+ upper : float | np .ndarray = newton .MAXVAL ,
111+ ) -> _Fixture :
112+ """Build a collision-free articulated rotational D6."""
113+ builder = newton .ModelBuilder (gravity = (0.0 , 0.0 , 0.0 ), up_axis = newton .Axis .Z )
114+ base = builder .add_link (mass = 2.0 , inertia = _BASE_INERTIA , label = "base" )
115+ link = builder .add_link (mass = 1.0 , inertia = _LINK_INERTIA , label = "link" )
116+ root = (
117+ builder .add_joint_fixed (parent = - 1 , child = base , label = "root" )
118+ if fixed_base
119+ else builder .add_joint_free (parent = - 1 , child = base , label = "root" )
120+ )
121+ lower_values = np .broadcast_to (lower , 3 )
122+ upper_values = np .broadcast_to (upper , 3 )
123+ configs = [
124+ newton .ModelBuilder .JointDofConfig (
125+ axis = axis ,
126+ target_pos = 0.0 ,
127+ target_vel = 0.0 ,
128+ target_ke = stiffness ,
129+ target_kd = drive_damping ,
130+ damping = passive_damping ,
131+ armature = armature ,
132+ limit_lower = float (lower_values [i ]),
133+ limit_upper = float (upper_values [i ]),
134+ limit_ke = 1.0e4 ,
135+ limit_kd = 100.0 ,
136+ )
137+ for i , axis in enumerate (axes )
138+ ]
139+ d6 = builder .add_joint_d6 (base , link , angular_axes = configs , label = "d6" )
140+ builder .add_articulation ([root , d6 ], label = "d6" )
141+ model = builder .finalize (device = _DEVICE )
142+ return _Fixture (
143+ model ,
144+ int (model .joint_q_start .numpy ()[d6 ]),
145+ int (model .joint_qd_start .numpy ()[d6 ]),
146+ int (model .joint_target_q_start .numpy ()[d6 ]),
147+ )
148+
149+
150+ def _make_solver (backend : str , model : newton .Model ) -> SolverKamino | SolverMuJoCo :
151+ """Create a configured collision-free conformance solver."""
152+ if backend == "kamino" :
153+ config = SolverKamino .Config (
154+ integrator = "euler" ,
155+ use_collision_detector = False ,
156+ use_fk_solver = False ,
157+ sparse_jacobian = True ,
158+ )
159+ config .constraints .alpha = 0.0
160+ config .constraints .beta = 0.1
161+ config .padmm .max_iterations = 200
162+ config .padmm .primal_tolerance = 1.0e-6
163+ config .padmm .dual_tolerance = 1.0e-6
164+ config .padmm .compl_tolerance = 1.0e-6
165+ return SolverKamino (model , config )
166+ if backend == "mjwarp" :
167+ return SolverMuJoCo (
168+ model , disable_contacts = True , integrator = "implicitfast" , iterations = 100 , use_mujoco_contacts = False
169+ )
170+ raise ValueError (f"Unsupported conformance backend: { backend } " )
171+
172+
173+ def _run (
174+ backend : str ,
175+ fixed_base : bool ,
176+ axes : tuple [newton .Axis , newton .Axis , newton .Axis ],
177+ * ,
178+ q : np .ndarray | None = None ,
179+ qd : np .ndarray | None = None ,
180+ effort : np .ndarray | None = None ,
181+ position_target : np .ndarray | None = None ,
182+ velocity_target : np .ndarray | None = None ,
183+ steps : int = 1 ,
184+ ** fixture_kwargs ,
185+ ) -> _Probe :
186+ """Run a D6 rollout and retain the raw D6 coordinate trajectory."""
187+ fixture = _build_fixture (fixed_base , axes , ** fixture_kwargs )
188+ state_in , state_out , control = fixture .model .state (), fixture .model .state (), fixture .model .control ()
189+ if q is not None :
190+ values = state_in .joint_q .numpy ()
191+ values [fixture .q_start : fixture .q_start + 3 ] = q
192+ state_in .joint_q .assign (values )
193+ if qd is not None :
194+ values = state_in .joint_qd .numpy ()
195+ values [fixture .qd_start : fixture .qd_start + 3 ] = qd
196+ state_in .joint_qd .assign (values )
197+ if effort is not None :
198+ values = np .zeros (fixture .model .joint_dof_count , dtype = np .float32 )
199+ values [fixture .qd_start : fixture .qd_start + 3 ] = effort
200+ control .joint_f .assign (values )
201+ if position_target is not None :
202+ values = control .joint_target_q .numpy ()
203+ values [fixture .target_q_start : fixture .target_q_start + 3 ] = position_target
204+ control .joint_target_q .assign (values )
205+ if velocity_target is not None :
206+ values = control .joint_target_qd .numpy ()
207+ values [fixture .qd_start : fixture .qd_start + 3 ] = velocity_target
208+ control .joint_target_qd .assign (values )
209+ newton .eval_fk (fixture .model , state_in .joint_q , state_in .joint_qd , state_in )
210+ solver = _make_solver (backend , fixture .model )
211+ contacts = Contacts (rigid_contact_max = 0 , soft_contact_max = 0 , device = _DEVICE ) if backend == "mjwarp" else None
212+ positions = [state_in .joint_q .numpy ()[fixture .q_start : fixture .q_start + 3 ].copy ()]
213+ velocities = [state_in .joint_qd .numpy ()[fixture .qd_start : fixture .qd_start + 3 ].copy ()]
214+ for _ in range (steps ):
215+ state_in .clear_forces ()
216+ solver .step (state_in , state_out , control , contacts , _DT )
217+ state_in , state_out = state_out , state_in
218+ positions .append (state_in .joint_q .numpy ()[fixture .q_start : fixture .q_start + 3 ].copy ())
219+ velocities .append (state_in .joint_qd .numpy ()[fixture .qd_start : fixture .qd_start + 3 ].copy ())
220+ return _Probe (
221+ np .stack (positions ),
222+ np .stack (velocities ),
223+ control .joint_f .numpy ()[fixture .qd_start : fixture .qd_start + 3 ].copy (),
224+ fixture .model .joint_coord_count ,
225+ fixture .model .joint_dof_count ,
226+ )
227+
228+
80229class TestGimbal (unittest .TestCase ):
81230 """Verify the rotational D6 representation."""
82231
@@ -272,154 +421,27 @@ def test_fk_reset_preserves_left_handed_coordinates_and_rates(self):
272421 state .joint_qd .numpy ()[qd_start : qd_start + 3 ], qd [qd_start : qd_start + 3 ], atol = 1.0e-5
273422 )
274423
275-
276- @dataclass (frozen = True )
277- class _Fixture :
278- """Model and D6 layout metadata for one conformance fixture."""
279-
280- model : newton .Model
281- q_start : int
282- qd_start : int
283- target_q_start : int
284-
285-
286- @dataclass (frozen = True )
287- class _Probe :
288- """Raw coordinate, velocity, and effort data from a solver rollout."""
289-
290- q : np .ndarray
291- qd : np .ndarray
292- effort : np .ndarray
293- coord_count : int
294- dof_count : int
295-
296-
297- def _build_fixture (
298- fixed_base : bool ,
299- axes : tuple [newton .Axis , newton .Axis , newton .Axis ],
300- * ,
301- stiffness : float = 0.0 ,
302- drive_damping : float = 0.0 ,
303- armature : float = 0.0 ,
304- passive_damping : float = 0.0 ,
305- lower : float | np .ndarray = - newton .MAXVAL ,
306- upper : float | np .ndarray = newton .MAXVAL ,
307- ) -> _Fixture :
308- """Build a collision-free articulated rotational D6."""
309- builder = newton .ModelBuilder (gravity = (0.0 , 0.0 , 0.0 ), up_axis = newton .Axis .Z )
310- base = builder .add_link (mass = 2.0 , inertia = _BASE_INERTIA , label = "base" )
311- link = builder .add_link (mass = 1.0 , inertia = _LINK_INERTIA , label = "link" )
312- root = (
313- builder .add_joint_fixed (parent = - 1 , child = base , label = "root" )
314- if fixed_base
315- else builder .add_joint_free (parent = - 1 , child = base , label = "root" )
316- )
317- lower_values = np .broadcast_to (lower , 3 )
318- upper_values = np .broadcast_to (upper , 3 )
319- configs = [
320- newton .ModelBuilder .JointDofConfig (
321- axis = axis ,
322- target_pos = 0.0 ,
323- target_vel = 0.0 ,
324- target_ke = stiffness ,
325- target_kd = drive_damping ,
326- damping = passive_damping ,
327- armature = armature ,
328- limit_lower = float (lower_values [i ]),
329- limit_upper = float (upper_values [i ]),
330- limit_ke = 1.0e4 ,
331- limit_kd = 100.0 ,
332- )
333- for i , axis in enumerate (axes )
334- ]
335- d6 = builder .add_joint_d6 (base , link , angular_axes = configs , label = "d6" )
336- builder .add_articulation ([root , d6 ], label = "d6" )
337- model = builder .finalize (device = _DEVICE )
338- return _Fixture (
339- model ,
340- int (model .joint_q_start .numpy ()[d6 ]),
341- int (model .joint_qd_start .numpy ()[d6 ]),
342- int (model .joint_target_q_start .numpy ()[d6 ]),
343- )
344-
345-
346- def _make_solver (backend : str , model : newton .Model ) -> SolverKamino | SolverMuJoCo :
347- """Create a configured collision-free conformance solver."""
348- if backend == "kamino" :
349- config = SolverKamino .Config (
350- integrator = "euler" ,
351- use_collision_detector = False ,
352- use_fk_solver = False ,
353- sparse_jacobian = True ,
354- )
355- config .constraints .alpha = 0.0
356- config .constraints .beta = 0.1
357- config .padmm .max_iterations = 200
358- config .padmm .primal_tolerance = 1.0e-6
359- config .padmm .dual_tolerance = 1.0e-6
360- config .padmm .compl_tolerance = 1.0e-6
361- return SolverKamino (model , config )
362- if backend == "mjwarp" :
363- return SolverMuJoCo (
364- model , disable_contacts = True , integrator = "implicitfast" , iterations = 100 , use_mujoco_contacts = False
365- )
366- raise ValueError (f"Unsupported conformance backend: { backend } " )
367-
368-
369- def _run (
370- backend : str ,
371- fixed_base : bool ,
372- axes : tuple [newton .Axis , newton .Axis , newton .Axis ],
373- * ,
374- q : np .ndarray | None = None ,
375- qd : np .ndarray | None = None ,
376- effort : np .ndarray | None = None ,
377- position_target : np .ndarray | None = None ,
378- velocity_target : np .ndarray | None = None ,
379- steps : int = 1 ,
380- ** fixture_kwargs ,
381- ) -> _Probe :
382- """Run a D6 rollout and retain the raw D6 coordinate trajectory."""
383- fixture = _build_fixture (fixed_base , axes , ** fixture_kwargs )
384- state_in , state_out , control = fixture .model .state (), fixture .model .state (), fixture .model .control ()
385- if q is not None :
386- values = state_in .joint_q .numpy ()
387- values [fixture .q_start : fixture .q_start + 3 ] = q
388- state_in .joint_q .assign (values )
389- if qd is not None :
390- values = state_in .joint_qd .numpy ()
391- values [fixture .qd_start : fixture .qd_start + 3 ] = qd
392- state_in .joint_qd .assign (values )
393- if effort is not None :
394- values = np .zeros (fixture .model .joint_dof_count , dtype = np .float32 )
395- values [fixture .qd_start : fixture .qd_start + 3 ] = effort
396- control .joint_f .assign (values )
397- if position_target is not None :
398- values = control .joint_target_q .numpy ()
399- values [fixture .target_q_start : fixture .target_q_start + 3 ] = position_target
400- control .joint_target_q .assign (values )
401- if velocity_target is not None :
402- values = control .joint_target_qd .numpy ()
403- values [fixture .qd_start : fixture .qd_start + 3 ] = velocity_target
404- control .joint_target_qd .assign (values )
405- newton .eval_fk (fixture .model , state_in .joint_q , state_in .joint_qd , state_in )
406- solver = _make_solver (backend , fixture .model )
407- contacts = Contacts (rigid_contact_max = 0 , soft_contact_max = 0 , device = _DEVICE ) if backend == "mjwarp" else None
408- positions = [state_in .joint_q .numpy ()[fixture .q_start : fixture .q_start + 3 ].copy ()]
409- velocities = [state_in .joint_qd .numpy ()[fixture .qd_start : fixture .qd_start + 3 ].copy ()]
410- for _ in range (steps ):
411- state_in .clear_forces ()
412- solver .step (state_in , state_out , control , contacts , _DT )
413- state_in , state_out = state_out , state_in
414- positions .append (state_in .joint_q .numpy ()[fixture .q_start : fixture .q_start + 3 ].copy ())
415- velocities .append (state_in .joint_qd .numpy ()[fixture .qd_start : fixture .qd_start + 3 ].copy ())
416- return _Probe (
417- np .stack (positions ),
418- np .stack (velocities ),
419- control .joint_f .numpy ()[fixture .qd_start : fixture .qd_start + 3 ].copy (),
420- fixture .model .joint_coord_count ,
421- fixture .model .joint_dof_count ,
422- )
424+ def test_limits (self ):
425+ """Drive each D6 coordinate to its own position limit."""
426+ lower = np .array ([- 0.15 , - 0.25 , - 0.35 ], dtype = np .float32 )
427+ upper = np .array ([0.2 , 0.3 , 0.4 ], dtype = np .float32 )
428+ target = np .array ([0.6 , - 0.6 , 0.6 ], dtype = np .float32 )
429+ expected = np .where (target > 0.0 , upper , lower )
430+ for axes in (_RH_AXES , _LH_AXES ):
431+ for fixed_base in (True , False ):
432+ with self .subTest (axes = axes , fixed_base = fixed_base ):
433+ probe = _run (
434+ "kamino" ,
435+ fixed_base ,
436+ axes ,
437+ position_target = target ,
438+ stiffness = 100.0 ,
439+ drive_damping = 15.0 ,
440+ lower = lower ,
441+ upper = upper ,
442+ steps = 50 ,
443+ )
444+ np .testing .assert_allclose (probe .q [- 1 ], expected , atol = 1.0e-2 , rtol = 0.0 )
423445
424446
425447@unittest .skipUnless (wp .get_cuda_device_count (), "requires CUDA device" )
@@ -530,30 +552,6 @@ def test_unwrapped_pd_targets(self):
530552 ** kwargs ,
531553 )
532554
533- def test_limits (self ):
534- """Drive each D6 coordinate to its own position limit."""
535- lower = np .array ([- 0.15 , - 0.25 , - 0.35 ], dtype = np .float32 )
536- upper = np .array ([0.2 , 0.3 , 0.4 ], dtype = np .float32 )
537- target = np .array ([0.6 , - 0.6 , 0.6 ], dtype = np .float32 )
538- expected = np .where (target > 0.0 , upper , lower )
539- for axes in (_RH_AXES , _LH_AXES ):
540- for fixed_base in (True , False ):
541- with self .subTest (axes = axes , fixed_base = fixed_base ):
542- for backend in ("mjwarp" , "kamino" ):
543- with self .subTest (backend = backend ):
544- probe = _run (
545- backend ,
546- fixed_base ,
547- axes ,
548- position_target = target ,
549- stiffness = 100.0 ,
550- drive_damping = 15.0 ,
551- lower = lower ,
552- upper = upper ,
553- steps = 120 ,
554- )
555- np .testing .assert_allclose (probe .q [- 1 ], expected , atol = 1.0e-2 , rtol = 0.0 )
556-
557555 def test_passive_damping (self ):
558556 """Match passive-damping D6 behavior."""
559557 for axes in (_RH_AXES , _LH_AXES ):
0 commit comments