Skip to content

GrahamDennis/pybt2

Repository files navigation

pybt2

codecov CI

pybt2 is a React-inspired incremental behaviour tree library.

  • React-inspired: Behaviour Trees are defined in Python, and each node can dynamically construct its children on each tick. Data can be implicitly passed into children via Contexts, and in-tree state is supported via React-like hooks like use_state, use_effect or use_async. No stringly-typed blackboards here! Although child nodes are dynamically constructed on each tick, each child node has an identifying key path that can be used to identify corresponding nodes between ticks to propagate state as required.

  • incremental: The complete tree isn't evaluated on each tick, only the parts that are out-of-date. pybt tracks data dependencies between nodes and memoizes node arguments and return values to only re-run nodes that need to be.

    Behind-the-scenes, pybt2 has an runtime that supports incremental execution of call graphs of functions returning any data, not just the standard behaviour-tree values of Success, Running or Failure.

A simple example

Consider the behaviour described in §5.4.2 of Behavior Trees in Robotics and AI:

Where the "Guarantee Power Supply" moves the robot towards the charging station if the power level is less than 20%. This Behaviour Tree can be described in pybt2 as (see robot.py):

from attrs import frozen
from pybt2.behaviour_tree.types import BTNode, BTNodeResult
from pybt2.runtime.fibre import CallContext
from pybt2.behaviour_tree.nodes import (
  PreconditionAction, PostconditionPreconditionAction, AnyOf, AllOf, Not
)

@frozen
class SafeRobot(BTNode):
    """
    Root behaviour tree node that ensures there's sufficient power before performing some
    other task.
    """
    task: BTNode

    def __call__(self, ctx: CallContext) -> BTNodeResult:
        return PreconditionAction(precondition=GuaranteePowerSupply(), action=self.task)


@frozen
class GuaranteePowerSupply(BTNode):
    """
    A subtree to maintain sufficient battery charge.
    
    If the battery level falls below 20%, then move the robot towards the charging area and
    charge until full.
    """
    def __call__(self, ctx: CallContext) -> BTNodeResult:
        return PostconditionPreconditionAction(
            postcondition=AnyOf(
                BatteryLevelIsAtLeast(100.0),
                AllOf(Not(InChargingArea()), BatteryLevelIsAtLeast(20.0))
            ),
            actions=[MoveTowardsChargingArea()],
        )


@frozen
class BatteryLevelIsAtLeast(BTNode):
    """
    A condition node that reads the current battery level from the battery context using a
    React-inspired use_context hook.
    """
    threshold: float

    def __call__(self, ctx: CallContext) -> BTNodeResult:
        return use_context(ctx, BatteryLevelContextKey) > self.threshold


@frozen
class InChargingArea(BTNode):
    """
    A condition node that reads the current vehicle position from the position context using
    a React-inspired use_context hook.
    """
    def __call__(self, ctx: CallContext) -> BTNodeResult:
        return use_context(ctx, PositionContextKey) < 0.1


@frozen
class MoveTowardsChargingArea(BTNode):
    """
    Move the robot towards the charging area (position 0.0 of 0.0 to 100.0).
    """
    def __call__(self, ctx: CallContext) -> BTNodeResult:
        use_capture(ctx, RobotVelocityDemandsCaptureKey, -1.0)
        return Running()


@frozen
class MoveTowards(BTNode):
    """
    Move the robot towards an arbitrary position, slowing down as it approaches the target.
    """
    destination: float

    def __call__(self, ctx: CallContext) -> BTNodeResult:
        current_position = use_context(ctx, PositionContextKey)
        desired_velocity = (self.destination - current_position) / 50.0
        use_capture(ctx, RobotVelocityDemandsCaptureKey, desired_velocity)
        return Running()

This Behaviour Tree references two Contexts, the BatteryLevelContextKey and the PositionContextKey that are implicitly passed into the tree above by a Context Provider:

@frozen
class RobotState:
    battery_level: float
    position: float

@frozen
class RobotContextProvider(BTNode):
    robot_state: RobotState
    child: BTNode

    def __call__(self, ctx: CallContext) -> BTNodeResult:
        return ctx.evaluate_inline(
            BatchContextProvider(
                contexts={
                    BatteryLevelContextKey: self.robot_state.battery_level,
                    PositionContextKey: self.robot_state.position,
                },
                child=self.child,
            )
        )

The full behaviour tree and its data dependencies can be visualised by running the tree in "analysis mode" (see TestRobotVisualisation in tests/behaviour_tree/test_robot.py):

In the diagram above:

  • Solid lines point from parent nodes to child nodes. These lines are labelled with the key that uniquely identifies this child in its parent node.
  • Dashed lines point from nodes to their long-range data dependencies. For example the nodes that read from the battery-level context or the robot position context have dashed arrows to those values stored in the tree.

The diagram also illustrates the use of Captures, which are the opposite of Contexts and can be used for gathering values from elsewhere in the tree. In this case, it is used for gathering robot velocity demands. Only the first velocity demand is passed to the simulator (see RobotSimulator in tests/behaviour_tree/robot.py).

Tree visualisation

The DotRenderer class can be used to produce visualisations of behaviour trees using GraphViz.

For example the SafeRobot(MoveTowards(destination=100)) tree can be visualised in increasing levels of detail:

  • Visualisation of an unevaluated SafeRobot(MoveTowards(destination=100)) node

  • Visualisation of the evaluation of SafeRobot(MoveTowards(destination=100)) node

  • GuaranteePowerSupply can be visualised as:

During normal execution, not every node in the behaviour tree will necessarily be evaluated. For example a Sequence node with 2 children won't evaluate the second child if the first child returns Running. To support visualisation and other kinds of tree analysis, nodes with early-return behaviour like this can support an "Analysis" execution mode (see the SupportsAnalysis abstract class) where all children are expected to be executed. Only nodes that return early need to subclass SupportsAnalysis. This execution mode can be used to visualise the complete tree and validate that all required contexts are being provided.

See TestRobotVisualisation in test_robot.py for an example.

Incremental execution

pybt2 supports incremental execution of function call graphs where:

  • the function and its arguments are represented by an immutable attrs class called "props"
  • each function call node (a FibreNode object) stores its previous props, result and other dependencies, and on subsequent executions if neither the props nor dependencies have changed, then the previous result will be returned

For example, a Sequence node that evaluates its children in order and returns the first non-successful result looks like:

@frozen
class SequenceNode(BTNode):
    children: Sequence[BTNode]

    def __call__(self, ctx: CallContext) -> BTNodeResult:
        for child in self.children:
            result = ctx.evaluate_child(child)
            if not is_success(result):
                return result
        return Success()

If an evaluated child of this SequenceNode is updated but still returns the same result (as determined by the == operator), then the SequenceNode will not be re-executed. However if one of the evaluated children changes its result, then the SequenceNode will be re-executed from the start. Its children however will only be re-executed if they are themselves out-of-date.


Development

Read the CONTRIBUTING.md file.

About

a React-inspired incremental behaviour tree library

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published