|
| 1 | +.. _ports-section-label: |
| 2 | + |
| 3 | +Ports (Experimental) |
| 4 | +==================== |
| 5 | + |
| 6 | +.. note:: |
| 7 | + |
| 8 | + The ports module (:mod:`py_trees.ports`) and the XML parser |
| 9 | + (:mod:`py_trees.parsers.behaviour_tree_xml`) are **experimental**. |
| 10 | + Their API may change between releases. |
| 11 | + |
| 12 | +Overview |
| 13 | +-------- |
| 14 | + |
| 15 | +**Ports** add a structured way of wiring up data exchange between nodes. |
| 16 | +Each node defines the data it reads and writes as **input** and **output** ports. |
| 17 | +The ports are wired to blackboard keys (the *remapping*), and port values are type-checked at runtime. |
| 18 | +This allows for a defined and constrained way of data exchange between nodes which is less error-prone and easier to debug than simply writing data on the blackboard and letting nodes read from and write to that entry directly. |
| 19 | + |
| 20 | +Why use ports? |
| 21 | +~~~~~~~~~~~~~~ |
| 22 | + |
| 23 | +Using ports instead of ad-hoc blackboard reads and writes pays off in several concrete ways: |
| 24 | + |
| 25 | +* **Explicit data contracts.** |
| 26 | + A node's ``input_ports()`` and ``output_ports()`` declarations *are* its data-flow API. |
| 27 | + A reader can see at a glance what a node consumes and produces without reading through its :meth:`~py_trees.behaviour.Behaviour.update` method. |
| 28 | + |
| 29 | +* **Structured, early error detection.** |
| 30 | + Port values are type-checked at runtime (``TypeError`` on mismatched writes/reads), required inputs without data raise :class:`~py_trees.ports.NoDataAvailable` instead of returning ``None`` silently, and misconfiguration (a remap for a port that isn't declared, or a port type that can't accept the wiring) surfaces at setup time rather than deep in a tick. |
| 31 | + |
| 32 | +* **XML authoring.** |
| 33 | + Once a library of port-enabled nodes exists, trees become declarative data. |
| 34 | + Non-programmers can read and edit tree structure (and the data wiring) without touching Python. |
| 35 | + See the :ref:`XML parser section <ports-xml-parser-label>` below. |
| 36 | + |
| 37 | +* **Reusable subtrees via rewiring.** |
| 38 | + A subtree is configured from the outside by rewiring its port remappings. |
| 39 | + A subtree can be re-used with different inputs and outputs without touching the internal code. |
| 40 | + In combination with the XML parser, this concept of re-usable subtrees becomes really powerful to quickly put together new behaviors. |
| 41 | + |
| 42 | +* **Automatic subtree isolation.** |
| 43 | + Sibling subtrees can freely reuse the same port names internally; the subtree namespace scopes them on the blackboard so they don't collide. |
| 44 | + |
| 45 | +* **Refactoring safety.** |
| 46 | + Changing the blackboard key a data item lives under is a *rewiring* change at setup time, not a code change scattered across every node that used to read or write that key by name. |
| 47 | + |
| 48 | +* **Easier to test.** |
| 49 | + A single node can be exercised in isolation by wiring its ports to known blackboard keys, seeding the inputs, and ticking once. |
| 50 | + No tree scaffolding required; the ports contract becomes the test surface. |
| 51 | + |
| 52 | +The primary API is :class:`py_trees.ports.PortsMixin`. |
| 53 | +Concrete nodes typically inherit from the convenience base |
| 54 | +:class:`py_trees.ports.BehaviourWithPorts`, which combines the mixin with |
| 55 | +:class:`py_trees.behaviour.Behaviour`: |
| 56 | + |
| 57 | +.. code-block:: python |
| 58 | +
|
| 59 | + import py_trees |
| 60 | + from py_trees.ports import BehaviourWithPorts, PortInformation |
| 61 | +
|
| 62 | + class Multiply(BehaviourWithPorts): |
| 63 | + @classmethod |
| 64 | + def input_ports(cls): |
| 65 | + return { |
| 66 | + "a": PortInformation(data_type=float, required=True), |
| 67 | + "b": PortInformation(data_type=float, required=True), |
| 68 | + } |
| 69 | +
|
| 70 | + @classmethod |
| 71 | + def output_ports(cls): |
| 72 | + return { |
| 73 | + "product": PortInformation(data_type=float, required=True), |
| 74 | + } |
| 75 | +
|
| 76 | + def update(self): |
| 77 | + self._set_output("product", self.get_input("a") * self.get_input("b")) |
| 78 | + return py_trees.common.Status.SUCCESS |
| 79 | +
|
| 80 | +Wiring (remapping) and type checking |
| 81 | +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ |
| 82 | + |
| 83 | +Ports become usable after :meth:`~py_trees.ports.PortsMixin.setup_ports` has been called with the *port remappings* (the "wiring"). |
| 84 | +Remappings map each port to an absolute or relative blackboard key. |
| 85 | +The purpose of remapping is to "wire" one node's output port(s) to another node's input port(s) so they can exchange data. |
| 86 | + |
| 87 | +.. code-block:: python |
| 88 | +
|
| 89 | + node = Multiply(name="mul") |
| 90 | + node.setup_ports( |
| 91 | + port_remappings={ |
| 92 | + "a": "/numbers/a", |
| 93 | + "b": "/numbers/b", |
| 94 | + "product": "/numbers/product", |
| 95 | + } |
| 96 | + ) |
| 97 | +
|
| 98 | +In the example above, another node's output ports would typically be remapped to ``/numbers/a`` and ``/numbers/b`` and thereby provide the input for the ``Multiply`` node. |
| 99 | + |
| 100 | +.. note:: Why is ``setup_ports()`` a separate call? |
| 101 | + Because the remapping table usually cannot be computed until the entire tree topology is known — either the user assembles it by hand or a parser generates it from e.g. XML (more on that next). |
| 102 | + See :class:`py_trees.ports.PortsMixin` for the full contract and semantics. |
| 103 | + |
| 104 | +.. _ports-xml-parser-label: |
| 105 | + |
| 106 | +Experimental XML parser |
| 107 | +----------------------- |
| 108 | + |
| 109 | +The module :mod:`py_trees.parsers.behaviour_tree_xml` ships an (experimental) parser for the `BehaviorTree.CPP <https://www.behaviortree.dev/docs/learn-the-basics/main_concepts>`_ XML format. |
| 110 | +It builds a py_trees tree from an XML file and auto-generates the port remappings for every node. |
| 111 | + |
| 112 | +.. code-block:: python |
| 113 | +
|
| 114 | + from py_trees.parsers.behaviour_tree_xml import parse_behaviour_tree_xml |
| 115 | +
|
| 116 | + root = parse_behaviour_tree_xml( |
| 117 | + "my_tree.xml", |
| 118 | + init_lookup={"MyNode": MyNode, "OtherNode": OtherNode, ...}, |
| 119 | + ) |
| 120 | +
|
| 121 | +See the :ref:`demos <ports-demos-section-label>` below for working examples. |
| 122 | + |
| 123 | +.. _ports-xml-attributes-label: |
| 124 | + |
| 125 | +XML attributes: ports *and* constructor arguments |
| 126 | +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ |
| 127 | + |
| 128 | +Attributes on a node's XML tag serve **two** distinct purposes: |
| 129 | + |
| 130 | +1. Attribute names that match a declared port (``input_ports()`` or ``output_ports()``) are treated as **port remappings**. |
| 131 | + Values may be ``{curly_key}`` references (wired to the remapping table) or literal constants (type-converted according to the port's declared type). |
| 132 | + |
| 133 | +2. Attribute names that do **not** match any declared port are treated as **constructor keyword arguments** and forwarded to the class constructor. |
| 134 | + Values are type-converted based on the constructor's type annotations (strings to ``int`` / ``float`` / ``bool`` / enum / etc.); un-annotated parameters receive the raw string. |
| 135 | + ``{curly_key}`` references are *not* allowed for constructor kwargs --- they raise a ``ValueError`` at parse time, because constructor kwargs can't be re-wired at runtime the way ports can. |
| 136 | + |
| 137 | +Example:: |
| 138 | + |
| 139 | + class Greeting(BehaviourWithPorts): |
| 140 | + |
| 141 | + @classmethod |
| 142 | + def input_ports(cls): |
| 143 | + return {"name_key": PortInformation(data_type=str, required=True)} |
| 144 | + |
| 145 | + @classmethod |
| 146 | + def output_ports(cls): |
| 147 | + return {"greeting": PortInformation(data_type=str, required=True)} |
| 148 | + |
| 149 | + def __init__(self, name: str, prefix: str = "Hello", **kwargs): |
| 150 | + super().__init__(name=name, **kwargs) |
| 151 | + self.prefix = prefix # not a port; populated from XML attribute |
| 152 | + |
| 153 | + def update(self): |
| 154 | + self._set_output("greeting", f"{self.prefix}, {self.get_input('name_key')}!") |
| 155 | + return py_trees.common.Status.SUCCESS |
| 156 | + |
| 157 | +.. code-block:: xml |
| 158 | +
|
| 159 | + <Greeting name="hello_node" name_key="{target}" prefix="Howdy"/> |
| 160 | + <!-- ^^^ behaviour name ^^^ port remap ^^^ ctor kwarg --> |
| 161 | +
|
| 162 | +Scope, limitations, and how to extend |
| 163 | +------------------------------------- |
| 164 | + |
| 165 | +The current ports framework is deliberately minimal. |
| 166 | +It ships the :class:`~py_trees.ports.PortsMixin` contract, the convenience :class:`~py_trees.ports.BehaviourWithPorts` base, an experimental XML parser, and four demos. |
| 167 | +It is the base for extensions to be added in future. |
| 168 | +A few things you should be aware of, and suggestions on how to fill the gaps yourself: |
| 169 | + |
| 170 | +**1. No port-aware behaviours, decorators, or composites are shipped.** |
| 171 | + |
| 172 | + ``py_trees.ports`` provides the *mechanism* for typed input/output ports. |
| 173 | + It does **not** currently ship any concrete behaviours that use ports (e.g. there is no ``Retry`` with ports). |
| 174 | + The library of port-enabled nodes is the user's domain: you define ``PortsMixin``-derived classes that actually *do something* with the input/output data. |
| 175 | + |
| 176 | +**2. Built-in decorators and composites cannot be wired through ports from XML.** |
| 177 | + |
| 178 | + The XML parser supports four built-in composite tags natively (``<Sequence>``, ``<Selector>`` / ``<Fallback>``, ``<Parallel>``). |
| 179 | + For these tags, only a **fixed** set of XML attributes is consumed: |
| 180 | + |
| 181 | + * ``<Sequence>`` / ``<Selector>`` / ``<Fallback>``: ``name``, ``memory`` |
| 182 | + * ``<Parallel>``: ``name``, ``policy`` (one of ``success_on_all``, |
| 183 | + ``success_on_one``, ``success_on_selected``) |
| 184 | + |
| 185 | + **Any other attribute on a built-in composite tag is silently ignored.** |
| 186 | + For example, ``<Parallel synchronise="true">`` has no effect, and port-style attributes on these tags are dropped without warning. |
| 187 | + This is also why you cannot take the number of attempts for a :class:`py_trees.decorators.Retry` from a port value via XML --- the parser only wires ports (and forwards constructor kwargs; see :ref:`ports-xml-attributes-label` above) on classes registered in ``init_lookup`` that derive from :class:`~py_trees.ports.PortsMixin`. |
| 188 | + Built-in decorators aren't recognised as XML tags at all. |
| 189 | + |
| 190 | +**3. The pattern: port-aware wrapper classes.** |
| 191 | + |
| 192 | + To make an existing upstream behaviour, decorator, or composite port-aware, wrap it in a small adapter class that combines :class:`~py_trees.ports.PortsMixin` with the upstream class and reads its runtime parameters from input ports. |
| 193 | + |
| 194 | + The recommended naming is to keep the short upstream class name (``Retry``, ``Repeat``, ``Parallel``, …) and place the port-aware version under a ``ports`` submodule that mirrors the upstream layout (e.g. ``py_trees.ports.decorators.Retry`` alongside the upstream ``py_trees.decorators.Retry``). |
| 195 | + The import path carries the "ported" information, so the class name stays short and matches its upstream counterpart. |
| 196 | + |
| 197 | + Example: a port-aware :class:`~py_trees.decorators.Retry` that takes its ``num_failures`` from an input port --- intended to live in ``py_trees.ports.decorators`` when contributed upstream, or in your own project's ``ports`` submodule: |
| 198 | + |
| 199 | + .. code-block:: python |
| 200 | +
|
| 201 | + # py_trees/ports/decorators.py (or yourproject/ports/decorators.py) |
| 202 | + import py_trees |
| 203 | + from py_trees.ports import PortInformation, PortsMixin |
| 204 | +
|
| 205 | + class Retry(PortsMixin, py_trees.decorators.Retry): |
| 206 | + """Retry that reads its failure budget from an input port.""" |
| 207 | +
|
| 208 | + @classmethod |
| 209 | + def input_ports(cls): |
| 210 | + return {"num_failures": PortInformation(data_type=int, required=True)} |
| 211 | +
|
| 212 | + @classmethod |
| 213 | + def output_ports(cls): |
| 214 | + return {} |
| 215 | +
|
| 216 | + def __init__( |
| 217 | + self, |
| 218 | + name: str, |
| 219 | + child: py_trees.behaviour.Behaviour, |
| 220 | + **kwargs, |
| 221 | + ): |
| 222 | + # Start with a safe default; it is overwritten on every initialise(). |
| 223 | + super().__init__( |
| 224 | + name=name, child=child, num_failures=1, **kwargs |
| 225 | + ) |
| 226 | +
|
| 227 | + def initialise(self) -> None: |
| 228 | + # Read the port value at tick boundary and apply it before the |
| 229 | + # upstream Retry logic runs. |
| 230 | + self.num_failures = self.get_input("num_failures") |
| 231 | + super().initialise() |
| 232 | +
|
| 233 | + Users import it as ``from py_trees.ports.decorators import Retry`` (or the equivalent path in their own project) --- the import path disambiguates it from the upstream ``py_trees.decorators.Retry``, so the class name stays clean. |
| 234 | + |
| 235 | + The XML then can accept the input via (remapped) ports:: |
| 236 | + |
| 237 | + <Retry name="retry" num_failures="{retry_budget}"> |
| 238 | + <SomeBehaviour name="worker" /> |
| 239 | + </Retry> |
| 240 | + |
| 241 | +.. note:: Contributing port-enabled extensions upstream is encouraged! |
| 242 | + |
| 243 | + If you build a generally useful port-aware wrapper --- for example, a port-enabled :class:`~py_trees.decorators.Retry`, :class:`~py_trees.decorators.Repeat`, :class:`~py_trees.composites.Parallel`, or :class:`~py_trees.timers.Timer` --- please consider contributing it back to py_trees under the matching ``py_trees.ports.*`` subpackage (``py_trees.ports.decorators``, ``py_trees.ports.composites``, ``py_trees.ports.timers``, and so on, mirroring the upstream module layout). |
| 244 | + A shared library of canonical port-aware adapters saves every user from re-implementing the same wrappers. |
| 245 | + Open a PR against the `py_trees devel branch <https://github.com/splintered-reality/py_trees>`_ and we will happily review it. |
| 246 | + |
| 247 | +.. _ports-demos-section-label: |
| 248 | + |
| 249 | +Demos |
| 250 | +----- |
| 251 | + |
| 252 | +Example programs live in ``py_trees.demos.ports``. |
| 253 | +Each demo module has a top-level docstring describing the scenario it demonstrates. |
| 254 | + |
| 255 | +Module reference |
| 256 | +---------------- |
| 257 | + |
| 258 | +.. automodule:: py_trees.ports |
| 259 | + :members: |
| 260 | + :show-inheritance: |
| 261 | + :synopsis: typed input/output ports |
| 262 | + |
| 263 | +.. automodule:: py_trees.parsers.behaviour_tree_xml |
| 264 | + :members: |
| 265 | + :show-inheritance: |
| 266 | + :synopsis: experimental XML parser for ports |
0 commit comments