From 09a6896e45f15eb2652e476625c126b4dd720e4e Mon Sep 17 00:00:00 2001 From: Misha Savchenko Date: Tue, 25 Aug 2026 08:31:25 -0500 Subject: [PATCH 01/16] publishing twist command --- .../imetro_behavior/geometry_behaviors.py | 53 ++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/src/imetro_behavior/imetro_behavior/geometry_behaviors.py b/src/imetro_behavior/imetro_behavior/geometry_behaviors.py index 1235621..6bbbe13 100644 --- a/src/imetro_behavior/imetro_behavior/geometry_behaviors.py +++ b/src/imetro_behavior/imetro_behavior/geometry_behaviors.py @@ -29,7 +29,7 @@ from py_trees.common import Access, Status from py_trees.ports import BehaviourWithPorts, PortInformation import tf2_geometry_msgs -from geometry_msgs.msg import PoseStamped, Pose, Point, Quaternion, TransformStamped +from geometry_msgs.msg import PoseStamped, Pose, Point, Quaternion, TransformStamped, TwistStamped from std_msgs.msg import Header from tf2_ros import TransformBroadcaster @@ -712,3 +712,54 @@ def update(self) -> Status: transform_stamped = self.get_input("transform_stamped") self.tf_broadcaster.sendTransform(transform_stamped) return Status.SUCCESS + +class PublishTwist(BehaviourWithPorts): + + publisher = None + twist_stamped = None + """Sends an action goal to Nav2 to navigate to a pose.""" + @classmethod + def input_ports(cls) -> dict: + """Return the input port declarations.""" + return {"topic": PortInformation(data_type=str, required=True), + "linear": PortInformation(data_type=list[float], required=True), + "angular": PortInformation(data_type=list[float], required=True), + "frame_id": PortInformation(data_type=str, required=False)} + + @classmethod + def output_ports(cls) -> dict: + """Return the output port declarations.""" + return {} + + def setup(self, **kwargs): + """Setup transform broadcaster.""" + self.node = kwargs.get("node") + if not isinstance(self.node, Node): + raise KeyError(f"A valid ROS node is required to setup the '{self.qualified_name}' node.") + + def update(self) -> Status: + """Publish the transform frame.""" + if self.publisher is None: + topic_name = "/" + self.get_input("topic") + self.publisher = self.node.create_publisher(TwistStamped, topic_name, 1) + + if self.twist_stamped is None: + + self.twist_stamped = TwistStamped() + + linear = self.get_input("linear", [0.0, 0.0, 0.0]) + angular = self.get_input("angular", [0.0, 0.0, 0.0]) + + self.twist_stamped.twist.linear.x = linear[0] + self.twist_stamped.twist.linear.y = linear[1] + self.twist_stamped.twist.linear.z = linear[2] + + self.twist_stamped.twist.angular.x = angular[0] + self.twist_stamped.twist.angular.y = angular[1] + self.twist_stamped.twist.angular.z = angular[2] + + self.twist_stamped.header.stamp = self.node.get_clock().now().to_msg() + self.twist_stamped.header.frame_id = self.get_input("frame_id", "map") + + self.publisher.publish(self.twist_stamped) + return Status.SUCCESS \ No newline at end of file From 9048aa41a518e1775e1a991775822c67525743a4 Mon Sep 17 00:00:00 2001 From: Misha Savchenko Date: Tue, 25 Aug 2026 09:22:08 -0500 Subject: [PATCH 02/16] fixed the init, for publishing twist --- .../imetro_behavior/geometry_behaviors.py | 28 +++++++++++++------ 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/src/imetro_behavior/imetro_behavior/geometry_behaviors.py b/src/imetro_behavior/imetro_behavior/geometry_behaviors.py index 6bbbe13..271ab93 100644 --- a/src/imetro_behavior/imetro_behavior/geometry_behaviors.py +++ b/src/imetro_behavior/imetro_behavior/geometry_behaviors.py @@ -17,6 +17,8 @@ # License for the specific language governing permissions and limitations # under the License. +from typing import Any + import copy import yaml @@ -714,15 +716,26 @@ def update(self) -> Status: return Status.SUCCESS class PublishTwist(BehaviourWithPorts): - - publisher = None - twist_stamped = None """Sends an action goal to Nav2 to navigate to a pose.""" + def __init__( + self, + name: str, + *, + topic_name: str, + **kwargs: Any, + ) -> None: + """ + + """ + super().__init__(name, **kwargs) + self.topic_name = topic_name + self.publisher = None + self.twist_stamped = None + @classmethod def input_ports(cls) -> dict: """Return the input port declarations.""" - return {"topic": PortInformation(data_type=str, required=True), - "linear": PortInformation(data_type=list[float], required=True), + return {"linear": PortInformation(data_type=list[float], required=True), "angular": PortInformation(data_type=list[float], required=True), "frame_id": PortInformation(data_type=str, required=False)} @@ -740,8 +753,7 @@ def setup(self, **kwargs): def update(self) -> Status: """Publish the transform frame.""" if self.publisher is None: - topic_name = "/" + self.get_input("topic") - self.publisher = self.node.create_publisher(TwistStamped, topic_name, 1) + self.publisher = self.node.create_publisher(TwistStamped, self.topic_name, 1) if self.twist_stamped is None: @@ -762,4 +774,4 @@ def update(self) -> Status: self.twist_stamped.header.frame_id = self.get_input("frame_id", "map") self.publisher.publish(self.twist_stamped) - return Status.SUCCESS \ No newline at end of file + return Status.SUCCESS From 0505dddf8f4073357d3d9ed1a7eebef924a7608f Mon Sep 17 00:00:00 2001 From: Misha Savchenko Date: Tue, 25 Aug 2026 12:26:17 -0500 Subject: [PATCH 03/16] timeout behavior --- .../imetro_behavior/decorators.py | 56 ++++++++++++++++++- .../imetro_behavior/geometry_behaviors.py | 7 +-- 2 files changed, 58 insertions(+), 5 deletions(-) diff --git a/src/imetro_behavior/imetro_behavior/decorators.py b/src/imetro_behavior/imetro_behavior/decorators.py index 58081d1..da661d8 100644 --- a/src/imetro_behavior/imetro_behavior/decorators.py +++ b/src/imetro_behavior/imetro_behavior/decorators.py @@ -16,12 +16,13 @@ # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations # under the License. - +import time from collections.abc import Iterator from py_trees.behaviour import Behaviour from py_trees.common import Status from py_trees.decorators import Decorator +from py_trees import common from py_trees.ports import PortInformation, PortsMixin @@ -91,3 +92,56 @@ def tick(self) -> Iterator[Behaviour]: def update(self) -> Status: """Reflect the child's status, since it is only ticked when the variable is true.""" return self.decorated.status + +class TimeoutPort(PortsMixin, Decorator): + """ + Executes a child/subtree with a timeout. + + A decorator that applies a timeout pattern to an existing behaviour. + If the timeout is reached, the encapsulated behaviour's + :meth:`~py_trees.behaviour.Behaviour.stop` method is called with + status :data:`~py_trees.common.Status.FAILURE` otherwise it will + simply directly tick and return with the same status + as that of its encapsulated behaviour. + """ + + finish_time: float = None + + @classmethod + def input_ports(cls) -> dict: + """Return the input port declarations.""" + return {"duration": PortInformation(data_type=float, required=True)} + + @classmethod + def output_ports(cls) -> dict: + """Return the output port declarations.""" + return {} + + def update(self) -> Status: + """ + Fail on timeout, or block / reflect the child's result accordingly. + + Terminate the child and return + :data:`~py_trees.common.Status.FAILURE` + if the timeout is exceeded. + + Returns: + the behaviour's new status :class:`~py_trees.common.Status` + """ + + current_time = time.monotonic() + + if self.finish_time is None: + self.finish_time = current_time + self.get_input("duration") + + if self.decorated.status == common.Status.RUNNING and current_time > self.finish_time: + self.feedback_message = "timed out" + self.logger.debug(f"{self.__class__.__name__}.update() {self.feedback_message}") + # invalidate the decorated (i.e. cancel it), could also put this logic in a terminate() method + self.decorated.stop(common.Status.INVALID) + return common.Status.FAILURE + if self.decorated.status == common.Status.RUNNING: + self.feedback_message = f"time still ticking ... [remaining: {self.finish_time - current_time}s]" + else: + self.feedback_message = "child finished before timeout triggered" + return self.decorated.status diff --git a/src/imetro_behavior/imetro_behavior/geometry_behaviors.py b/src/imetro_behavior/imetro_behavior/geometry_behaviors.py index 271ab93..c663542 100644 --- a/src/imetro_behavior/imetro_behavior/geometry_behaviors.py +++ b/src/imetro_behavior/imetro_behavior/geometry_behaviors.py @@ -716,7 +716,9 @@ def update(self) -> Status: return Status.SUCCESS class PublishTwist(BehaviourWithPorts): - """Sends an action goal to Nav2 to navigate to a pose.""" + """ + + """ def __init__( self, name: str, @@ -724,9 +726,6 @@ def __init__( topic_name: str, **kwargs: Any, ) -> None: - """ - - """ super().__init__(name, **kwargs) self.topic_name = topic_name self.publisher = None From dc0471bf08f1a0733d803f5cfcff1ca90c5ba023 Mon Sep 17 00:00:00 2001 From: Misha Savchenko Date: Tue, 25 Aug 2026 12:47:00 -0500 Subject: [PATCH 04/16] docstring for PublishTwist behavior --- src/imetro_behavior/imetro_behavior/geometry_behaviors.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/imetro_behavior/imetro_behavior/geometry_behaviors.py b/src/imetro_behavior/imetro_behavior/geometry_behaviors.py index c663542..a932f1a 100644 --- a/src/imetro_behavior/imetro_behavior/geometry_behaviors.py +++ b/src/imetro_behavior/imetro_behavior/geometry_behaviors.py @@ -717,7 +717,9 @@ def update(self) -> Status: class PublishTwist(BehaviourWithPorts): """ - + Takes in `linear` and `angular` and converts them geometry_msgs/msg/Twist. + Creates std_msgs/msg/Header from `frame_id` input and current ROS 2 timestamp. + Combines both into geometry_msgs/msg/TwistStamped and publishes onto the `topic_name`. """ def __init__( self, @@ -744,13 +746,13 @@ def output_ports(cls) -> dict: return {} def setup(self, **kwargs): - """Setup transform broadcaster.""" + """Get the ROS node from the blackboard.""" self.node = kwargs.get("node") if not isinstance(self.node, Node): raise KeyError(f"A valid ROS node is required to setup the '{self.qualified_name}' node.") def update(self) -> Status: - """Publish the transform frame.""" + """Assemble and publish twist stamped message.""" if self.publisher is None: self.publisher = self.node.create_publisher(TwistStamped, self.topic_name, 1) From 60b7de8f23444338b966ee64261445afac118294 Mon Sep 17 00:00:00 2001 From: Misha Savchenko Date: Tue, 25 Aug 2026 12:51:12 -0500 Subject: [PATCH 05/16] added more to timeout port docstring --- src/imetro_behavior/imetro_behavior/decorators.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/imetro_behavior/imetro_behavior/decorators.py b/src/imetro_behavior/imetro_behavior/decorators.py index da661d8..53125d7 100644 --- a/src/imetro_behavior/imetro_behavior/decorators.py +++ b/src/imetro_behavior/imetro_behavior/decorators.py @@ -95,6 +95,11 @@ def update(self) -> Status: class TimeoutPort(PortsMixin, Decorator): """ + Reimplimentation of the Timeout behavior from the py_trees library + [Link](https://py-trees.readthedocs.io/en/devel/modules.html#py_trees.decorators.Timeout) for reference. + + NOTE: This behavior is supposed to be temporary. + Executes a child/subtree with a timeout. A decorator that applies a timeout pattern to an existing behaviour. From 46b4b4c188976095e0815e12d77cd158e36521db Mon Sep 17 00:00:00 2001 From: Misha Savchenko Date: Tue, 25 Aug 2026 12:51:56 -0500 Subject: [PATCH 06/16] pre-commit --- .../imetro_behavior/decorators.py | 15 ++--- .../imetro_behavior/geometry_behaviors.py | 60 ++++++++++--------- 2 files changed, 40 insertions(+), 35 deletions(-) diff --git a/src/imetro_behavior/imetro_behavior/decorators.py b/src/imetro_behavior/imetro_behavior/decorators.py index 53125d7..2c375f4 100644 --- a/src/imetro_behavior/imetro_behavior/decorators.py +++ b/src/imetro_behavior/imetro_behavior/decorators.py @@ -93,13 +93,14 @@ def update(self) -> Status: """Reflect the child's status, since it is only ticked when the variable is true.""" return self.decorated.status + class TimeoutPort(PortsMixin, Decorator): """ - Reimplimentation of the Timeout behavior from the py_trees library + Reimplementation of the Timeout behavior from the py_trees library [Link](https://py-trees.readthedocs.io/en/devel/modules.html#py_trees.decorators.Timeout) for reference. - + NOTE: This behavior is supposed to be temporary. - + Executes a child/subtree with a timeout. A decorator that applies a timeout pattern to an existing behaviour. @@ -110,8 +111,8 @@ class TimeoutPort(PortsMixin, Decorator): as that of its encapsulated behaviour. """ - finish_time: float = None - + finish_time: float = None + @classmethod def input_ports(cls) -> dict: """Return the input port declarations.""" @@ -136,8 +137,8 @@ def update(self) -> Status: current_time = time.monotonic() - if self.finish_time is None: - self.finish_time = current_time + self.get_input("duration") + if self.finish_time is None: + self.finish_time = current_time + self.get_input("duration") if self.decorated.status == common.Status.RUNNING and current_time > self.finish_time: self.feedback_message = "timed out" diff --git a/src/imetro_behavior/imetro_behavior/geometry_behaviors.py b/src/imetro_behavior/imetro_behavior/geometry_behaviors.py index a932f1a..e0719f7 100644 --- a/src/imetro_behavior/imetro_behavior/geometry_behaviors.py +++ b/src/imetro_behavior/imetro_behavior/geometry_behaviors.py @@ -715,12 +715,14 @@ def update(self) -> Status: self.tf_broadcaster.sendTransform(transform_stamped) return Status.SUCCESS + class PublishTwist(BehaviourWithPorts): """ Takes in `linear` and `angular` and converts them geometry_msgs/msg/Twist. Creates std_msgs/msg/Header from `frame_id` input and current ROS 2 timestamp. - Combines both into geometry_msgs/msg/TwistStamped and publishes onto the `topic_name`. + Combines both into geometry_msgs/msg/TwistStamped and publishes onto the `topic_name`. """ + def __init__( self, name: str, @@ -728,17 +730,19 @@ def __init__( topic_name: str, **kwargs: Any, ) -> None: - super().__init__(name, **kwargs) - self.topic_name = topic_name - self.publisher = None - self.twist_stamped = None + super().__init__(name, **kwargs) + self.topic_name = topic_name + self.publisher = None + self.twist_stamped = None @classmethod def input_ports(cls) -> dict: """Return the input port declarations.""" - return {"linear": PortInformation(data_type=list[float], required=True), - "angular": PortInformation(data_type=list[float], required=True), - "frame_id": PortInformation(data_type=str, required=False)} + return { + "linear": PortInformation(data_type=list[float], required=True), + "angular": PortInformation(data_type=list[float], required=True), + "frame_id": PortInformation(data_type=str, required=False), + } @classmethod def output_ports(cls) -> dict: @@ -749,30 +753,30 @@ def setup(self, **kwargs): """Get the ROS node from the blackboard.""" self.node = kwargs.get("node") if not isinstance(self.node, Node): - raise KeyError(f"A valid ROS node is required to setup the '{self.qualified_name}' node.") - + raise KeyError(f"A valid ROS node is required to setup the '{self.qualified_name}' node.") + def update(self) -> Status: """Assemble and publish twist stamped message.""" if self.publisher is None: - self.publisher = self.node.create_publisher(TwistStamped, self.topic_name, 1) - + self.publisher = self.node.create_publisher(TwistStamped, self.topic_name, 1) + if self.twist_stamped is None: - - self.twist_stamped = TwistStamped() - - linear = self.get_input("linear", [0.0, 0.0, 0.0]) - angular = self.get_input("angular", [0.0, 0.0, 0.0]) - - self.twist_stamped.twist.linear.x = linear[0] - self.twist_stamped.twist.linear.y = linear[1] - self.twist_stamped.twist.linear.z = linear[2] - - self.twist_stamped.twist.angular.x = angular[0] - self.twist_stamped.twist.angular.y = angular[1] - self.twist_stamped.twist.angular.z = angular[2] - - self.twist_stamped.header.stamp = self.node.get_clock().now().to_msg() - self.twist_stamped.header.frame_id = self.get_input("frame_id", "map") + + self.twist_stamped = TwistStamped() + + linear = self.get_input("linear", [0.0, 0.0, 0.0]) + angular = self.get_input("angular", [0.0, 0.0, 0.0]) + + self.twist_stamped.twist.linear.x = linear[0] + self.twist_stamped.twist.linear.y = linear[1] + self.twist_stamped.twist.linear.z = linear[2] + + self.twist_stamped.twist.angular.x = angular[0] + self.twist_stamped.twist.angular.y = angular[1] + self.twist_stamped.twist.angular.z = angular[2] + + self.twist_stamped.header.stamp = self.node.get_clock().now().to_msg() + self.twist_stamped.header.frame_id = self.get_input("frame_id", "map") self.publisher.publish(self.twist_stamped) return Status.SUCCESS From 1c4790cbaeb5270cbdbfec41e13dcef5e8d0bc01 Mon Sep 17 00:00:00 2001 From: Misha Savchenko Date: Tue, 25 Aug 2026 15:46:03 -0500 Subject: [PATCH 07/16] moved finish time to initialize method --- src/imetro_behavior/imetro_behavior/decorators.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/imetro_behavior/imetro_behavior/decorators.py b/src/imetro_behavior/imetro_behavior/decorators.py index 2c375f4..5cc4601 100644 --- a/src/imetro_behavior/imetro_behavior/decorators.py +++ b/src/imetro_behavior/imetro_behavior/decorators.py @@ -123,6 +123,11 @@ def output_ports(cls) -> dict: """Return the output port declarations.""" return {} + def initialise(self) -> None: + """Reset the feedback message and finish time on behaviour entry.""" + self.finish_time = time.monotonic() + self.get_input("duration") + self.feedback_message = "" + def update(self) -> Status: """ Fail on timeout, or block / reflect the child's result accordingly. @@ -137,8 +142,6 @@ def update(self) -> Status: current_time = time.monotonic() - if self.finish_time is None: - self.finish_time = current_time + self.get_input("duration") if self.decorated.status == common.Status.RUNNING and current_time > self.finish_time: self.feedback_message = "timed out" From 2b24c8022486731057ce82f9cfc516023d65ef42 Mon Sep 17 00:00:00 2001 From: Misha Savchenko Date: Tue, 25 Aug 2026 15:46:34 -0500 Subject: [PATCH 08/16] yoinked the testing from py_trees testing suite --- src/imetro_behavior/tests/test_decorators.py | 107 ++++++++++++++++++- 1 file changed, 106 insertions(+), 1 deletion(-) diff --git a/src/imetro_behavior/tests/test_decorators.py b/src/imetro_behavior/tests/test_decorators.py index edcfcf4..a28089b 100644 --- a/src/imetro_behavior/tests/test_decorators.py +++ b/src/imetro_behavior/tests/test_decorators.py @@ -16,12 +16,13 @@ # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations # under the License. +import time import py_trees from py_trees.blackboard import Blackboard from py_trees.common import Status -from imetro_behavior.decorators import SuccessIfVariableIsFalse, SuccessIfVariableIsTrue +from imetro_behavior.decorators import SuccessIfVariableIsFalse, SuccessIfVariableIsTrue, TimeoutPort def test_success_if_variable_is_true_skips_child() -> None: @@ -88,3 +89,107 @@ def test_success_if_variable_is_false_ticks_child() -> None: decorator.tick_once() assert decorator.status == Status.FAILURE assert child.status == Status.FAILURE + +def test_timeout() -> None: + running = py_trees.behaviours.Running(name="Running") + timeout = TimeoutPort(name="Timeout", child=running) + timeout.setup_ports() + + Blackboard.set(timeout._get_blackboard_key("duration"), 0.2) + + print(py_trees.display.unicode_tree(timeout)) + visitor = py_trees.visitors.DebugVisitor() + + # Test that it times out and re-initialises properly + for i in range(0, 2): + py_trees.tests.tick_tree(timeout, 2 * i + 1, 2 * i + 1, visitors=[visitor]) + + print("\n--------- Assertions ---------\n") + print("timeout.status == py_trees.common.Status.RUNNING") + assert timeout.status == py_trees.common.Status.RUNNING + print("running.status == py_trees.common.Status.RUNNING") + assert running.status == py_trees.common.Status.RUNNING + + time.sleep(0.3) + py_trees.tests.tick_tree(timeout, 2 * i + 2, 2 * i + 2, visitors=[visitor]) + + print("\n--------- Assertions ---------\n") + print("timeout.status == py_trees.common.Status.FAILURE") + assert timeout.status == py_trees.common.Status.FAILURE + print("running.status == py_trees.common.Status.INVALID") # type: ignore[unreachable] + assert running.status == py_trees.common.Status.INVALID + + # test that it passes on success + count = py_trees.behaviours.StatusQueue( + name="Queue", + queue=[py_trees.common.Status.RUNNING], + eventually=py_trees.common.Status.SUCCESS, + ) + timeout = TimeoutPort(name="Timeout", child=count) + timeout.setup_ports() + + Blackboard.set(timeout._get_blackboard_key("duration"), 0.2) + + print(py_trees.display.unicode_tree(timeout)) + + py_trees.tests.tick_tree(timeout, 1, 1, visitors=[visitor]) + + print("\n--------- Assertions ---------\n") + print("timeout.status == py_trees.common.Status.RUNNING") + assert timeout.status == py_trees.common.Status.RUNNING + print("count.status == py_trees.common.Status.RUNNING") + assert count.status == py_trees.common.Status.RUNNING + + py_trees.tests.tick_tree(timeout, 2, 2, visitors=[visitor]) + + print("\n--------- Assertions ---------\n") + print("timeout.status == py_trees.common.Status.SUCCESS") + assert timeout.status == py_trees.common.Status.SUCCESS + print("count.status == py_trees.common.Status.SUCCESS") # type: ignore[unreachable] + assert count.status == py_trees.common.Status.SUCCESS + + # test that it passes on failure + failure = py_trees.behaviours.Failure(name="Failure") + timeout = TimeoutPort(name="Timeout", child=failure) + timeout.setup_ports() + + Blackboard.set(timeout._get_blackboard_key("duration"), 0.2) + + print(py_trees.display.unicode_tree(timeout)) + + py_trees.tests.tick_tree(timeout, 1, 1, visitors=[visitor]) + + print("\n--------- Assertions ---------\n") + print("timeout.status == py_trees.common.Status.FAILURE") + assert timeout.status == py_trees.common.Status.FAILURE + print("failure.status == py_trees.common.Status.FAILURE") + assert failure.status == py_trees.common.Status.FAILURE + + # test that it succeeds if child succeeds on last tick + count = py_trees.behaviours.StatusQueue( + name="Queue", + queue=[py_trees.common.Status.RUNNING], + eventually=py_trees.common.Status.SUCCESS, + ) + timeout = TimeoutPort(name="Timeout", child=count) + timeout.setup_ports() + + Blackboard.set(timeout._get_blackboard_key("duration"), 0.1) + print(py_trees.display.unicode_tree(timeout)) + + py_trees.tests.tick_tree(timeout, 1, 1, visitors=[visitor]) + + print("\n--------- Assertions ---------\n") + print("timeout.status == py_trees.common.Status.RUNNING") + assert timeout.status == py_trees.common.Status.RUNNING + print("count.status == py_trees.common.Status.RUNNING") + assert count.status == py_trees.common.Status.RUNNING + + time.sleep(0.2) # go past the duration + py_trees.tests.tick_tree(timeout, 2, 2, visitors=[visitor]) + + print("\n--------- Assertions ---------\n") + print("timeout.status == py_trees.common.Status.SUCCESS") + assert timeout.status == py_trees.common.Status.SUCCESS + print("count.status == py_trees.common.Status.SUCCESS") + assert count.status == py_trees.common.Status.SUCCESS From 53d1f002210c917b718890bc07aada90e2b5cac7 Mon Sep 17 00:00:00 2001 From: Misha Savchenko Date: Tue, 25 Aug 2026 15:47:55 -0500 Subject: [PATCH 09/16] pre-commit --- src/imetro_behavior/imetro_behavior/decorators.py | 1 - src/imetro_behavior/tests/test_decorators.py | 9 +++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/imetro_behavior/imetro_behavior/decorators.py b/src/imetro_behavior/imetro_behavior/decorators.py index 5cc4601..4c67ccb 100644 --- a/src/imetro_behavior/imetro_behavior/decorators.py +++ b/src/imetro_behavior/imetro_behavior/decorators.py @@ -142,7 +142,6 @@ def update(self) -> Status: current_time = time.monotonic() - if self.decorated.status == common.Status.RUNNING and current_time > self.finish_time: self.feedback_message = "timed out" self.logger.debug(f"{self.__class__.__name__}.update() {self.feedback_message}") diff --git a/src/imetro_behavior/tests/test_decorators.py b/src/imetro_behavior/tests/test_decorators.py index a28089b..c59c4ab 100644 --- a/src/imetro_behavior/tests/test_decorators.py +++ b/src/imetro_behavior/tests/test_decorators.py @@ -90,11 +90,12 @@ def test_success_if_variable_is_false_ticks_child() -> None: assert decorator.status == Status.FAILURE assert child.status == Status.FAILURE + def test_timeout() -> None: running = py_trees.behaviours.Running(name="Running") timeout = TimeoutPort(name="Timeout", child=running) timeout.setup_ports() - + Blackboard.set(timeout._get_blackboard_key("duration"), 0.2) print(py_trees.display.unicode_tree(timeout)) @@ -127,7 +128,7 @@ def test_timeout() -> None: ) timeout = TimeoutPort(name="Timeout", child=count) timeout.setup_ports() - + Blackboard.set(timeout._get_blackboard_key("duration"), 0.2) print(py_trees.display.unicode_tree(timeout)) @@ -152,7 +153,7 @@ def test_timeout() -> None: failure = py_trees.behaviours.Failure(name="Failure") timeout = TimeoutPort(name="Timeout", child=failure) timeout.setup_ports() - + Blackboard.set(timeout._get_blackboard_key("duration"), 0.2) print(py_trees.display.unicode_tree(timeout)) @@ -173,7 +174,7 @@ def test_timeout() -> None: ) timeout = TimeoutPort(name="Timeout", child=count) timeout.setup_ports() - + Blackboard.set(timeout._get_blackboard_key("duration"), 0.1) print(py_trees.display.unicode_tree(timeout)) From 17b263b6e6dfd41cd50c053574bf08a9d49d0eb2 Mon Sep 17 00:00:00 2001 From: Misha Savchenko Date: Tue, 25 Aug 2026 17:17:12 -0500 Subject: [PATCH 10/16] removed redundant import and it's usage --- src/imetro_behavior/imetro_behavior/decorators.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/imetro_behavior/imetro_behavior/decorators.py b/src/imetro_behavior/imetro_behavior/decorators.py index 4c67ccb..a1a349c 100644 --- a/src/imetro_behavior/imetro_behavior/decorators.py +++ b/src/imetro_behavior/imetro_behavior/decorators.py @@ -22,7 +22,6 @@ from py_trees.behaviour import Behaviour from py_trees.common import Status from py_trees.decorators import Decorator -from py_trees import common from py_trees.ports import PortInformation, PortsMixin @@ -142,13 +141,13 @@ def update(self) -> Status: current_time = time.monotonic() - if self.decorated.status == common.Status.RUNNING and current_time > self.finish_time: + if self.decorated.status == Status.RUNNING and current_time > self.finish_time: self.feedback_message = "timed out" self.logger.debug(f"{self.__class__.__name__}.update() {self.feedback_message}") # invalidate the decorated (i.e. cancel it), could also put this logic in a terminate() method - self.decorated.stop(common.Status.INVALID) - return common.Status.FAILURE - if self.decorated.status == common.Status.RUNNING: + self.decorated.stop(Status.INVALID) + return Status.FAILURE + if self.decorated.status == Status.RUNNING: self.feedback_message = f"time still ticking ... [remaining: {self.finish_time - current_time}s]" else: self.feedback_message = "child finished before timeout triggered" From afa655cdaf3981202618c35f54e155ff8cc19818 Mon Sep 17 00:00:00 2001 From: Misha Savchenko Date: Tue, 25 Aug 2026 17:35:40 -0500 Subject: [PATCH 11/16] added more specificity to ports --- .../imetro_behavior/geometry_behaviors.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/imetro_behavior/imetro_behavior/geometry_behaviors.py b/src/imetro_behavior/imetro_behavior/geometry_behaviors.py index e0719f7..27b7712 100644 --- a/src/imetro_behavior/imetro_behavior/geometry_behaviors.py +++ b/src/imetro_behavior/imetro_behavior/geometry_behaviors.py @@ -718,7 +718,7 @@ def update(self) -> Status: class PublishTwist(BehaviourWithPorts): """ - Takes in `linear` and `angular` and converts them geometry_msgs/msg/Twist. + Takes in `linear` and `angular` velocities and converts them geometry_msgs/msg/Twist. Creates std_msgs/msg/Header from `frame_id` input and current ROS 2 timestamp. Combines both into geometry_msgs/msg/TwistStamped and publishes onto the `topic_name`. """ @@ -739,8 +739,8 @@ def __init__( def input_ports(cls) -> dict: """Return the input port declarations.""" return { - "linear": PortInformation(data_type=list[float], required=True), - "angular": PortInformation(data_type=list[float], required=True), + "linear_velocity": PortInformation(data_type=list[float], required=True), + "angular_velocity": PortInformation(data_type=list[float], required=True), "frame_id": PortInformation(data_type=str, required=False), } @@ -764,8 +764,8 @@ def update(self) -> Status: self.twist_stamped = TwistStamped() - linear = self.get_input("linear", [0.0, 0.0, 0.0]) - angular = self.get_input("angular", [0.0, 0.0, 0.0]) + linear = self.get_input("linear_velocity", [0.0, 0.0, 0.0]) + angular = self.get_input("angular_velocity", [0.0, 0.0, 0.0]) self.twist_stamped.twist.linear.x = linear[0] self.twist_stamped.twist.linear.y = linear[1] From af4587965593ec0ae4202050248c3d0dd1b9f3c2 Mon Sep 17 00:00:00 2001 From: Misha Savchenko Date: Tue, 25 Aug 2026 17:41:37 -0500 Subject: [PATCH 12/16] moved the message and publisher instantiation into inilize method --- .../imetro_behavior/geometry_behaviors.py | 42 +++++++++---------- 1 file changed, 20 insertions(+), 22 deletions(-) diff --git a/src/imetro_behavior/imetro_behavior/geometry_behaviors.py b/src/imetro_behavior/imetro_behavior/geometry_behaviors.py index 27b7712..7f5aab7 100644 --- a/src/imetro_behavior/imetro_behavior/geometry_behaviors.py +++ b/src/imetro_behavior/imetro_behavior/geometry_behaviors.py @@ -749,6 +749,25 @@ def output_ports(cls) -> dict: """Return the output port declarations.""" return {} + def initialise(self) -> None: + """Create TwistStamped message publisher, assemble twist message.""" + self.publisher = self.node.create_publisher(TwistStamped, self.topic_name, 1) + self.twist_stamped = TwistStamped() + + linear = self.get_input("linear_velocity", [0.0, 0.0, 0.0]) + angular = self.get_input("angular_velocity", [0.0, 0.0, 0.0]) + + self.twist_stamped.twist.linear.x = linear[0] + self.twist_stamped.twist.linear.y = linear[1] + self.twist_stamped.twist.linear.z = linear[2] + + self.twist_stamped.twist.angular.x = angular[0] + self.twist_stamped.twist.angular.y = angular[1] + self.twist_stamped.twist.angular.z = angular[2] + + self.twist_stamped.header.stamp = self.node.get_clock().now().to_msg() + self.twist_stamped.header.frame_id = self.get_input("frame_id", "map") + def setup(self, **kwargs): """Get the ROS node from the blackboard.""" self.node = kwargs.get("node") @@ -756,27 +775,6 @@ def setup(self, **kwargs): raise KeyError(f"A valid ROS node is required to setup the '{self.qualified_name}' node.") def update(self) -> Status: - """Assemble and publish twist stamped message.""" - if self.publisher is None: - self.publisher = self.node.create_publisher(TwistStamped, self.topic_name, 1) - - if self.twist_stamped is None: - - self.twist_stamped = TwistStamped() - - linear = self.get_input("linear_velocity", [0.0, 0.0, 0.0]) - angular = self.get_input("angular_velocity", [0.0, 0.0, 0.0]) - - self.twist_stamped.twist.linear.x = linear[0] - self.twist_stamped.twist.linear.y = linear[1] - self.twist_stamped.twist.linear.z = linear[2] - - self.twist_stamped.twist.angular.x = angular[0] - self.twist_stamped.twist.angular.y = angular[1] - self.twist_stamped.twist.angular.z = angular[2] - - self.twist_stamped.header.stamp = self.node.get_clock().now().to_msg() - self.twist_stamped.header.frame_id = self.get_input("frame_id", "map") - + """Publish twist stamped message.""" self.publisher.publish(self.twist_stamped) return Status.SUCCESS From a554d727e32a4e937fbe4682b6666a82ca639789 Mon Sep 17 00:00:00 2001 From: Misha Savchenko Date: Tue, 25 Aug 2026 17:46:00 -0500 Subject: [PATCH 13/16] terminate for publishing twist --- src/imetro_behavior/imetro_behavior/geometry_behaviors.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/imetro_behavior/imetro_behavior/geometry_behaviors.py b/src/imetro_behavior/imetro_behavior/geometry_behaviors.py index 7f5aab7..c411f9b 100644 --- a/src/imetro_behavior/imetro_behavior/geometry_behaviors.py +++ b/src/imetro_behavior/imetro_behavior/geometry_behaviors.py @@ -778,3 +778,8 @@ def update(self) -> Status: """Publish twist stamped message.""" self.publisher.publish(self.twist_stamped) return Status.SUCCESS + + def terminate(self, new_status: Status) -> Status: + """Cleanup the publisher""" + if self.status == Status.RUNNING and new_status == Status.INVALID: + self.publisher.destroy() From 0775ac8f1ac2ee3558e3d9c89d845a90660d7c99 Mon Sep 17 00:00:00 2001 From: Misha Savchenko Date: Tue, 25 Aug 2026 17:46:56 -0500 Subject: [PATCH 14/16] pre-commit --- src/imetro_behavior/imetro_behavior/geometry_behaviors.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/imetro_behavior/imetro_behavior/geometry_behaviors.py b/src/imetro_behavior/imetro_behavior/geometry_behaviors.py index c411f9b..a49a0b5 100644 --- a/src/imetro_behavior/imetro_behavior/geometry_behaviors.py +++ b/src/imetro_behavior/imetro_behavior/geometry_behaviors.py @@ -780,6 +780,6 @@ def update(self) -> Status: return Status.SUCCESS def terminate(self, new_status: Status) -> Status: - """Cleanup the publisher""" - if self.status == Status.RUNNING and new_status == Status.INVALID: - self.publisher.destroy() + """Cleanup the publisher""" + if self.status == Status.RUNNING and new_status == Status.INVALID: + self.publisher.destroy() From a3180fd4094187e05db779dc5c7e237e4c9ef37c Mon Sep 17 00:00:00 2001 From: Misha Savchenko Date: Tue, 25 Aug 2026 18:12:08 -0500 Subject: [PATCH 15/16] pared things down a bit --- src/imetro_behavior/tests/test_decorators.py | 88 +++++++------------- 1 file changed, 29 insertions(+), 59 deletions(-) diff --git a/src/imetro_behavior/tests/test_decorators.py b/src/imetro_behavior/tests/test_decorators.py index c59c4ab..ff57f89 100644 --- a/src/imetro_behavior/tests/test_decorators.py +++ b/src/imetro_behavior/tests/test_decorators.py @@ -93,104 +93,74 @@ def test_success_if_variable_is_false_ticks_child() -> None: def test_timeout() -> None: running = py_trees.behaviours.Running(name="Running") + timeout = TimeoutPort(name="Timeout", child=running) timeout.setup_ports() - Blackboard.set(timeout._get_blackboard_key("duration"), 0.2) - print(py_trees.display.unicode_tree(timeout)) - visitor = py_trees.visitors.DebugVisitor() - # Test that it times out and re-initialises properly for i in range(0, 2): - py_trees.tests.tick_tree(timeout, 2 * i + 1, 2 * i + 1, visitors=[visitor]) + timeout.tick_once() - print("\n--------- Assertions ---------\n") - print("timeout.status == py_trees.common.Status.RUNNING") - assert timeout.status == py_trees.common.Status.RUNNING - print("running.status == py_trees.common.Status.RUNNING") - assert running.status == py_trees.common.Status.RUNNING + assert timeout.status == Status.RUNNING + assert running.status == Status.RUNNING time.sleep(0.3) - py_trees.tests.tick_tree(timeout, 2 * i + 2, 2 * i + 2, visitors=[visitor]) - print("\n--------- Assertions ---------\n") - print("timeout.status == py_trees.common.Status.FAILURE") - assert timeout.status == py_trees.common.Status.FAILURE - print("running.status == py_trees.common.Status.INVALID") # type: ignore[unreachable] - assert running.status == py_trees.common.Status.INVALID + timeout.tick_once() + + assert timeout.status == Status.FAILURE + assert running.status == Status.INVALID # test that it passes on success count = py_trees.behaviours.StatusQueue( name="Queue", - queue=[py_trees.common.Status.RUNNING], - eventually=py_trees.common.Status.SUCCESS, + queue=[Status.RUNNING], + eventually=Status.SUCCESS, ) + timeout = TimeoutPort(name="Timeout", child=count) timeout.setup_ports() - Blackboard.set(timeout._get_blackboard_key("duration"), 0.2) - print(py_trees.display.unicode_tree(timeout)) + timeout.tick_once() + assert timeout.status == Status.RUNNING + assert count.status == Status.RUNNING - py_trees.tests.tick_tree(timeout, 1, 1, visitors=[visitor]) - - print("\n--------- Assertions ---------\n") - print("timeout.status == py_trees.common.Status.RUNNING") - assert timeout.status == py_trees.common.Status.RUNNING - print("count.status == py_trees.common.Status.RUNNING") - assert count.status == py_trees.common.Status.RUNNING - - py_trees.tests.tick_tree(timeout, 2, 2, visitors=[visitor]) - - print("\n--------- Assertions ---------\n") - print("timeout.status == py_trees.common.Status.SUCCESS") - assert timeout.status == py_trees.common.Status.SUCCESS - print("count.status == py_trees.common.Status.SUCCESS") # type: ignore[unreachable] - assert count.status == py_trees.common.Status.SUCCESS + timeout.tick_once() + assert timeout.status == Status.SUCCESS + assert count.status == Status.SUCCESS # test that it passes on failure failure = py_trees.behaviours.Failure(name="Failure") + timeout = TimeoutPort(name="Timeout", child=failure) timeout.setup_ports() - Blackboard.set(timeout._get_blackboard_key("duration"), 0.2) - print(py_trees.display.unicode_tree(timeout)) - - py_trees.tests.tick_tree(timeout, 1, 1, visitors=[visitor]) + timeout.tick_once() - print("\n--------- Assertions ---------\n") - print("timeout.status == py_trees.common.Status.FAILURE") - assert timeout.status == py_trees.common.Status.FAILURE - print("failure.status == py_trees.common.Status.FAILURE") - assert failure.status == py_trees.common.Status.FAILURE + assert timeout.status == Status.FAILURE + assert failure.status == Status.FAILURE # test that it succeeds if child succeeds on last tick count = py_trees.behaviours.StatusQueue( name="Queue", - queue=[py_trees.common.Status.RUNNING], - eventually=py_trees.common.Status.SUCCESS, + queue=[Status.RUNNING], + eventually=Status.SUCCESS, ) timeout = TimeoutPort(name="Timeout", child=count) timeout.setup_ports() Blackboard.set(timeout._get_blackboard_key("duration"), 0.1) - print(py_trees.display.unicode_tree(timeout)) - py_trees.tests.tick_tree(timeout, 1, 1, visitors=[visitor]) + timeout.tick_once() - print("\n--------- Assertions ---------\n") - print("timeout.status == py_trees.common.Status.RUNNING") - assert timeout.status == py_trees.common.Status.RUNNING - print("count.status == py_trees.common.Status.RUNNING") - assert count.status == py_trees.common.Status.RUNNING + assert timeout.status == Status.RUNNING + assert count.status == Status.RUNNING time.sleep(0.2) # go past the duration - py_trees.tests.tick_tree(timeout, 2, 2, visitors=[visitor]) + timeout.tick_once() - print("\n--------- Assertions ---------\n") - print("timeout.status == py_trees.common.Status.SUCCESS") - assert timeout.status == py_trees.common.Status.SUCCESS - print("count.status == py_trees.common.Status.SUCCESS") - assert count.status == py_trees.common.Status.SUCCESS + assert timeout.status == Status.SUCCESS + assert count.status == Status.SUCCESS From 434fc1a954b92ca48473e22c9c730bc4ad6f8b7c Mon Sep 17 00:00:00 2001 From: Misha Savchenko Date: Tue, 25 Aug 2026 18:21:22 -0500 Subject: [PATCH 16/16] simplified and seperated the tests --- src/imetro_behavior/tests/test_decorators.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/imetro_behavior/tests/test_decorators.py b/src/imetro_behavior/tests/test_decorators.py index ff57f89..4c18f2c 100644 --- a/src/imetro_behavior/tests/test_decorators.py +++ b/src/imetro_behavior/tests/test_decorators.py @@ -91,7 +91,7 @@ def test_success_if_variable_is_false_ticks_child() -> None: assert child.status == Status.FAILURE -def test_timeout() -> None: +def test_timeout_reinitialization() -> None: running = py_trees.behaviours.Running(name="Running") timeout = TimeoutPort(name="Timeout", child=running) @@ -112,6 +112,8 @@ def test_timeout() -> None: assert timeout.status == Status.FAILURE assert running.status == Status.INVALID + +def test_timeout_passthrough_success() -> None: # test that it passes on success count = py_trees.behaviours.StatusQueue( name="Queue", @@ -124,13 +126,15 @@ def test_timeout() -> None: Blackboard.set(timeout._get_blackboard_key("duration"), 0.2) timeout.tick_once() - assert timeout.status == Status.RUNNING assert count.status == Status.RUNNING + assert timeout.status == Status.RUNNING timeout.tick_once() - assert timeout.status == Status.SUCCESS assert count.status == Status.SUCCESS + assert timeout.status == Status.SUCCESS + +def test_timeout_passthrough_failure() -> None: # test that it passes on failure failure = py_trees.behaviours.Failure(name="Failure") @@ -140,9 +144,11 @@ def test_timeout() -> None: timeout.tick_once() - assert timeout.status == Status.FAILURE assert failure.status == Status.FAILURE + assert timeout.status == Status.FAILURE + +def test_timeout_status_passthrough_order() -> None: # test that it succeeds if child succeeds on last tick count = py_trees.behaviours.StatusQueue( name="Queue", @@ -156,11 +162,12 @@ def test_timeout() -> None: timeout.tick_once() - assert timeout.status == Status.RUNNING assert count.status == Status.RUNNING + assert timeout.status == Status.RUNNING time.sleep(0.2) # go past the duration + timeout.tick_once() - assert timeout.status == Status.SUCCESS assert count.status == Status.SUCCESS + assert timeout.status == Status.SUCCESS