Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions docs/ports.rst
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,80 @@ Example::
<Greeting name="hello_node" name_key="{target}" prefix="Howdy"/>
<!-- ^^^ behaviour name ^^^ port remap ^^^ ctor kwarg -->

.. _ports-xml-default-inputs-label:

Setting default inputs for subtrees
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

A ``<BehaviorTree>`` definition can carry attributes of its own.
Every attribute other than ``ID`` and ``name`` is a **default input** for that tree:
a value used for the matching key whenever an instance of the tree does not supply one itself.
This lets a reusable subtree ship with sensible values baked in, so callers only have to override what they actually care about.

.. code-block:: xml

<root main_tree_to_execute="MainTree">
<BehaviorTree ID="Greeter" greeting="Hello" volume="5">
<Sequence>
<Speak name="Speak" text="{greeting}" volume="{volume}"/>
</Sequence>
</BehaviorTree>

<BehaviorTree ID="MainTree">
<Sequence>
<SubTree ID="Greeter" name="Default"/> <!-- "Hello", 5 -->
<SubTree ID="Greeter" name="Loud" volume="11"/> <!-- "Hello", 11 -->
<SubTree ID="Greeter" name="Custom" greeting="Howdy"/> <!-- "Howdy", 5 -->
</Sequence>
</BehaviorTree>
</root>

Resolution is per key: an attribute on the ``<SubTree>`` element wins, and every key it leaves out
falls back to the default on the ``<BehaviorTree>`` definition.
Defaults are written exactly like any other XML attribute value.
A literal constant is type-converted according to the declared port type
(so ``volume="5"`` reaches an ``int`` port as ``5``), and a ``{curly_key}`` reference is wired through the remapping table as usual.
Note that a ``{curly_key}`` default is resolved in the scope of whoever *instantiates* the subtree,
not inside the subtree itself, so it behaves like a remapping the caller would have written by hand.
Literal defaults are usually the clearer choice.

Defaults for the top-level tree
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

The same mechanism applies to the tree named by ``main_tree_to_execute``.
Attributes on *its* ``<BehaviorTree>`` element are the defaults for the tree as a whole.
Since there is no enclosing ``<SubTree>`` element to override them from, those defaults are overridden
from Python instead, via the ``input_mappings`` argument of
:func:`~py_trees.parsers.behaviour_tree_xml.parse_behaviour_tree_xml`:

.. code-block:: xml

<root main_tree_to_execute="MainTree">
<BehaviorTree ID="MainTree" greeting="Hello">
<Sequence>
<Speak name="Speak" text="{greeting}" volume="{volume}"/>
</Sequence>
</BehaviorTree>
</root>

.. code-block:: python

root = parse_behaviour_tree_xml(
"my_tree.xml",
input_mappings={"greeting": "Howdy", "volume": "11"},
)

This makes the top-level tree parameterisable from the calling application without having to
edit (or template) the XML for each run.

A few rules to be aware of:

* ``input_mappings`` values must be **strings**, matching how they would have been written as
XML attributes (pass ``"11"``, not ``11``). A non-string value raises ``ValueError`` at parse time.
* ``input_mappings`` both overrides existing defaults and supplies keys the XML never defaulted at all (``volume`` in the example above).
* A required input port left with neither a default nor a mapping is not an error at parse time.
It raises :class:`~py_trees.ports.NoDataAvailable` if and when the node first reads it during a tick.

.. _ports-xml-includes-label:

Composing trees from multiple files
Expand Down
40 changes: 34 additions & 6 deletions py_trees/parsers/behaviour_tree_xml.py
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,7 @@ def parse_behaviour_tree_xml(
node_registry: dict | str = "auto",
logger: PortsLogger | None = None,
search_paths: list[str] | None = None,
input_mappings: dict[str, str] | None = None,
) -> py_trees.behaviour.Behaviour:
"""
Parse the XML file and build the behavior tree.
Expand Down Expand Up @@ -335,6 +336,9 @@ def parse_behaviour_tree_xml(
``{tag: class/partial}`` dict to use exclusively. Defaults to ``"auto"``.
logger (PortsLogger | None): Optional logger (NoOp if None).
search_paths (list[str] | None): Optional extra directories to resolve imports.
input_mappings (dict[str, str]): Optional input key-value mappings that can override default
top-level behaviour tree port values. Note that the values must be strings, to match how
they would be defined in XML.

Returns:
The root py_trees.behaviour.Behaviour for the requested tree.
Expand Down Expand Up @@ -387,9 +391,17 @@ def parse_behaviour_tree_xml(
logger.debug(f"[DEBUG] Starting parse of main tree ID='{main_tree_id}'")
bt_elem = bt_index[main_tree_id]

# Apply any input mappings by just modifying the XML element.
if input_mappings is not None:
for k, v in input_mappings.items():
if not isinstance(v, str):
raise ValueError(f"Value in input mapping {k} -> {v} must be a string.")
bt_elem.attrib[k] = v

remapping_table = build_subtree_remapping(bt_elem, bt_index, {}, "/", logger)
tree = build_tree_from_xml(
bt_elem,
remapping_table={},
remapping_table=remapping_table,
node_registry=node_registry,
bt_index=bt_index,
logger=logger,
Expand Down Expand Up @@ -444,18 +456,21 @@ def add_new_key_to_remapping_table(value: str, remapping_table: dict[str, str],

def build_subtree_remapping(
elem: ET.Element,
bt_index: dict[str, ET.Element],
remapping_table: dict[str, str],
parent_namespace: str,
logger: PortsLogger = NOOP_LOGGER,
) -> dict[str, str]:
"""
Process the <SubTree> XML element.

The remapping table is updated to include the remappings from the <subtreeplus> or <subtree> element.
The remapping table is updated to include the remappings from the <subtreeplus> or <subtree> element,
as well as any default port values inherited from the underlying <behaviortree> definition.
The subtree is then instantiated with the new remapping table.

Args:
elem: The <SubTree> (or <SubTreePlus>) XML element.
bt_index (dict[str, ET.Element]): dictionary {ID: BehaviorTree element} for subtree lookup.
remapping_table: Parent remapping table (logical name -> absolute key).
parent_namespace: Absolute namespace of the parent tree.
logger: Optional logger.
Expand All @@ -478,6 +493,15 @@ def build_subtree_remapping(
continue
logger.debug(f"Checking to add new key for SubTree attribute: {k} -> {v}")
add_new_key_to_remapping_table(v, remapping_table=remapping_table, subtree_namespace=parent_namespace)

# Also include any default args in the behavior tree definition that were not remapped in the subtree instance.
bt_elem = bt_index[elem.attrib["ID"]]
for k, v in bt_elem.attrib.items():
if k in ("ID", "name"):
continue
logger.debug(f"Checking to add new key for default BehaviorTree attribute: {k} -> {v}")
add_new_key_to_remapping_table(v, remapping_table=remapping_table, subtree_namespace=parent_namespace)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New to this, but wondering about the scope of this remapping since the subtree namespace is the same as the parent namespace. Is the parent remapping_table the authoritative source? What value does the subtree namepsace bring here? And in particular, what would happen if two subtrees used the same port name but different values?

I might just need to spend more time reading...

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

subtree_namespace keeps pre-pending namespaces down the hierarchy, so it modifies the remapping table as you recursively instantiate subtrees XML.

So basically the remapping cannot apply to 2 subtrees since they have to have unique names, and therefore have unique namespaces.


logger.debug(f"Updated remapping table: {remapping_table}")

# Build the new remapping table for this subtree. We build a new remapping table because we need to ensure that
Expand All @@ -489,7 +513,9 @@ def build_subtree_remapping(
# If the value is a key and it is already in the remapping table, we resolve it to its absolute path.
# Example: `<SubTree ID="subtree1" in="{other_key}" />` - we need resolve {other_key} to its absolute
# path (using the parent remapping) and then add `in -> resolved({other_key})` to the new remapping table.
for k, v in elem.attrib.items():
attrib_dict = bt_elem.attrib.copy() # the copy is necessary!
attrib_dict.update(elem.attrib)
for k, v in attrib_dict.items():
if k in ("ID", "name"):
continue
logger.debug(f"Processing SubTree attribute: {k} -> {v}")
Expand All @@ -506,6 +532,7 @@ def build_subtree_remapping(
v = resolve_direct_value_remapping(v, remapping_table)
logger.debug(f"[Direct value] Adding remapping from parent remapping table: {k} -> {v}")
new_remapping[k] = v

logger.debug(f"Subtree '{elem.attrib['ID']}' new remapping table: {new_remapping}")
return new_remapping

Expand Down Expand Up @@ -678,7 +705,7 @@ def build_tree_from_xml(
elem: ET.Element,
remapping_table: dict[str, str],
node_registry: dict,
bt_index: dict,
bt_index: dict[str, ET.Element],
logger: PortsLogger = NOOP_LOGGER,
subtree_namespace: str = "/",
parent_names_str: str = "",
Expand All @@ -691,7 +718,7 @@ def build_tree_from_xml(
remapping_table (dict[str, str]): Remapping table.
node_registry (dict): Mapping from class names (str) to callables (constructors or partials)
that return ``PortsMixin``-derived instances.
bt_index (dict[str, BehaviorTree]): dictionary {ID: BehaviorTree element} for subtree lookup.
bt_index (dict[str, ET.Element]): dictionary {ID: BehaviorTree element} for subtree lookup.
subtree_namespace (str): current blackboard namespace.
logger: Optional logger-like object.
parent_names_str (str): Dot-separated string of parent names for logging context and generating node names.
Expand Down Expand Up @@ -880,6 +907,7 @@ def build_tree_from_xml(
f"<BehaviorTree ID='{elem.attrib.get('ID', '')}'> must have exactly one child (the root node), "
f"but found {len(child_elems)} children."
)

return build_tree_from_xml(
child_elems[0],
remapping_table,
Expand All @@ -904,7 +932,7 @@ def build_tree_from_xml(
# Build the new namespace by prepending the subtree ID
new_namespace = get_absolute_reference(subtree_name, subtree_namespace)
# Build the new remapping table for this subtree.
new_remapping = build_subtree_remapping(elem, remapping_table, subtree_namespace, logger)
new_remapping = build_subtree_remapping(elem, bt_index, remapping_table, subtree_namespace, logger)
# Recursively build the subtree with the new remapping table
return build_tree_from_xml(
subtree_elem,
Expand Down
99 changes: 97 additions & 2 deletions tests/test_ports_xml_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,21 @@

import py_trees
from py_trees.parsers.behaviour_tree_xml import is_key, parse_behaviour_tree_xml
from py_trees.ports import BehaviourWithPorts, PortInformation, PortsMixin, get_ports_registry
from py_trees.ports import (
BehaviourWithPorts,
NoDataAvailable,
PortInformation,
PortsMixin,
get_ports_registry,
)
from py_trees.ports_utils import (
find_node_by_class,
find_node_by_name,
generate_node_name,
strip_trailing_uuid4,
)

from .test_ports_helpers import Consumer, Producer
from .test_ports_helpers import Consumer, FloatConsumer, Producer


class StdoutLogger:
Expand Down Expand Up @@ -418,6 +424,95 @@ def test_subtree_parsing_direct_values_to_XML(self) -> None:

self.assertEqual(node.consumed_value, expected_value)

def test_subtree_default_ports(self) -> None:
"""Verify that subtree default ports take effect as intended."""
self.xml = """<root main_tree_to_execute="MainTree">
<BehaviorTree ID="SubTree" input1="default_str" input2="42">
<Sequence>
<Consumer name="Consumer" input="{input1}"/>
<FloatConsumer name="FloatConsumer" input="{input2}"/>
</Sequence>
</BehaviorTree>

<BehaviorTree ID="MainTree">
<Sequence>
<SubTree ID="SubTree" name="AllDefaults" />
<SubTree ID="SubTree" name="FirstInputSet" input1="override_str" />
<SubTree ID="SubTree" name="SecondInputSet" input2="67" />
<SubTree ID="SubTree" name="BothInputsSet" input1="another_str" input2="9001" />
</Sequence>
</BehaviorTree>
</root>"""

self.tempfile = tempfile.NamedTemporaryFile(delete=False, mode="w", suffix=".xml")
self.tempfile.write(self.xml)
self.tempfile.close()

root_node = parse_behaviour_tree_xml(self.tempfile.name, logger=StdoutLogger())
btree = py_trees.trees.BehaviourTree(root_node)
btree.tick()

for subtree_name, expected_str, expected_float in (
("AllDefaults", "default_str", 42),
("FirstInputSet", "override_str", 42),
("SecondInputSet", "default_str", 67),
("BothInputsSet", "another_str", 9001),
):
node = find_node_by_name(root_node, f"{subtree_name}.Consumer")
assert isinstance(node, Consumer)
self.assertEqual(node.consumed_value, expected_str)

node = find_node_by_name(root_node, f"{subtree_name}.FloatConsumer")
assert isinstance(node, FloatConsumer)
self.assertEqual(node.consumed_value, expected_float)

def test_top_level_default_ports(self) -> None:
"""Verify that top-level behaviour default ports take effect as intended."""
# In this tree, input1 has a default value but input2 does not.
self.xml = """<root main_tree_to_execute="MainTree">
<BehaviorTree ID="MainTree" input1="default_str">
<Sequence>
<Consumer name="Consumer" input="{input1}"/>
<FloatConsumer name="FloatConsumer" input="{input2}"/>
</Sequence>
</BehaviorTree>
</root>"""

self.tempfile = tempfile.NamedTemporaryFile(delete=False, mode="w", suffix=".xml")
self.tempfile.write(self.xml)
self.tempfile.close()

# Run without mappings. This should fail in FloatConsumer because no port was set.
root_node = parse_behaviour_tree_xml(self.tempfile.name, logger=StdoutLogger())
btree = py_trees.trees.BehaviourTree(root_node)
btree.tick()

node = find_node_by_name(root_node, "Consumer", strip_prefix=True)
assert isinstance(node, Consumer)
self.assertEqual(node.consumed_value, "default_str")

float_node = find_node_by_name(root_node, "FloatConsumer", strip_prefix=True)
assert isinstance(float_node, FloatConsumer)
with self.assertRaises(NoDataAvailable):
self.assertEqual(float_node.consumed_value, 42)

# Setting input values should override the inputs appropriately.
root_node = parse_behaviour_tree_xml(
self.tempfile.name,
logger=StdoutLogger(),
input_mappings={"input1": "override_str", "input2": "67"},
)
btree = py_trees.trees.BehaviourTree(root_node)
btree.tick()

node = find_node_by_name(root_node, "Consumer", strip_prefix=True)
assert isinstance(node, Consumer)
self.assertEqual(node.consumed_value, "override_str")

float_node = find_node_by_name(root_node, "FloatConsumer", strip_prefix=True)
assert isinstance(float_node, FloatConsumer)
self.assertEqual(float_node.consumed_value, 67)

def test_wait_node(self) -> None:
"""Verify that the duration value is successfully used by the Wait node."""
wait_duration_ms = 500
Expand Down
Loading