From 02f8882ff3d83de38e087a1e51eeba7aff46e2c5 Mon Sep 17 00:00:00 2001 From: Peter Corke Date: Wed, 21 Oct 2026 17:05:33 +1000 Subject: [PATCH 1/2] fix(plot): PyPlot and PyPlot2 step() raise ValueError for a dt of zero or less plt.pause(0) does not return, because a timeout of zero means to wait until the event loop is stopped, so env.step(0) hung with no message. Co-Authored-By: Claude Sonnet 5.5 --- src/roboticstoolbox/backends/PyPlot/PyPlot.py | 13 +++- .../backends/PyPlot/PyPlot2.py | 10 ++- tests/test_step_dt.py | 66 +++++++++++++++++++ 3 files changed, 86 insertions(+), 3 deletions(-) create mode 100644 tests/test_step_dt.py diff --git a/src/roboticstoolbox/backends/PyPlot/PyPlot.py b/src/roboticstoolbox/backends/PyPlot/PyPlot.py index 7b27f7aa6..31b68831b 100644 --- a/src/roboticstoolbox/backends/PyPlot/PyPlot.py +++ b/src/roboticstoolbox/backends/PyPlot/PyPlot.py @@ -269,6 +269,7 @@ def step(self, dt: float = 0.05) -> None: :param dt: time step in seconds, defaults to 50 (0.05 s) :type dt: int, optional + :raises ValueError: if ``dt`` is not greater than zero ``env.step(args)`` triggers an update of the 3D scene in the matplotlib window referenced by ``env``. @@ -281,10 +282,18 @@ def step(self, dt: float = 0.05) -> None: updated in the internal state of the robot object. - The control type is defined by the robot object, and not all robot objects support all control types. - - Execution is blocked for the specified interval + - Execution is blocked for the specified interval, which must be + greater than zero: Matplotlib's ``pause(0)`` does not return, since a + timeout of zero means to wait until the event loop is stopped + .. versionchanged:: 1.5.0 + ``dt`` of zero or less raises ``ValueError``, it used to wait for ever. """ - + if dt <= 0: + raise ValueError( + f"dt is {dt}, it must be greater than zero: a pause of zero seconds " + "does not return" + ) # update the robot's state for rpl in self.robots: diff --git a/src/roboticstoolbox/backends/PyPlot/PyPlot2.py b/src/roboticstoolbox/backends/PyPlot/PyPlot2.py index 956dcd2b5..9d6fc1305 100644 --- a/src/roboticstoolbox/backends/PyPlot/PyPlot2.py +++ b/src/roboticstoolbox/backends/PyPlot/PyPlot2.py @@ -191,8 +191,16 @@ def step(self, dt: float = 0.05) -> None: by the robot object, and not all robot objects support all control types. + :param dt: time step in seconds, which must be greater than zero + :raises ValueError: if ``dt`` is not greater than zero: Matplotlib's + ``pause(0)`` does not return, since a timeout of zero means to wait + until the event loop is stopped """ - + if dt <= 0: + raise ValueError( + f"dt is {dt}, it must be greater than zero: a pause of zero seconds " + "does not return" + ) self._step_robots(dt) diff --git a/tests/test_step_dt.py b/tests/test_step_dt.py new file mode 100644 index 000000000..72a9f3482 --- /dev/null +++ b/tests/test_step_dt.py @@ -0,0 +1,66 @@ +""" +A time step of zero used to hang: ``plt.pause(0)`` waits until the event loop is stopped. +""" + +import matplotlib + +matplotlib.use("Agg") + +import matplotlib.pyplot as plt # noqa: E402 +import pytest # noqa: E402 + +import roboticstoolbox as rtb # noqa: E402 +from roboticstoolbox import ET2 # noqa: E402 +from roboticstoolbox.backends.PyPlot import PyPlot, PyPlot2 # noqa: E402 + +pytestmark = pytest.mark.timeout(20) + + +@pytest.fixture(autouse=True) +def close_figures(): + plt.close("all") + yield + plt.close("all") + + +@pytest.fixture +def env3d(): + env = PyPlot() + env.launch() + env.add(rtb.models.DH.Puma560(), readonly=True) + return env + + +@pytest.fixture +def env2d(): + env = PyPlot2() + env.launch() + env.add(rtb.Robot2(ET2.R() * ET2.tx(1) * ET2.R() * ET2.tx(1)), readonly=True) + return env + + +@pytest.mark.parametrize("dt", [0, 0.0, -0.05]) +def test_3d_step_needs_a_positive_dt(env3d, dt): + with pytest.raises(ValueError, match="greater than zero"): + env3d.step(dt) + + +@pytest.mark.parametrize("dt", [0, 0.0, -0.05]) +def test_2d_step_needs_a_positive_dt(env2d, dt): + with pytest.raises(ValueError, match="greater than zero"): + env2d.step(dt) + + +def test_a_small_dt_is_fine(env3d, env2d): + env3d.step(0.001) + env2d.step(0.001) + + +def test_the_default_dt_is_fine(env3d): + env3d.step() + + +def test_plot_with_a_zero_dt_raises_and_does_not_hang(): + puma = rtb.models.DH.Puma560() + with pytest.raises(ValueError, match="greater than zero"): + puma.plot(puma.qr, backend="pyplot", dt=0, block=False) From 7a9ddd9ff014509ed7cabdf675bd3d604a04b312 Mon Sep 17 00:00:00 2001 From: Peter Corke Date: Thu, 22 Oct 2026 07:36:37 +1000 Subject: [PATCH 2/2] fix(plot): only a window pauses for dt, so only there is a dt of zero an error The notebook animation draws a frame for each step and does not pause, and its tests (and users) pass dt=0. Raising for every render mode broke them on CI. The hang is plt.pause(0), which is only called in the window render mode. Co-Authored-By: Claude Sonnet 5.5 --- src/roboticstoolbox/backends/PyPlot/PyPlot.py | 15 +++++++++------ src/roboticstoolbox/backends/PyPlot/PyPlot2.py | 11 ++++++----- tests/test_step_dt.py | 16 ++++++++++++++++ 3 files changed, 31 insertions(+), 11 deletions(-) diff --git a/src/roboticstoolbox/backends/PyPlot/PyPlot.py b/src/roboticstoolbox/backends/PyPlot/PyPlot.py index cbbcd8024..f42c6a36e 100644 --- a/src/roboticstoolbox/backends/PyPlot/PyPlot.py +++ b/src/roboticstoolbox/backends/PyPlot/PyPlot.py @@ -269,7 +269,8 @@ def step(self, dt: float = 0.05) -> None: :param dt: time step in seconds, defaults to 50 (0.05 s) :type dt: int, optional - :raises ValueError: if ``dt`` is not greater than zero + :raises ValueError: if ``dt`` is not greater than zero in a window, where the + plot pauses for ``dt`` ``env.step(args)`` triggers an update of the 3D scene in the matplotlib window referenced by ``env``. @@ -282,14 +283,16 @@ def step(self, dt: float = 0.05) -> None: updated in the internal state of the robot object. - The control type is defined by the robot object, and not all robot objects support all control types. - - Execution is blocked for the specified interval, which must be - greater than zero: Matplotlib's ``pause(0)`` does not return, since a - timeout of zero means to wait until the event loop is stopped + - In a window the execution is blocked for the specified interval, which + must be greater than zero: Matplotlib's ``pause(0)`` does not return, + since a timeout of zero means to wait until the event loop is stopped. + In a notebook there is no pause and ``dt`` of zero is fine. .. versionchanged:: 1.5.0 - ``dt`` of zero or less raises ``ValueError``, it used to wait for ever. + In a window, ``dt`` of zero or less raises ``ValueError``, it used to wait + for ever. """ - if dt <= 0: + if dt <= 0 and self.render_mode == "window": raise ValueError( f"dt is {dt}, it must be greater than zero: a pause of zero seconds " "does not return" diff --git a/src/roboticstoolbox/backends/PyPlot/PyPlot2.py b/src/roboticstoolbox/backends/PyPlot/PyPlot2.py index b5fb148f4..c17aa3b5f 100644 --- a/src/roboticstoolbox/backends/PyPlot/PyPlot2.py +++ b/src/roboticstoolbox/backends/PyPlot/PyPlot2.py @@ -191,12 +191,13 @@ def step(self, dt: float = 0.05) -> None: by the robot object, and not all robot objects support all control types. - :param dt: time step in seconds, which must be greater than zero - :raises ValueError: if ``dt`` is not greater than zero: Matplotlib's - ``pause(0)`` does not return, since a timeout of zero means to wait - until the event loop is stopped + :param dt: time step in seconds, which must be greater than zero in a window, + where the plot pauses for ``dt``; in a notebook there is no pause + :raises ValueError: if ``dt`` is not greater than zero in a window: + Matplotlib's ``pause(0)`` does not return, since a timeout of zero means + to wait until the event loop is stopped """ - if dt <= 0: + if dt <= 0 and self.render_mode == "window": raise ValueError( f"dt is {dt}, it must be greater than zero: a pause of zero seconds " "does not return" diff --git a/tests/test_step_dt.py b/tests/test_step_dt.py index 72a9f3482..a5608d6f3 100644 --- a/tests/test_step_dt.py +++ b/tests/test_step_dt.py @@ -51,6 +51,22 @@ def test_2d_step_needs_a_positive_dt(env2d, dt): env2d.step(dt) +def inline_env(cls, robot): + env = cls() + env.launch(render_mode="notebook-inline") + env.add(robot, readonly=True) + return env + + +@pytest.mark.parametrize("dt", [0, 0.0]) +def test_in_a_notebook_there_is_no_pause_and_zero_is_fine(dt): + # the notebook animation draws a frame for each step and does not pause + inline_env(PyPlot, rtb.models.DH.Puma560()).step(dt) + inline_env( + PyPlot2, rtb.Robot2(ET2.R() * ET2.tx(1) * ET2.R() * ET2.tx(1)) + ).step(dt) + + def test_a_small_dt_is_fine(env3d, env2d): env3d.step(0.001) env2d.step(0.001)