Skip to content
Open
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
1 change: 1 addition & 0 deletions .github/workflows/pre-commit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,4 @@ jobs:
cache: true
environments: visualization
- run: pixi run --locked -e visualization typecheck-gui
- run: pixi run --locked -e visualization test-gui
12 changes: 11 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ pixi run --locked test
pixi run --locked package
pixi run --locked typecheck
pixi run --locked -e visualization typecheck-gui
pixi run --locked -e visualization test-gui
pixi run --locked -e format format
pixi run --locked -e format lint
```
Expand All @@ -26,7 +27,9 @@ environment. ROS package dependencies also belong in the corresponding

Both packages retain their ament Python layout and support standard Python
distribution builds. `package` builds source distributions and wheels and checks
that ROS resources are present and test fixtures are excluded.
that ROS resources and typing metadata are present. Test fixtures are included
in source distributions and excluded from wheels; isolated imports verify that
the built packages can be imported without using the checkout.

Add focused regression tests for parser or runtime changes. Runtime tests use
small fake elements and assert observable stack and event behavior. Tests that
Expand All @@ -43,3 +46,10 @@ ship `py.typed`; blackboards are generic and parameter values use `object` to
reflect the existing YAML and ROS inputs. Narrow a parameter with `isinstance`
before using it as a particular type. Untyped ROS integrations are confined to
third-party imports, and the small RQT stubs document the interfaces used here.

The core test task includes localhost DDS integration. GUI tests use offscreen Qt
and Graphviz in the visualization environment; the default environment skips
those modules when Qt is absent. The dedicated GUI task requires Qt, so CI cannot
silently skip its tests. All dependency installation can be left to CI when local
bandwidth is limited; use the locked environments already available locally for
focused checks.
246 changes: 104 additions & 142 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,185 +1,147 @@
# dynamic_stack_decider
# Dynamic Stack Decider

A lightweight framework for decision making and behavior defining for robots and other agents in changing environments.
It combines the advantages of behavior trees, decision trees and state machines.
DSD selects persistent actions through a hierarchy of decisions that can be

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I don't like the new description

reevaluated as the environment changes. The active stack records the decisions

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This isn't even the point really.

behind the current action, making it useful for inspecting robot behavior.

## General Structure
Python classes implement decisions and actions. A `.dsd` file defines their
control flow:

In general, the dynamic stack decider consists of actions and decisions.
A decision is a python class extending `dynamic_stack_decider.AbstractDecisionElement`.
It should implement the `perform()` method where, depending on some information, a string is returned.

An action is a python class extending `dynamic_stack_decider.AbtractActionElement`.
It should also implement the `perform()` method where code can be executed that performs an action.
It should not return anything.

The DSD follows a tree-like structure that is specified in the DSD domain-specific language.
Let's look at an example:

```
-->
$Mode
BALL --> $BallSeen
YES --> @TrackBall
NO --> @SearchBall
PATTERN --> @LookAround
```text
-->HeadBehavior
$BallSeen
YES --> @TrackBall
NO --> @SearchBall
```

This example is a very simple head behavior for a robot.
The arrow (`-->`) at the beginning of the file indicates the start of the behavior.
Decisions are prefix with `$`, actions with `@`.
Their names correspond to the class names of the python classes implementing the respective action or decision.

The first decision is the `Mode` decision.
It decides about the mode of the head and returns the string `BALL` or `PATTERN`.
By convention, the returned strings are in capital letters.
The returned strings are indented and followed by an arrow; the element after the arrow is executed when the corresponding string is returned by the decision.
`BallSeen.perform()` returns an outcome such as `YES` or `NO`.
`TrackBall.perform()` and `SearchBall.perform()` perform one tick of work and
return nothing. Actions retain their instance state between updates and complete
with `return self.pop()`.

<p align="center">
<img src="dynamic_stack_decider/docs/_static/DSD_Stack_Example.png" alt="A visualization of the Stack" width="350px"/>
</p>
## Try the example

The underlying model for the DSD is a stack.
The root element (`Mode` in the example above) is always at the bottom of the stack.
When it makes a decision, the result is pushed on the stack and executed, for example the `BallSeen` decision.
Then, depending on the result, the next decision or action is pushed and executed, for example the `TrackBall` action.
This actions stays on top of the stack until one of the following conditions is met:
1. It calls `self.pop()` to pop itself from the stack.
Then, the decision that is now on top of the stack is executed again and depending on the result, another action or decision is pushed.
2. One of the decisions on the stack is reevaluated (see #Reevaluation below).
Install [Pixi](https://pixi.sh), then run the checked-in example:

The `__init__` method of an element is called when it is pushed on the stack.
It receives the following arguments:
* `blackboard`: an arbitrary object that is shared between all stack elements
* `dsd`: the instance of the dynamic stack decider
* `parameters`: an optional dictionary of parameters
```sh
pixi run --locked demo
```

The `perform` method is called when it is on top of the stack or getting reevaluated.
It receives the argument `reevaluate` which is set to `True` when the element is getting reevaluated and to `False` when it is executed normally.
The example uses a shared blackboard and switches from searching to tracking.
It requires neither a ROS node nor hardware. The core currently imports ROS
Python libraries; Pixi supplies those dependencies.

## Starting the DSD
## Define elements

To start the DSD, a `dynamic_stack_decider.DSD` object has to be created.
Its arguments are a blackboard, which is passed to all elements, and an optional ROS topic that is used for debugging.
Then, `register_actions` and `register_decisions` have to be called with the name of a folder containing the actions and decisions, respectively.
After registering the elements, `load_behavior` should be used to load the file containing the behavior description in the DSD language.
Finally, to actually execute the stack decider, `update` should be called repeatedly.
The blackboard can be any application object. Use the generic base classes to
keep its interface visible to the type checker:

On each update, all elements on the stack except the top elements are reevaluated from bottom to top, if requested.
Then, the top element is executed.
If it was a decision, the resulting action is pushed on the stack, to be executed in the next call to `update`.
```python
from dataclasses import dataclass

## Reevaluation
from dynamic_stack_decider import AbstractActionElement, AbstractDecisionElement

Normally, only the topmost action or decision of the stack is executed and another decision can only be executed if the topmost element pops itself from the stack.
Sometimes, it is useful to also execute particular decisions when they are not on top of the stack.
For example in the example above, if the mode can be set externally, it would make sense to check on every update of the stack if the decision has changed.
To achieve that behavior, the `get_reevaluate` should be implemented, either to just return `True` or to implement more sophisticated reevaluation criteria.

When the element is reevaluated, `perform` is called on the element.
If the result of `perform` is the same as the last time it was called, nothing happens and the next decision is reevaluated.
If the result of `perform` is different from the last time, everything above the reevaluated decision is discarded and a new element, depending on the result of `perform`, is pushed on the stack and executed.
@dataclass
class Blackboard:
ball_seen: bool = False

An action can call `self.do_not_reevaluate()` to avoid reevaluation of the stack on the next call to `update`. Alternatively the parameters (see #Parameters) `r` or `reevaluate` can be used in the dsd file to enable or disable the reevaluation during a specific action.

## Sequence elements
class BallSeen(AbstractDecisionElement[Blackboard]):
def perform(self, reevaluate: bool = False) -> str:
return "YES" if self.blackboard.ball_seen else "NO"

Sequence elements can be used to perform multiple actions instead of a single one.
To use a sequence element, simply separate the actions with commas, like that: `@FirstAction, @SecondAction, @ThirdAction`.
When the sequence element is pushed on the stack, the first action of the sequence remains on top of the stack until it pops itself from the stack.
Then, the next action in the sequence is pushed to the stack.
def get_reevaluate(self) -> bool:
return True

## Parameters

Sometimes it is helpful to pass parameters to actions or decisions.
The syntax is `@Action + parameter:value`, multiple parameters can be passed using further `+ parameter:value` pairs.
The parameters to an element are passed to the element's constructor as a dictionary.
The value of the parameter is passed through `yaml.safe_load`.
Therefore, you can use integers, floats, booleans or strings as values.
It is also possible to load parameters from the ROS parameter server using `parameter:%rosparam`, where `rosparam` is the name of the parameter on the parameter server.
class SearchBall(AbstractActionElement[Blackboard]):
def perform(self, reevaluate: bool = False) -> None:
# Start or monitor the application's search operation here.
pass

Here is an example:
```
-->
$Mode
BALL --> $BallSeen
YES --> @TrackBall + time:10
NO --> @SearchBall
PATTERN --> @LookAround
def on_pop(self) -> None:
# Cancel outstanding work and release resources here.
pass
```

## Subtrees
The constructor receives the shared `blackboard`, the owning `dsd`, and a
`parameters` dictionary. A subclass overriding it must call `super().__init__`.
Parameter values are YAML- or ROS-provided objects; narrow them with `isinstance`
before treating them as a specific type. Each activation receives its own copy.

To avoid duplication and deep nested structures in the DSD file, subtrees can be used.
Here is an example that is equivalent to the example above:
```
#BallMode
$BallSeen
YES --> @TrackBall
NO --> @SearchBall
## Run a behavior

-->
$Role
BALL --> #BallMode
PATTERN --> @LookAround
```
Create a `DSD(blackboard)` instance, register actions and decisions, then load a
behavior. Registries map class names to their implementations; alternatively,
`register_actions(path)` and `register_decisions(path)` discover classes in a
Python file or package. The [runnable example](examples/demo.py) shows explicit
registries.

Subtrees can also receive parameters.
Here is another example:
```
Call `update()` regularly from one owner. It reevaluates eligible decisions from
the bottom of the active stack, then follows decision outcomes immediately until
an action runs. Actions in a comma-separated chain advance on completion; the
successor runs on the next update.

#BallMode
$BallSeen + tracktime
YES --> @TrackBall + time:*tracktime
NO --> @SearchBall
Use `on_pop()` for cleanup on completion, preemption, interruption, and behavior
replacement. Return immediately after calling `pop()` or `interrupt()`.
`dsd.last_error` exposes the first failure of the latest update; ordinary update
failures are logged. Applications must choose their recovery behavior.

-->
$Role
BALL --> #BallMode + tracktime:10
PATTERN --> @LookAround
```
See the [execution contract](docs/runtime.md) for timing, cleanup, suppression,
root behavior, and error semantics. The [language reference](docs/language.md)
covers action chains, `ELSE`, comments, parameters, and reusable subtrees. Parser
errors include the source file and line.

As you can see, the parameters are specified in the same way as for actions or decisions.
In the subtree, the parameter has to be given a name using the `+` syntax and can be referenced with the `*` operator.
## Inspect a running DSD

## ELSE
Pass both a ROS node and a debug topic namespace:

`ELSE` is a catch-all decision result.
It can be used to map multiple results to a single action or decision.
For example, the `Role` decision could return the roles `BALL`, `GOAL`, `ROBOT`, and `PATTERN`, but the latter three should result in the same behavior:
```python
dsd = DSD(blackboard, node=node, debug_topic="behavior/debug")
```
-->
$Mode
BALL --> $BallSeen
YES --> @TrackBall
NO --> @SearchBall
ELSE --> @LookAround

The RQT visualization discovers DSD instances through their debug topics and
shows the behavior tree, active path, and debug data. Start the installed
visualization package with:

```sh
ros2 run dynamic_stack_decider_visualization dsd_gui
```

## Interrupt
Debug topics use JSON in `std_msgs/msg/String`. The tree and current-action topics
use transient-local durability. Element IDs identify nodes within a loaded tree;
they are not persistent IDs across process restarts.

A stack element can call `self.interrupt()` to create an interrupt which removes all elements except for the start element from the stack (i.e., the DSD is restarted).
Elements can attach debug values with `publish_debug_data(label, value)` and clear
them with `clear_debug_data()`. Unsupported values are represented as strings.
Debug failures cannot prevent behavior execution.

## Debugging
## Install and develop

For debugging, the DSD visualization should be used.
It automatically discovers all running DSDs with a debug topic and visualizes them in RQT.
The visualization shows the DSD tree with the currently active actions and decisions as a tree on the left and the current stack on the right.
More information can be added to the right side by calling `self.publish_debug_data(label, data)` in any action or decision.
Both packages retain their ROS 2 ament Python layout and support source and wheel
builds. In a ROS workspace, use the workspace's `rosdep` and `colcon` workflow.
ROS dependencies are declared in `package.xml`; Python dependencies are declared
in `setup.py`. Installing a wheel alone does not install ROS.

## DSD Development GUI
If you want to use a graphical user interface to define your DSD, we recommend [this repository](https://github.com/bit-bots/dsd-gui)
This repository's locked Pixi environments provide core testing, optional Qt/RQT
checks, and lightweight formatting tools. See [CONTRIBUTING.md](CONTRIBUTING.md)
for commands and dependency updates. The default test environment avoids the GUI
dependencies; the visualization CI job runs offscreen Qt and Graphviz tests.

## VSCode Extension
If you use VSCode, you can use the [DSD extension](https://marketplace.visualstudio.com/items?itemName=Mastermori.dsd) which provides syntax highlighting.
## Background and open decisions

## Examples
The architecture is described in
[DSD — Dynamic Stack Decider](https://link.springer.com/article/10.1007/s12369-021-00768-8).
The [Bit-Bots workspace](https://github.com/bit-bots/bitbots_main) contains body
behavior, humanoid control, and localization examples.

Here are a few projects that use the DSD and can be used for reference:
* The [Bit-Bots Behavior](https://github.com/bit-bots/bitbots_main/tree/main/bitbots_behavior)
* The [Bit-Bots Humanoid Control Module](https://github.com/bit-bots/bitbots_main/tree/main/bitbots_motion/bitbots_hcm/bitbots_hcm/hcm_dsd)
* The [Parser Test](https://github.com/bit-bots/dynamic_stack_decider/tree/master/dynamic_stack_decider/test) in this repository
Architectural choices are tracked separately:

## The Paper
- [Failure recovery and action outcomes](https://github.com/bit-bots/dynamic_stack_decider/issues/115)
- [ROS integration and registration boundaries](https://github.com/bit-bots/dynamic_stack_decider/issues/116)
- [Parameter grammar and outcome validation](https://github.com/bit-bots/dynamic_stack_decider/issues/117)
- [Root-chain completion and suppression scope](https://github.com/bit-bots/dynamic_stack_decider/issues/121)

The framework is also described in more depth in [DSD – Dynamic Stack Decider](https://link.springer.com/article/10.1007/s12369-021-00768-8)
The maintenance changes preserve existing chain timing and suppression behavior
while tightening invalid-input handling and lifecycle guarantees.
19 changes: 19 additions & 0 deletions dynamic_stack_decider/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
Copyright (c) Hamburg Bit-Bots

Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do
so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
3 changes: 2 additions & 1 deletion dynamic_stack_decider/MANIFEST.in
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
include README.md
include README.md LICENSE
recursive-include test *.py *.dsd
Loading
Loading