Skip to content

Commit 5753731

Browse files
Tighten the graph-capture regression test
Assert every case against the closed-form integral instead of running an eager loop first and comparing the captured runs against it, and fold the eager run into the same parametrized set. Collapses three layers of helper plumbing into one, and subTest now reports every failing step count rather than stopping at the first.
1 parent f70837d commit 5753731

1 file changed

Lines changed: 36 additions & 52 deletions

File tree

newton/tests/test_actuators.py

Lines changed: 36 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -4095,55 +4095,46 @@ def _loop(
40954095
"CUDA graph capture requires CUDA device with memory pools",
40964096
)
40974097
class 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

Comments
 (0)