@@ -4095,55 +4095,46 @@ def _loop(
40954095 "CUDA graph capture requires CUDA device with memory pools" ,
40964096)
40974097class TestControllerStateGraphCapture (unittest .TestCase ):
4098- """Verify controller state advances across graph replays for any captured step count.
4098+ """Controller state must advance per replay, not only for an even captured step count.
40994099
4100- ``Actuator.step `` publishes the advanced state on-device, so a captured
4101- region advances state on every replay whatever number of steps it holds.
4102- An odd count above one used to discard the last step 's update, and a single
4103- captured step never advanced at all, because only the caller's host-side
4104- name swap moved the state forward .
4100+ A PID with only ``ki `` set, held at a constant position error by a model no
4101+ solver moves, accumulates exactly ``ki * error * dt`` per actuator step
4102+ however the loop is chunked. While only the caller 's host-side swap
4103+ advanced state, an odd captured count above one discarded the last step's
4104+ update and a single captured step never advanced at all .
41054105 """
41064106
41074107 DT = 0.01
41084108 KI = 1.0
41094109 TARGET = 1.0
4110- TOTAL_STEPS = 12
4110+ STEPS = 12
41114111
4112- def _build (
4113- self , device : wp .Device , implicit : bool
4114- ) -> tuple [Actuator , ResponseOracle | None , newton .State , newton .Control ]:
4115- """PID with ki only, driving a pendulum that no solver moves.
4112+ def _integral_after_all_steps (self , implicit : bool , steps_per_graph : int | None ) -> float :
4113+ """Run :attr:`STEPS` actuator steps, eagerly or as replays of a captured region.
41164114
4117- The position error stays at :attr:`TARGET`, so the integral must grow by
4118- exactly ``KI * TARGET * DT`` per actuator step however the loop is
4119- chunked. Both effort modes advance the integral through the same
4120- double-buffered state .
4115+ Args:
4116+ implicit: Solve the control law implicitly instead of explicitly.
4117+ Both effort modes advance the integral through the same state.
4118+ steps_per_graph: Steps per captured region, or ``None`` to run eagerly .
41214119 """
4120+ device = wp .get_device ()
41224121 builder = newton .ModelBuilder (gravity = (0.0 , 0.0 , 0.0 ))
4122+ # Mass and inertia only matter to the implicit response oracle.
41234123 body = builder .add_link (com = wp .vec3 (0.5 , 0.0 , 0.0 ), inertia = _POINT_MASS_INERTIA , mass = 1.0 )
41244124 joint = builder .add_joint_revolute (parent = - 1 , child = body , axis = newton .Axis .Z )
41254125 builder .add_articulation ([joint ])
41264126 builder .add_actuator (ControllerPID , index = builder .joint_qd_start [joint ], kp = 0.0 , ki = self .KI , kd = 0.0 )
41274127 model = builder .finalize (device = device )
41284128
41294129 actuator = model .actuators [0 ]
4130- oracle = None
4131- if implicit :
4132- oracle = ResponseOracle (model )
4130+ oracle = ResponseOracle (model ) if implicit else None
4131+ if oracle is not None :
41334132 actuator .set_effort_mode_implicit (response = oracle )
4134- state = model .state ()
4135- control = model .control ()
4136- control .joint_target_q .fill_ (self .TARGET )
4137- return actuator , oracle , state , control
4138-
4139- def _integral (self , act_state : Actuator .State ) -> float :
4140- return float (act_state .controller_state .integral .numpy ()[0 ])
4141-
4142- def _run (self , implicit : bool ) -> None :
4143- device = wp .get_device ()
4144- expected = self .KI * self .TARGET * self .DT * self .TOTAL_STEPS
4133+ state , control = model .state (), model .control ()
4134+ control .joint_target_q .fill_ (self .TARGET ) # joint_q stays 0, so the error is constant
4135+ s0 , s1 = actuator .state (), actuator .state ()
41454136
4146- def loop ( actuator , oracle , state , control , s0 , s1 , steps ):
4137+ def run ( s0 , s1 , steps ):
41474138 """The documented stateful loop, host-side swap included."""
41484139 for _ in range (steps ):
41494140 control .joint_f .zero_ ()
@@ -4153,38 +4144,31 @@ def loop(actuator, oracle, state, control, s0, s1, steps):
41534144 s0 , s1 = s1 , s0
41544145 return s0 , s1
41554146
4156- actuator , oracle , state , control = self ._build (device , implicit )
4157- s0 , s1 = actuator .state (), actuator .state ()
4158- s0 , s1 = loop (actuator , oracle , state , control , s0 , s1 , self .TOTAL_STEPS )
4159- eager = self ._integral (s0 )
4160- self .assertAlmostEqual (eager , expected , places = 6 , msg = "eager integral must accumulate every step" )
4161-
4162- for steps_per_graph in (1 , 2 , 3 ):
4163- actuator , oracle , state , control = self ._build (device , implicit )
4164- s0 , s1 = actuator .state (), actuator .state ()
4147+ if steps_per_graph is None :
4148+ s0 , s1 = run (s0 , s1 , self .STEPS )
4149+ else :
41654150 # Module loading and lazy allocation have to happen before a capture.
4166- s0 , s1 = loop ( actuator , oracle , state , control , s0 , s1 , 1 )
4151+ s0 , s1 = run ( s0 , s1 , 1 )
41674152 s0 .controller_state .integral .zero_ ()
41684153 s1 .controller_state .integral .zero_ ()
4169-
41704154 with wp .ScopedCapture (device ) as capture :
4171- s0 , s1 = loop ( actuator , oracle , state , control , s0 , s1 , steps_per_graph )
4172- for _ in range (self .TOTAL_STEPS // steps_per_graph ):
4155+ s0 , s1 = run ( s0 , s1 , steps_per_graph )
4156+ for _ in range (self .STEPS // steps_per_graph ):
41734157 wp .capture_launch (capture .graph )
4174- wp .synchronize_device (device )
4158+ wp .synchronize_device (device )
4159+ return float (s0 .controller_state .integral .numpy ()[0 ])
41754160
4176- self .assertAlmostEqual (
4177- self ._integral (s0 ),
4178- eager ,
4179- places = 6 ,
4180- msg = f"{ steps_per_graph } step(s) per graph must match eager over { self .TOTAL_STEPS } steps" ,
4181- )
4161+ def _assert_integral_matches (self , implicit : bool ) -> None :
4162+ expected = self .KI * self .TARGET * self .DT * self .STEPS
4163+ for steps_per_graph in (None , 1 , 2 , 3 ):
4164+ with self .subTest (steps_per_graph = steps_per_graph ):
4165+ self .assertAlmostEqual (self ._integral_after_all_steps (implicit , steps_per_graph ), expected , places = 6 )
41824166
41834167 def test_pid_integral_advances_per_replay_explicit (self ):
4184- self ._run (implicit = False )
4168+ self ._assert_integral_matches (implicit = False )
41854169
41864170 def test_pid_integral_advances_per_replay_implicit (self ):
4187- self ._run (implicit = True )
4171+ self ._assert_integral_matches (implicit = True )
41884172
41894173
41904174# ---------------------------------------------------------------------------
0 commit comments